HUGE refactor. working towards web UI
This commit is contained in:
parent
dd54e8cc49
commit
1f94eecca9
29 changed files with 1669 additions and 162 deletions
46
.air.toml
Normal file
46
.air.toml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/melody-vod-manager"
|
||||
cmd = "go build -o ./tmp/melody-vod-manager ./cmd/server"
|
||||
delay = 1000
|
||||
exclude_dir = []
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html", "css"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
.DS_Store
|
||||
config.toml
|
||||
tmp
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -1,8 +1,10 @@
|
|||
# Vodular
|
||||
This tool stitches together livestream VOD segments (in `.mkv`format) and automatically uploads them to YouTube, complete with customisable metadata such as titles, descriptions, and tags.
|
||||
# Melody VOD Manager
|
||||
This tool stitches together livestream VOD segments (in `.mkv` format) and automatically uploads them to YouTube, complete with customisable metadata such as titles, descriptions, and tags.
|
||||
|
||||
I built this to greatly simplify the process of getting my full-quality livestream VODs onto YouTube, and I'm open-sourcing it in the hopes that it helps someone else with their workflow. As such, personal forks are welcome and encouraged!
|
||||
|
||||
**WARNING:** This is an experimental web-based version of [Vodular](https://codeberg.org/arimelody/vodular). This documentation has not been updated yet, and will not be updated until this version is stable. Watch this space!
|
||||
|
||||
## Quick Jump
|
||||
- [Basic Usage](#basic-usage)
|
||||
- [VOD Metadata](#vod-metadata)
|
||||
|
|
@ -11,21 +13,21 @@ I built this to greatly simplify the process of getting my full-quality livestre
|
|||
## Basic usage
|
||||
1. Run the tool for the first time to generate a starter configuration file:
|
||||
```sh
|
||||
$ vodular
|
||||
$ melody-vod-manager
|
||||
New config file created (config.toml). Please edit this file before running again!
|
||||
```
|
||||
The directory which holds your configuration file and templates varies,
|
||||
depending on platform:
|
||||
- **Linux:** `~/.config/vodular/templates`
|
||||
- **macOS:** `~/Library/Application Support/vodular/templates`
|
||||
- **Windows:** `%AppData%/vodular/templates`
|
||||
- **Linux:** `~/.config/melody-vod-manager/templates`
|
||||
- **macOS:** `~/Library/Application Support/melody-vod-manager/templates`
|
||||
- **Windows:** `%AppData%/melody-vod-manager/templates`
|
||||
|
||||
2. Edit your configuration file as necessary (You will need to create a [YouTube Data API v3](https://developers.google.com/youtube/v3) service and provide its credentials here).
|
||||
**IMPORTANT:** `config.toml` contains very sensitive credentials. Do not share this file with anyone.
|
||||
|
||||
3. Initialise a VOD directory:
|
||||
```sh
|
||||
$ vodular --init /path/to/vod
|
||||
$ melody-vod-manager --init /path/to/vod
|
||||
Directory successfully initialised. Be sure to update metadata.toml before uploading!
|
||||
```
|
||||
|
||||
|
|
@ -33,10 +35,10 @@ Directory successfully initialised. Be sure to update metadata.toml before uploa
|
|||
|
||||
5. Upload a VOD (Optionally, delete the redundant full VOD export afterwards):
|
||||
```sh
|
||||
$ vodular --deleteAfter /path/to/vod
|
||||
$ melody-vod-manager --deleteAfter /path/to/vod
|
||||
```
|
||||
|
||||
**NOTE:** On first run, you will be prompted to sign in to YouTube with the channel you wish to upload to. To sign out, simply run `vodular --logout`.
|
||||
**NOTE:** On first run, you will be prompted to sign in to YouTube with the channel you wish to upload to. To sign out, simply run `melody-vod-manager --logout`.
|
||||
|
||||
## VOD Metadata
|
||||
When `--init`ialising a directory, a `metadata.toml` file is created. This is a
|
||||
|
|
@ -69,7 +71,7 @@ url = 'https://example.org'
|
|||
|
||||
## Templates
|
||||
There are three template files, `title.txt`, `description.txt`, and `tags.txt`,
|
||||
which can be created in `/path/to/vodular/templates`. These templates can be
|
||||
which can be created in `/path/to/melody-vod-manager/templates`. These templates can be
|
||||
created and tweaked to customise your VOD metadata on upload. They are enhanced
|
||||
with Go's [template format](https://pkg.go.dev/text/template) to inject
|
||||
information provided in `metadata.toml`, and other neat functionality!
|
||||
|
|
|
|||
99
cmd/server/main.go
Normal file
99
cmd/server/main.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"syscall"
|
||||
|
||||
"arimelody.space/melody-vod-manager/config"
|
||||
"arimelody.space/melody-vod-manager/logger"
|
||||
"arimelody.space/melody-vod-manager/server"
|
||||
"arimelody.space/melody-vod-manager/templates"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mattn/go-isatty"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
var handlers []slog.Handler
|
||||
slogger := slog.New(logger.NewSlogHandler(os.Stderr, logger.Options{
|
||||
Handlers: handlers,
|
||||
IsTerminal: isatty.IsTerminal(os.Stderr.Fd()),
|
||||
Level: slog.LevelDebug,
|
||||
}))
|
||||
slog.SetDefault(slogger)
|
||||
|
||||
cliLogger := slogger.WithGroup("CLI")
|
||||
|
||||
cliLogger.Debug("Running with arguments:", "args", os.Args)
|
||||
|
||||
userConfigDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
cliLogger.Error("Could not determine user configuration directory:", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
configLocations := []string{
|
||||
"config.toml",
|
||||
path.Join(userConfigDir, "melody-vod-manager", "config.toml"),
|
||||
"/etc/melody-vod-manager/config.toml",
|
||||
}
|
||||
var cfg *config.Config
|
||||
for _, path := range configLocations {
|
||||
cliLogger.Debug("Checking config path:", "path", path)
|
||||
cfg, err = config.ReadConfig(path)
|
||||
if !os.IsNotExist(err) { break }
|
||||
}
|
||||
if cfg == nil {
|
||||
cliLogger.Error("Failed to load configuration!", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var templateDir string
|
||||
templateLocations := []string{
|
||||
path.Join(userConfigDir, "melody-vod-manager", "templates"),
|
||||
"/etc/melody-vod-manager/templates",
|
||||
}
|
||||
var textTemplates *templates.TextTemplates
|
||||
for _, dir := range templateLocations {
|
||||
cliLogger.Debug("Checking template directory:", "dir", dir)
|
||||
textTemplates, err = templates.FetchTemplates(ctx, dir)
|
||||
if err == nil { templateDir = dir }
|
||||
if !os.IsNotExist(err) { break }
|
||||
}
|
||||
if err != nil {
|
||||
cliLogger.Error("Failed to fetch templates:", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
srv, err := server.New(
|
||||
server.VODServerConfig{
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
VODDirectory: cfg.VODDirectory,
|
||||
TemplateDirectory: templateDir,
|
||||
},
|
||||
server.VODGoogleConfig{
|
||||
ApiKey: cfg.Google.ApiKey,
|
||||
ClientID: cfg.Google.ClientID,
|
||||
ClientSecret: cfg.Google.ClientSecret,
|
||||
},
|
||||
textTemplates,
|
||||
)
|
||||
if err != nil { panic(err) }
|
||||
|
||||
if err = srv.Run(ctx); err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
cliLogger.Error("A fatal error has occurred:", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
|
@ -17,21 +16,20 @@ import (
|
|||
"golang.org/x/oauth2/google"
|
||||
"google.golang.org/api/youtube/v3"
|
||||
|
||||
"arimelody.space/vodular/config"
|
||||
"arimelody.space/vodular/scanner"
|
||||
vid "arimelody.space/vodular/video"
|
||||
yt "arimelody.space/vodular/youtube"
|
||||
"arimelody.space/melody-vod-manager/config"
|
||||
"arimelody.space/melody-vod-manager/res"
|
||||
"arimelody.space/melody-vod-manager/scanner"
|
||||
"arimelody.space/melody-vod-manager/templates"
|
||||
vid "arimelody.space/melody-vod-manager/video"
|
||||
yt "arimelody.space/melody-vod-manager/youtube"
|
||||
)
|
||||
|
||||
//go:embed res/help.txt
|
||||
var helpText string
|
||||
|
||||
const SEGMENT_EXTENSION = "mkv"
|
||||
const MAX_TITLE_LEN = 100
|
||||
const MAX_DESCRIPTION_LEN = 5000
|
||||
|
||||
func showHelp() {
|
||||
fmt.Println(helpText)
|
||||
fmt.Println(res.HelpText)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +40,7 @@ func main() {
|
|||
log.Fatalf("Could not determine user configuration directory: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
config.CONFIG_FILENAME = path.Join(userConfigDir, "vodular", "config.toml")
|
||||
config.CONFIG_FILENAME = path.Join(userConfigDir, "melody-vod-manager", "config.toml")
|
||||
cfg, err := config.ReadConfig(config.CONFIG_FILENAME)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read config: %v", err)
|
||||
|
|
@ -154,7 +152,7 @@ func main() {
|
|||
}
|
||||
|
||||
// good to have early on
|
||||
templates, err := yt.FetchTemplates(path.Join(userConfigDir, "vodular", "templates"))
|
||||
templates, err := templates.FetchTemplates(context.Background(), path.Join(userConfigDir, "melody-vod-manager", "templates"))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to fetch templates: %v", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -288,12 +286,21 @@ func main() {
|
|||
|
||||
// youtube oauth flow
|
||||
ctx := context.Background()
|
||||
var redirectUri string
|
||||
if cfg.Secure {
|
||||
redirectUri = fmt.Sprintf("https://%s", cfg.Host)
|
||||
} else {
|
||||
redirectUri = fmt.Sprintf("http://%s", cfg.Host)
|
||||
}
|
||||
if !(cfg.Secure && cfg.Port == 443) && !(!cfg.Secure && cfg.Port == 80) {
|
||||
redirectUri += fmt.Sprintf(":%d", cfg.Port)
|
||||
}
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: cfg.Google.ClientID,
|
||||
ClientSecret: cfg.Google.ClientSecret,
|
||||
Endpoint: google.Endpoint,
|
||||
Scopes: []string{ youtube.YoutubeScope },
|
||||
RedirectURL: cfg.RedirectUri,
|
||||
RedirectURL: redirectUri,
|
||||
}
|
||||
var token *oauth2.Token
|
||||
if cfg.Token != nil {
|
||||
24
colour/colour.go
Normal file
24
colour/colour.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package colour
|
||||
|
||||
const Reset = "\033[0m"
|
||||
const Bright = "\033[1m"
|
||||
const Dim = "\033[2m"
|
||||
|
||||
const Red = "\033[31m"
|
||||
const Green = "\033[32m"
|
||||
const Yellow = "\033[33m"
|
||||
const Blue = "\033[34m"
|
||||
const Purple = "\033[35m"
|
||||
const Cyan = "\033[36m"
|
||||
const Gray = "\033[37m"
|
||||
const White = "\033[97m"
|
||||
|
||||
var AllColours = []string{
|
||||
Red,
|
||||
Green,
|
||||
Yellow,
|
||||
Blue,
|
||||
Purple,
|
||||
Cyan,
|
||||
White,
|
||||
}
|
||||
9
config.example.toml
Normal file
9
config.example.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
host = '0.0.0.0'
|
||||
secure = false
|
||||
port = 8080
|
||||
vod_directory = '/media/videos'
|
||||
|
||||
[google]
|
||||
api_key = '<your API key here>'
|
||||
client_id = '<your client ID here>'
|
||||
client_secret = '<your client secret here>'
|
||||
|
|
@ -16,16 +16,19 @@ type (
|
|||
}
|
||||
|
||||
Config struct {
|
||||
Host string `toml:"host" comment:"Address to host OAuth2 redirect flow"`
|
||||
RedirectUri string `toml:"redirect_uri" comment:"URI to use in Google OAuth2 flow"`
|
||||
Google GoogleConfig `toml:"google"`
|
||||
Token *oauth2.Token `toml:"token" comment:"This section is filled in automatically on a successful authentication flow."`
|
||||
Host string `toml:"host" comment:"Address to host OAuth2 redirect flow"`
|
||||
Secure bool `toml:"bool" comment:"Whether or not host is secured with TLS (HTTPS)"`
|
||||
Port int16 `toml:"port"`
|
||||
VODDirectory string `toml:"vod_directory" comment:"Directory to be used for VOD management"`
|
||||
Google GoogleConfig `toml:"google"`
|
||||
Token *oauth2.Token `toml:"token" comment:"This section is filled in automatically on a successful authentication flow."`
|
||||
}
|
||||
)
|
||||
|
||||
var defaultConfig = Config{
|
||||
Host: "localhost:8090",
|
||||
RedirectUri: "http://localhost:8090",
|
||||
Host: "localhost",
|
||||
Secure: false,
|
||||
Port: 8080,
|
||||
Google: GoogleConfig{
|
||||
ApiKey: "<your API key here>",
|
||||
ClientID: "<your client ID here>",
|
||||
|
|
@ -38,15 +41,12 @@ var CONFIG_FILENAME string = "config.toml"
|
|||
func ReadConfig(filename string) (*Config, error) {
|
||||
cfgBytes, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to open file: %v", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
err = toml.Unmarshal(cfgBytes, &config)
|
||||
if err != nil { return &config, fmt.Errorf("failed to parse: %v", err) }
|
||||
if err != nil { return nil, err }
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
|
|
|||
34
go.mod
34
go.mod
|
|
@ -1,4 +1,4 @@
|
|||
module arimelody.space/vodular
|
||||
module arimelody.space/melody-vod-manager
|
||||
|
||||
go 1.25.3
|
||||
|
||||
|
|
@ -13,25 +13,49 @@ require (
|
|||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/aws/aws-sdk-go v1.38.20 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/u2takey/ffmpeg-go v0.5.0 // indirect
|
||||
github.com/u2takey/go-utils v0.3.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/grpc v1.76.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
|
|
|
|||
65
go.sum
65
go.sum
|
|
@ -6,6 +6,14 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
|
|||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
github.com/aws/aws-sdk-go v1.38.20 h1:QbzNx/tdfATbdKfubBpkt84OM6oBkxQZRw6+bW2GyeA=
|
||||
github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
|
@ -13,12 +21,28 @@ github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44am
|
|||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
|
|
@ -39,12 +63,23 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
|
|||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
|
|
@ -52,17 +87,34 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
|||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU=
|
||||
github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc=
|
||||
github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys=
|
||||
github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
|
|
@ -78,30 +130,42 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVW
|
|||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
|
@ -123,6 +187,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
|||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
|
||||
|
|
|
|||
189
logger/slog.go
Normal file
189
logger/slog.go
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"arimelody.space/melody-vod-manager/colour"
|
||||
"github.com/mattn/go-isatty"
|
||||
)
|
||||
|
||||
type (
|
||||
Options struct {
|
||||
Handlers []slog.Handler
|
||||
Level slog.Leveler
|
||||
IsTerminal bool
|
||||
}
|
||||
|
||||
SlogHandler struct {
|
||||
attrs []slog.Attr
|
||||
group string
|
||||
mutex *sync.Mutex
|
||||
options Options
|
||||
writer io.Writer
|
||||
}
|
||||
)
|
||||
|
||||
const groupPadding = 11
|
||||
const levelPadding = 5
|
||||
|
||||
func NewSlogHandler(writer io.Writer, options Options) *SlogHandler {
|
||||
writerFile, writerIsFile := writer.(*os.File)
|
||||
if writerIsFile {
|
||||
options.IsTerminal = isatty.IsTerminal(writerFile.Fd())
|
||||
}
|
||||
|
||||
return &SlogHandler{
|
||||
options: options,
|
||||
attrs: []slog.Attr{},
|
||||
group: "OTHER",
|
||||
mutex: &sync.Mutex{},
|
||||
writer: writer,
|
||||
}
|
||||
}
|
||||
|
||||
func (log *SlogHandler) appendRecord(buf *strings.Builder, record slog.Record) {
|
||||
log.appendTime(buf, record.Time)
|
||||
log.appendGroup(buf, log.group)
|
||||
log.appendLevel(buf, record.Level)
|
||||
log.appendMessage(buf, record.Message)
|
||||
|
||||
for _, attr := range log.attrs {
|
||||
log.appendAttr(buf, attr)
|
||||
}
|
||||
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
log.appendAttr(buf, attr)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (log *SlogHandler) appendTime(buf *strings.Builder, t time.Time) {
|
||||
if !t.IsZero() {
|
||||
if log.options.IsTerminal {
|
||||
buf.WriteString(colour.Dim + colour.Gray)
|
||||
}
|
||||
buf.WriteRune('[')
|
||||
buf.WriteString(t.Format(time.UnixDate))
|
||||
buf.WriteRune(']')
|
||||
if log.options.IsTerminal {
|
||||
buf.WriteString(colour.Reset)
|
||||
}
|
||||
buf.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
|
||||
func (log *SlogHandler) appendGroup(buf *strings.Builder, group string) {
|
||||
if log.options.IsTerminal {
|
||||
buf.WriteString(colour.Dim + colour.Gray)
|
||||
}
|
||||
log.appendPadLeft(buf, groupPadding, group)
|
||||
if log.options.IsTerminal {
|
||||
buf.WriteString(colour.Reset)
|
||||
}
|
||||
buf.WriteRune(' ')
|
||||
}
|
||||
|
||||
func (log *SlogHandler) appendPadLeft(buf *strings.Builder, padding int, content string) {
|
||||
padding -= len(content)
|
||||
for i := padding; i > 0; i-- {
|
||||
buf.WriteRune(' ')
|
||||
}
|
||||
buf.WriteString(content)
|
||||
}
|
||||
|
||||
func (log *SlogHandler) appendPadRight(buf *strings.Builder, padding int, content string) {
|
||||
buf.WriteString(content)
|
||||
padding -= len(content)
|
||||
for i := padding; i > 0; i-- {
|
||||
buf.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
|
||||
func (log *SlogHandler) appendLevel(buf *strings.Builder, level slog.Level) {
|
||||
if log.options.IsTerminal {
|
||||
levelColour := colour.Reset
|
||||
switch level {
|
||||
case slog.LevelDebug:
|
||||
levelColour = colour.Purple
|
||||
case slog.LevelError:
|
||||
levelColour = colour.Red
|
||||
case slog.LevelWarn:
|
||||
levelColour = colour.Yellow
|
||||
case slog.LevelInfo:
|
||||
levelColour = colour.Cyan
|
||||
}
|
||||
buf.WriteString(levelColour)
|
||||
}
|
||||
log.appendPadRight(buf, levelPadding, level.String())
|
||||
if log.options.IsTerminal {
|
||||
buf.WriteString(colour.Reset)
|
||||
}
|
||||
buf.WriteRune(' ')
|
||||
}
|
||||
|
||||
func (log *SlogHandler) appendMessage(buf *strings.Builder, message string) {
|
||||
if message != "" {
|
||||
buf.WriteString(message)
|
||||
buf.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
|
||||
func (log *SlogHandler) appendAttr(buf *strings.Builder, attr slog.Attr) {
|
||||
buf.WriteString(attr.String())
|
||||
buf.WriteRune(' ')
|
||||
}
|
||||
|
||||
func (log *SlogHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return level >= log.options.Level.Level()
|
||||
}
|
||||
|
||||
func (log *SlogHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
buf := &strings.Builder{}
|
||||
|
||||
log.appendRecord(buf, record)
|
||||
|
||||
buf.WriteRune('\n')
|
||||
|
||||
log.mutex.Lock()
|
||||
defer log.mutex.Unlock()
|
||||
|
||||
if _, err := log.writer.Write([]byte(buf.String())); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, handler := range log.options.Handlers {
|
||||
if err := handler.Handle(ctx, record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (log *SlogHandler) WithGroup(group string) slog.Handler {
|
||||
log2 := *log
|
||||
log2.group = group
|
||||
log2.options.Handlers = make([]slog.Handler, len(log.options.Handlers))
|
||||
for i, handler := range log.options.Handlers {
|
||||
log.options.Handlers[i] = handler.WithGroup(group)
|
||||
}
|
||||
return &log2
|
||||
}
|
||||
|
||||
func (log *SlogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
log2 := *log
|
||||
log2.attrs = make([]slog.Attr, 0, len(log.attrs)+len(attrs))
|
||||
log2.attrs = append(log2.attrs, log.attrs...)
|
||||
log2.attrs = append(log2.attrs, attrs...)
|
||||
log2.options.Handlers = make([]slog.Handler, len(log.options.Handlers))
|
||||
for i, handler := range log.options.Handlers {
|
||||
log.options.Handlers[i] = handler.WithAttrs(attrs)
|
||||
}
|
||||
return &log2
|
||||
}
|
||||
26
public/script/vod.js
Normal file
26
public/script/vod.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
const uploadState = document.getElementById("upload-state");
|
||||
console.log(uploadState);
|
||||
|
||||
function start() {
|
||||
const source = new EventSource("/sse?" + new URLSearchParams({"vod": location.pathname.split("/vods")[1]}).toString());
|
||||
source.addEventListener("message", event => {
|
||||
const data = JSON.parse(event.data);
|
||||
switch (data.state) {
|
||||
case "none":
|
||||
uploadState.textContent = "Yes";
|
||||
source.close();
|
||||
break;
|
||||
case "processing":
|
||||
uploadState.textContent = `Processing (${data.progress}%)`;
|
||||
break;
|
||||
case "uploading":
|
||||
uploadState.textContent = `Uploading (${data.progress}%)`;
|
||||
break;
|
||||
case "complete":
|
||||
uploadState.textContent = "Yes";
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
start();
|
||||
9
public/static.go
Normal file
9
public/static.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package public
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed style
|
||||
var StyleFS embed.FS
|
||||
|
||||
//go:embed script
|
||||
var ScriptFS embed.FS
|
||||
1
public/style/index.css
Normal file
1
public/style/index.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import url("/style/main.css");
|
||||
237
public/style/main.css
Normal file
237
public/style/main.css
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
:root {
|
||||
--bg-0: #101010;
|
||||
--bg-1: #181818;
|
||||
--bg-2: #282828;
|
||||
--bg-3: #404040;
|
||||
|
||||
--fg-0: #b0b0b0;
|
||||
--fg-1: #c0c0c0;
|
||||
--fg-2: #d0d0d0;
|
||||
--fg-3: #e0e0e0;
|
||||
|
||||
--col-shadow-0: #0002;
|
||||
--col-shadow-1: #0004;
|
||||
--col-shadow-2: #0006;
|
||||
--col-highlight-0: #ffffff08;
|
||||
--col-highlight-1: #fff1;
|
||||
--col-highlight-2: #fff2;
|
||||
|
||||
--col-new: #b3ee5b;
|
||||
--col-on-new: #1b2013;
|
||||
--col-save: #6fd7ff;
|
||||
--col-on-save: #283f48;
|
||||
--col-delete: #ff7171;
|
||||
--col-on-delete: #371919;
|
||||
|
||||
--col-warn: #ffe86a;
|
||||
--col-on-warn: var(--bg-0);
|
||||
--col-warn-hover: #ffec81;
|
||||
|
||||
--col-link: #a3ef81;
|
||||
|
||||
--shadow-sm:
|
||||
0 1px 2px var(--col-shadow-2),
|
||||
inset 0 1px 1px var(--col-highlight-2);
|
||||
--shadow-md:
|
||||
0 2px 4px var(--col-shadow-1),
|
||||
inset 0 2px 2px var(--col-highlight-1);
|
||||
--shadow-lg:
|
||||
0 4px 8px var(--col-shadow-0),
|
||||
inset 0 4px 4px var(--col-highlight-0);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg-0: #e8e8e8;
|
||||
--bg-1: #f0f0f0;
|
||||
--bg-2: #f8f8f8;
|
||||
--bg-3: #ffffff;
|
||||
|
||||
--fg-0: #606060;
|
||||
--fg-1: #404040;
|
||||
--fg-2: #303030;
|
||||
--fg-3: #202020;
|
||||
|
||||
--col-shadow-0: #0002;
|
||||
--col-shadow-1: #0004;
|
||||
--col-shadow-2: #0008;
|
||||
--col-highlight-0: #fff2;
|
||||
--col-highlight-1: #fff4;
|
||||
--col-highlight-2: #fff8;
|
||||
|
||||
--col-warn: #ffe86a;
|
||||
--col-on-warn: var(--fg-3);
|
||||
--col-warn-hover: #ffec81;
|
||||
|
||||
--col-link: #3c7423;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 16px;
|
||||
color: var(--fg-0);
|
||||
background-color: var(--bg-0);
|
||||
|
||||
transition: background .1s ease-out, color .1s ease-out;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
main {
|
||||
width: 900px;
|
||||
max-width: calc(100% - 2em);
|
||||
height: fit-content;
|
||||
min-height: calc(100vh - 2em);
|
||||
margin: 0 auto;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--col-link);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color .1s ease-out, background-color .1s ease-out;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
img.icon {
|
||||
height: .8em;
|
||||
transition: filter .1s ease-out;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #303030;
|
||||
color: #f0f0f0;
|
||||
padding: .23em .3em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
a.delete:not(.button) {
|
||||
color: #d22828;
|
||||
}
|
||||
|
||||
.button, button {
|
||||
padding: .5em .8em;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
|
||||
color: inherit;
|
||||
background-color: var(--bg-2);
|
||||
border: none;
|
||||
border-radius: 10em;
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-weight: 500;
|
||||
transition: background .1s ease-out, color .1s ease-out;
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
button:hover, .button:hover {
|
||||
background: #fff;
|
||||
}
|
||||
button:active, .button:active {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
|
||||
.button.new, button.new {
|
||||
color: var(--col-on-new);
|
||||
background-color: var(--col-new);
|
||||
}
|
||||
.button.save, button.save {
|
||||
color: var(--col-on-save);
|
||||
background-color: var(--col-save);
|
||||
}
|
||||
.button.delete, button.delete {
|
||||
color: var(--col-on-delete);
|
||||
background-color: var(--col-delete);
|
||||
}
|
||||
.button:hover, button:hover {
|
||||
color: var(--bg-3);
|
||||
background-color: var(--fg-3);
|
||||
}
|
||||
.button:active, button:active {
|
||||
color: var(--bg-2);
|
||||
background-color: var(--fg-0);
|
||||
}
|
||||
.button[disabled], button[disabled] {
|
||||
color: var(--fg-0) !important;
|
||||
background-color: var(--bg-3) !important;
|
||||
opacity: .5;
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
form {
|
||||
width: 100%;
|
||||
display: block;
|
||||
color: var(--fg-0);
|
||||
}
|
||||
form label {
|
||||
width: 100%;
|
||||
margin: 1rem 0 .5rem 0;
|
||||
display: block;
|
||||
}
|
||||
form input {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
margin: .5em 0;
|
||||
padding: .3em .5em;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #808080;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
background-color: var(--bg-0);
|
||||
}
|
||||
form input[type="text"],
|
||||
form input[type="password"] {
|
||||
width: 16em;
|
||||
}
|
||||
form input[type="number"] {
|
||||
width: 2em;
|
||||
}
|
||||
input[disabled] {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
main {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
width: 900px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: 0 auto;
|
||||
padding: 1em;
|
||||
|
||||
nav a {
|
||||
padding: .2em .4em;
|
||||
border-radius: 4px;
|
||||
background-color: var(--bg-1);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
}
|
||||
5
public/style/vod-list.css
Normal file
5
public/style/vod-list.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
@import url("/style/main.css");
|
||||
|
||||
table {
|
||||
|
||||
}
|
||||
7
public/style/vod.css
Normal file
7
public/style/vod.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
@import url("/style/main.css");
|
||||
|
||||
.metadata-top {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1em;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
Vodular
|
||||
Melody VOD Manager
|
||||
|
||||
USAGE: vodular [options] [directory]
|
||||
USAGE: melody-vod-manager [options] [directory]
|
||||
|
||||
OPTIONS:
|
||||
-h, --help: Show this help message.
|
||||
|
|
@ -10,7 +10,7 @@ OPTIONS:
|
|||
-d, --deleteAfter: Deletes the full VOD after upload.
|
||||
-f, --force: Force uploading the VOD, even if it already exists.
|
||||
|
||||
SOURCE: https://codeberg.org/arimelody/vodular
|
||||
ISSUES: https://codeberg.org/arimelody/vodular/issues
|
||||
SOURCE: https://codeberg.org/arimelody/melody-vod-manager
|
||||
ISSUES: https://codeberg.org/arimelody/melody-vod-manager/issues
|
||||
|
||||
made with <3 by ari melody, 2026
|
||||
|
|
|
|||
8
res/res.go
Normal file
8
res/res.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package res
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed help.txt
|
||||
var HelpText string
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
package scanner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
|
|
@ -14,19 +17,19 @@ import (
|
|||
|
||||
type (
|
||||
Category struct {
|
||||
Name string `toml:"name"`
|
||||
Type string `toml:"type" comment:"Valid types: gaming, other (default: other)"`
|
||||
Url string `toml:"url"`
|
||||
Name string `toml:"name" json:"name"`
|
||||
Type string `toml:"type" json:"type" comment:"Valid types: gaming, other (default: other)"`
|
||||
Url string `toml:"url" json:"url"`
|
||||
}
|
||||
|
||||
Metadata struct {
|
||||
Title string `toml:"title"`
|
||||
Part int `toml:"part"`
|
||||
Date string `toml:"date"`
|
||||
Tags []string `toml:"tags"`
|
||||
FootageDir string `toml:"footage_dir"`
|
||||
Uploaded bool `toml:"uploaded"`
|
||||
Category *Category `toml:"category" comment:"(Optional) Category details, for additional credits."`
|
||||
Title string `toml:"title" json:"title"`
|
||||
Part int `toml:"part" json:"part"`
|
||||
Date toml.LocalDate `toml:"date" json:"date"`
|
||||
Tags []string `toml:"tags" json:"tags"`
|
||||
FootageDir string `toml:"footage_dir" json:"footage_dir"`
|
||||
Uploaded bool `toml:"uploaded" json:"uploaded"`
|
||||
Category *Category `toml:"category" json:"category" comment:"(Optional) Category details, for additional credits."`
|
||||
}
|
||||
|
||||
FFprobeFormat struct {
|
||||
|
|
@ -107,25 +110,36 @@ func ReadMetadata(directory string) (*Metadata, error) {
|
|||
}
|
||||
|
||||
func WriteMetadata(directory string, metadata *Metadata) (error) {
|
||||
file, err := os.OpenFile(
|
||||
path.Join(directory, METADATA_FILENAME),
|
||||
os.O_CREATE | os.O_RDWR, 0644,
|
||||
)
|
||||
data, err := toml.Marshal(metadata)
|
||||
if err != nil { return err }
|
||||
|
||||
err = toml.NewEncoder(file).Encode(metadata)
|
||||
err = os.WriteFile(path.Join(directory, METADATA_FILENAME), data, 0644)
|
||||
return err
|
||||
}
|
||||
|
||||
func DefaultMetadata() *Metadata {
|
||||
return &Metadata{
|
||||
Title: "Untitled Stream",
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
Part: 0,
|
||||
Category: &Category{
|
||||
Name: "Something",
|
||||
Type: "",
|
||||
Url: "",
|
||||
Date: toml.LocalDate{
|
||||
Year: time.Now().Year(),
|
||||
Month: int(time.Now().Month()),
|
||||
Day: time.Now().Day(),
|
||||
},
|
||||
Part: 0,
|
||||
Category: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func BuildTemplate(metadata *Metadata, tmpl *template.Template) (string, error) {
|
||||
out := &bytes.Buffer{}
|
||||
err := tmpl.Execute(out, metadata)
|
||||
return strings.TrimSpace(out.String()), err
|
||||
}
|
||||
|
||||
func FileSizeString(size int64) string {
|
||||
denomination := "B"
|
||||
if size > 1000 { size /= 1000; denomination = "KB" }
|
||||
if size > 1000 { size /= 1000; denomination = "MB" }
|
||||
if size > 1000 { size /= 1000; denomination = "GB" }
|
||||
if size > 1000 { size /= 1000; denomination = "TB" }
|
||||
return fmt.Sprintf("%d%s", size, denomination)
|
||||
}
|
||||
|
|
|
|||
482
server/server.go
Normal file
482
server/server.go
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
|
||||
"arimelody.space/melody-vod-manager/public"
|
||||
"arimelody.space/melody-vod-manager/scanner"
|
||||
"arimelody.space/melody-vod-manager/templates"
|
||||
)
|
||||
|
||||
type (
|
||||
VODGoogleConfig struct {
|
||||
ApiKey string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
VODServerConfig struct {
|
||||
Host string
|
||||
Port int16
|
||||
VODDirectory string
|
||||
TemplateDirectory string
|
||||
}
|
||||
|
||||
VODServer struct {
|
||||
config VODServerConfig
|
||||
google VODGoogleConfig
|
||||
googleToken string
|
||||
|
||||
textTemplates *templates.TextTemplates
|
||||
|
||||
started sync.Mutex
|
||||
logger *slog.Logger
|
||||
router *gin.Engine
|
||||
}
|
||||
)
|
||||
|
||||
func New(config VODServerConfig, google VODGoogleConfig, textTemplates *templates.TextTemplates) (*VODServer, error) {
|
||||
server := &VODServer{
|
||||
started: sync.Mutex{},
|
||||
logger: slog.Default().WithGroup("VOD-MANAGER"),
|
||||
router: gin.Default(),
|
||||
}
|
||||
|
||||
server.config = config
|
||||
server.google = google
|
||||
server.textTemplates = textTemplates
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func (srv *VODServer) Run(ctx context.Context) error {
|
||||
if !srv.started.TryLock() {
|
||||
return errors.New("server already started")
|
||||
}
|
||||
defer srv.started.Unlock()
|
||||
|
||||
failed := make(chan error)
|
||||
|
||||
go func() {
|
||||
err := srv.start(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
failed <- err
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-failed:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
srv.stop(ctx)
|
||||
return context.Canceled
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *VODServer) handleGetVOD(ctx *gin.Context, vodDir string, metadata *scanner.Metadata) {
|
||||
if ctx.Request.Header.Get("Accept") == "application/json" {
|
||||
ctx.JSON(http.StatusOK, metadata)
|
||||
return
|
||||
}
|
||||
|
||||
type (
|
||||
VODFootageBlock struct {
|
||||
Name string
|
||||
Size string
|
||||
SizeBytes int64
|
||||
}
|
||||
|
||||
VOD struct {
|
||||
Title string
|
||||
Part uint
|
||||
Date time.Time
|
||||
TitleFormatted string
|
||||
Tags []string
|
||||
Category *scanner.Category
|
||||
DescriptionFormatted string
|
||||
Uploaded bool
|
||||
FootageDir string
|
||||
Footage []VODFootageBlock
|
||||
Directory string
|
||||
}
|
||||
)
|
||||
|
||||
var err error
|
||||
|
||||
textTemplateData := templates.TextTemplateData{
|
||||
Title: metadata.Title,
|
||||
Part: uint(metadata.Part),
|
||||
Date: metadata.Date.AsTime(time.UTC),
|
||||
Category: metadata.Category,
|
||||
}
|
||||
|
||||
titleFormatted := bytes.Buffer{}
|
||||
err = srv.textTemplates.Title.Execute(&titleFormatted, textTemplateData)
|
||||
if err != nil {
|
||||
srv.logger.Error("Failed to format VOD title:", "dir", vodDir, "err", err)
|
||||
ctx.String(
|
||||
http.StatusInternalServerError,
|
||||
http.StatusText(http.StatusInternalServerError),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
descriptionFormatted := bytes.Buffer{}
|
||||
err = srv.textTemplates.Description.Execute(&descriptionFormatted, textTemplateData)
|
||||
if err != nil {
|
||||
srv.logger.Error("Failed to format VOD description:", "dir", vodDir, "err", err)
|
||||
ctx.String(
|
||||
http.StatusInternalServerError,
|
||||
http.StatusText(http.StatusInternalServerError),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
vod := VOD{
|
||||
Title: metadata.Title,
|
||||
Part: uint(metadata.Part),
|
||||
Date: metadata.Date.AsTime(time.UTC),
|
||||
TitleFormatted: titleFormatted.String(),
|
||||
Tags: metadata.Tags,
|
||||
Category: metadata.Category,
|
||||
DescriptionFormatted: descriptionFormatted.String(),
|
||||
Uploaded: metadata.Uploaded,
|
||||
FootageDir: metadata.FootageDir,
|
||||
Footage: []VODFootageBlock{},
|
||||
Directory: strings.TrimPrefix(vodDir[:strings.LastIndex(vodDir, "/")], srv.config.VODDirectory),
|
||||
}
|
||||
footageDir := path.Join(vodDir, metadata.FootageDir)
|
||||
files, err := os.ReadDir(footageDir)
|
||||
if err != nil {
|
||||
srv.logger.Error("Failed to read footage directory:", "dir", footageDir, "err", err)
|
||||
ctx.String(
|
||||
http.StatusInternalServerError,
|
||||
http.StatusText(http.StatusInternalServerError),
|
||||
)
|
||||
}
|
||||
for _, file := range files {
|
||||
if file.IsDir() || !strings.HasSuffix(file.Name(), "mkv") { continue }
|
||||
filePath := path.Join(vodDir, metadata.FootageDir, file.Name())
|
||||
stat, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
srv.logger.Error("Failed to stat file:", "path", filePath, "err", err)
|
||||
ctx.String(
|
||||
http.StatusInternalServerError,
|
||||
http.StatusText(http.StatusInternalServerError),
|
||||
)
|
||||
}
|
||||
|
||||
vod.Footage = append(vod.Footage, VODFootageBlock{
|
||||
Name: file.Name(),
|
||||
Size: scanner.FileSizeString(stat.Size()),
|
||||
SizeBytes: stat.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := templates.VODTemplate.Execute(ctx.Writer, vod); err != nil {
|
||||
srv.logger.Error("Failed to render VOD template:", "err", err)
|
||||
ctx.String(
|
||||
http.StatusInternalServerError,
|
||||
http.StatusText(http.StatusInternalServerError),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *VODServer) handleInitVODDirectory (ctx *gin.Context, vodDir string) {
|
||||
srv.logger.Debug("Initialising VOD directory:", "dir", vodDir)
|
||||
|
||||
metadata := scanner.DefaultMetadata()
|
||||
err := scanner.WriteMetadata(vodDir, metadata)
|
||||
if err != nil {
|
||||
srv.logger.Error("Failed to initialise VOD directory:", "dir", vodDir, "err", err)
|
||||
ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Redirect(
|
||||
http.StatusFound,
|
||||
fmt.Sprintf("/vods%s", strings.TrimPrefix(vodDir, srv.config.VODDirectory)),
|
||||
)
|
||||
}
|
||||
|
||||
func (srv *VODServer) handleListVODs(ctx *gin.Context, queryDir string) {
|
||||
stat, err := os.Stat(queryDir)
|
||||
|
||||
if !stat.IsDir() {
|
||||
http.ServeFile(ctx.Writer, ctx.Request, queryDir)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Query("init") == "true" {
|
||||
srv.handleInitVODDirectory(ctx, queryDir)
|
||||
return
|
||||
}
|
||||
|
||||
metadata, err := scanner.ReadMetadata(queryDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
srv.logger.Error("Failed to read metadata:", "err", err)
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if metadata != nil {
|
||||
srv.handleGetVOD(ctx, queryDir, metadata)
|
||||
return
|
||||
}
|
||||
|
||||
dirs, err := os.ReadDir(queryDir)
|
||||
if err != nil {
|
||||
srv.logger.Error("Failed to read directory:", "dir", queryDir, "err", err)
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type (
|
||||
VODListItem struct {
|
||||
Title string `json:"title"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
FileItem struct {
|
||||
Name string `json:"name"`
|
||||
Size string `json:"-"`
|
||||
SizeBytes int64 `json:"size"`
|
||||
}
|
||||
|
||||
ListVODsResponse struct {
|
||||
VODs []VODListItem `json:"vods"`
|
||||
Directories []string `json:"directories"`
|
||||
Files []FileItem `json:"files"`
|
||||
}
|
||||
|
||||
ListVODsHTMLResponse struct {
|
||||
ListVODsResponse
|
||||
Directory string
|
||||
}
|
||||
)
|
||||
|
||||
res := ListVODsResponse{
|
||||
VODs: []VODListItem{},
|
||||
Directories: []string{},
|
||||
Files: []FileItem{},
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if dir.IsDir() {
|
||||
fullPath := path.Join(queryDir, dir.Name())
|
||||
metadata, err := scanner.ReadMetadata(fullPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
slog.Error("Failed to read metadata:", "dir", fullPath, "err", err)
|
||||
// soft fail (list as directory)
|
||||
}
|
||||
if metadata != nil {
|
||||
titleFormatted := &bytes.Buffer{}
|
||||
if err := srv.textTemplates.Title.Execute(titleFormatted, templates.TextTemplateData{
|
||||
Title: metadata.Title,
|
||||
Part: uint(metadata.Part),
|
||||
Date: metadata.Date.AsTime(time.UTC),
|
||||
Category: metadata.Category,
|
||||
}); err != nil {
|
||||
srv.logger.Error("Failed to format VOD title:", "vodPath", fullPath, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
res.VODs = append(res.VODs, VODListItem{
|
||||
Title: strings.TrimSpace(titleFormatted.String()),
|
||||
Path: dir.Name(),
|
||||
})
|
||||
} else {
|
||||
res.Directories = append(res.Directories, dir.Name())
|
||||
}
|
||||
} else {
|
||||
filePath := path.Join(queryDir, dir.Name())
|
||||
stat, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
srv.logger.Error("Failed to stat file:", "path", filePath, "err", err)
|
||||
continue
|
||||
}
|
||||
res.Files = append(res.Files, FileItem{
|
||||
Name: stat.Name(),
|
||||
Size: scanner.FileSizeString(stat.Size()),
|
||||
SizeBytes: stat.Size(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Header("cache-control", "max-age=0")
|
||||
if ctx.GetHeader("accept") == "application/json" {
|
||||
ctx.JSON(http.StatusOK, res)
|
||||
} else {
|
||||
if err := templates.VODListTemplate.Execute(ctx.Writer, ListVODsHTMLResponse{
|
||||
ListVODsResponse: res,
|
||||
Directory: strings.TrimPrefix(queryDir, srv.config.VODDirectory),
|
||||
}); err != nil {
|
||||
srv.logger.Error("Failed to render VOD list template:", "err", err)
|
||||
ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *VODServer) handleUpdateVOD(ctx *gin.Context, vodDir string) {
|
||||
metadata, err := scanner.ReadMetadata(vodDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
http.NotFound(ctx.Writer, ctx.Request)
|
||||
return
|
||||
}
|
||||
ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
return
|
||||
}
|
||||
|
||||
if title := ctx.PostForm("title"); title != "" { metadata.Title = title }
|
||||
if part, err := strconv.Atoi(ctx.PostForm("part")); err == nil && part > 0 { metadata.Part = part }
|
||||
if date, err := time.Parse("2006-01-02", ctx.PostForm("date")); err == nil { metadata.Date = toml.LocalDate{
|
||||
Year: date.Year(), Month: int(date.Month()), Day: date.Day(),
|
||||
} }
|
||||
if ctx.PostForm("category-enabled") == "on" {
|
||||
metadata.Category = &scanner.Category{}
|
||||
if categoryName := ctx.PostForm("category-name"); categoryName != "" { metadata.Category.Name = categoryName }
|
||||
if categoryType := ctx.PostForm("category-type"); categoryType != "" { metadata.Category.Type = categoryType }
|
||||
if categoryUrl := ctx.PostForm("category-url"); categoryUrl != "" { metadata.Category.Url = categoryUrl }
|
||||
} else {
|
||||
metadata.Category = nil
|
||||
}
|
||||
|
||||
err = scanner.WriteMetadata(vodDir, metadata)
|
||||
if err != nil {
|
||||
srv.logger.Error("Failed to write VOD metadata:", "dir", vodDir, "err", err)
|
||||
ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Redirect(
|
||||
http.StatusFound,
|
||||
fmt.Sprintf("/vods%s", strings.TrimPrefix(vodDir, srv.config.VODDirectory)),
|
||||
)
|
||||
}
|
||||
|
||||
func (srv *VODServer) start(ctx context.Context) error {
|
||||
srv.logger.Info("Starting server...")
|
||||
|
||||
srv.router.GET("/_health", func(ctx *gin.Context) {
|
||||
ctx.String(http.StatusOK, http.StatusText(http.StatusOK))
|
||||
})
|
||||
|
||||
srv.router.GET("/", func(ctx *gin.Context) {
|
||||
if err := templates.IndexTemplate.Execute(ctx.Writer, nil); err != nil {
|
||||
ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
})
|
||||
|
||||
srv.router.GET("/vods", func(ctx *gin.Context) {
|
||||
srv.handleListVODs(ctx, srv.config.VODDirectory)
|
||||
})
|
||||
srv.router.GET("/vods/*path", func(ctx *gin.Context) {
|
||||
srv.handleListVODs(ctx, path.Join(srv.config.VODDirectory, ctx.Param("path")))
|
||||
})
|
||||
srv.router.POST("/vods/*path", func(ctx *gin.Context) {
|
||||
srv.handleUpdateVOD(ctx, path.Join(srv.config.VODDirectory, ctx.Param("path")))
|
||||
})
|
||||
|
||||
srv.router.GET("/sse", func(ctx *gin.Context) {
|
||||
queryEscape, err := url.PathUnescape(ctx.Query("vod"))
|
||||
if err != nil {
|
||||
srv.logger.Error("Failed to parse SSE query string:", "err", err)
|
||||
ctx.String(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
|
||||
vodDir := path.Join(srv.config.VODDirectory, queryEscape)
|
||||
srv.logger.Debug("SSE connection:", "vod", vodDir)
|
||||
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
ctx.Stream(func(w io.Writer) bool {
|
||||
<-ticker.C
|
||||
ctx.SSEvent("", gin.H{
|
||||
"state": "uploading",
|
||||
"progress": "13.37",
|
||||
})
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
srv.router.GET("/templates/title", func(ctx *gin.Context) {
|
||||
tmpl := templates.DefaultTitleTemplate
|
||||
var titlePath = path.Join(srv.config.TemplateDirectory, "title.txt")
|
||||
if titleFile, err := os.ReadFile(titlePath); err == nil {
|
||||
ctx.String(http.StatusOK, string(titleFile))
|
||||
return
|
||||
}
|
||||
ctx.String(http.StatusOK, tmpl)
|
||||
})
|
||||
srv.router.GET("/templates/description", func(ctx *gin.Context) {
|
||||
tmpl := templates.DefaultDescriptionTemplate
|
||||
var descriptionPath = path.Join(srv.config.TemplateDirectory, "description.txt")
|
||||
if descriptionFile, err := os.ReadFile(descriptionPath); err == nil {
|
||||
ctx.String(http.StatusOK, string(descriptionFile))
|
||||
return
|
||||
}
|
||||
ctx.String(http.StatusOK, tmpl)
|
||||
})
|
||||
srv.router.GET("/templates/tags", func(ctx *gin.Context) {
|
||||
var tagsPath = path.Join(srv.config.TemplateDirectory, "tags.txt")
|
||||
ctx.Header("content-type", "application/json")
|
||||
if tagsFile, err := os.ReadFile(tagsPath); err == nil {
|
||||
tags := strings.Split(strings.TrimSuffix(string(tagsFile), "\n"), "\n")
|
||||
res := strings.Builder{}
|
||||
res.WriteRune('[')
|
||||
for i, tag := range tags {
|
||||
fmt.Fprintf(&res, "\"%s\"", tag)
|
||||
if i < len(tags) - 1 { res.WriteString(", ") }
|
||||
}
|
||||
res.WriteRune(']')
|
||||
ctx.String(http.StatusOK, res.String())
|
||||
return
|
||||
}
|
||||
ctx.String(http.StatusOK, "[]")
|
||||
})
|
||||
|
||||
srv.router.GET("/style/*path", func(ctx *gin.Context) {
|
||||
path := strings.TrimPrefix(ctx.Request.URL.Path, "/")
|
||||
http.ServeFileFS(ctx.Writer, ctx.Request, public.StyleFS, path)
|
||||
})
|
||||
|
||||
srv.router.GET("/script/*path", func(ctx *gin.Context) {
|
||||
path := strings.TrimPrefix(ctx.Request.URL.Path, "/")
|
||||
http.ServeFileFS(ctx.Writer, ctx.Request, public.ScriptFS, path)
|
||||
})
|
||||
|
||||
failed := make(chan error)
|
||||
|
||||
go func() {
|
||||
srv.logger.Info("Server online.", "host", srv.config.Host, "port", srv.config.Port)
|
||||
failed <- srv.router.Run(fmt.Sprintf(":%d", srv.config.Port))
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-failed:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return context.Canceled
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *VODServer) stop(_ context.Context) {
|
||||
srv.logger.Info("Shutting down...")
|
||||
}
|
||||
59
templates/html.go
Normal file
59
templates/html.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed "html/layout.html"
|
||||
var layoutHTML string
|
||||
//go:embed "html/index.html"
|
||||
var indexHTML string
|
||||
//go:embed "html/vod-list.html"
|
||||
var vodListHTML string
|
||||
//go:embed "html/vod.html"
|
||||
var vodHTML string
|
||||
|
||||
type (
|
||||
Crumb struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
)
|
||||
|
||||
var templateFuncs = template.FuncMap{
|
||||
"FormatTime": func (time time.Time, format string) string {
|
||||
return time.Format(format)
|
||||
},
|
||||
"ToLower": func (str string) string {
|
||||
return strings.ToLower(str)
|
||||
},
|
||||
"ToUpper": func (str string) string {
|
||||
return strings.ToUpper(str)
|
||||
},
|
||||
"Crumbs": func (path string) []Crumb {
|
||||
split := strings.Split(strings.TrimSuffix(path, "/"), "/")
|
||||
crumbs := make([]Crumb, len(split))
|
||||
for i := range crumbs {
|
||||
segment := split[i]
|
||||
crumbs[i] = Crumb{
|
||||
segment,
|
||||
strings.Repeat("../", len(crumbs)-i-1),
|
||||
}
|
||||
}
|
||||
return crumbs
|
||||
},
|
||||
}
|
||||
|
||||
var BaseTemplate = template.Must(template.New("base").Funcs(templateFuncs).Parse(
|
||||
strings.Join([]string{
|
||||
layoutHTML,
|
||||
// headerHTML,
|
||||
// footerHTML,
|
||||
}, "\n"),
|
||||
))
|
||||
var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(indexHTML))
|
||||
var VODListTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(vodListHTML))
|
||||
var VODTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(vodHTML))
|
||||
18
templates/html/index.html
Normal file
18
templates/html/index.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{{define "head"}}
|
||||
<title>Melody VOD Manager</title>
|
||||
|
||||
<link rel="stylesheet" href="/style/index.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>Melody VOD Manager</h1>
|
||||
<a href="/vods">Vods</a>
|
||||
<p>Templates:</p>
|
||||
<ul>
|
||||
<li><a href="/templates/title">Title</a></li>
|
||||
<li><a href="/templates/description">Description</a></li>
|
||||
<li><a href="/templates/tags">Tags</a></li>
|
||||
</ul>
|
||||
</main>
|
||||
{{end}}
|
||||
32
templates/html/layout.html
Normal file
32
templates/html/layout.html
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
{{block "head" .}}{{end}}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<a href="/vods">Vods</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{{block "content" .}}
|
||||
<main>
|
||||
<h1>hello, world!</h1>
|
||||
<p>
|
||||
this is a template page.
|
||||
you probably shouldn't be seeing this!
|
||||
</p>
|
||||
</main>
|
||||
{{end}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
55
templates/html/vod-list.html
Normal file
55
templates/html/vod-list.html
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{{define "head"}}
|
||||
<title>Listing "{{.Directory}}" - Melody VOD Manager</title>
|
||||
|
||||
<link rel="stylesheet" href="/style/vod-list.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>VODs</h1>
|
||||
|
||||
<nav>→ {{range Crumbs .Directory}}<a href="./{{.Path}}">{{.Name}}</a>/{{end}}</nav>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2">Name</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $VOD := .VODs}}
|
||||
<tr>
|
||||
<td>📼</td>
|
||||
<td><a href="/vods{{$.Directory}}/{{$VOD.Path}}">{{$VOD.Title}}</a></td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
{{range $Name := .Directories}}
|
||||
<tr>
|
||||
<td>📁</td>
|
||||
<td><a href="/vods{{$.Directory}}/{{$Name}}">{{$Name}}</a></td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
{{range $File := .Files}}
|
||||
<tr>
|
||||
<td>📄</td>
|
||||
<td><a href="/vods{{$.Directory}}/{{$File.Name}}">{{$File.Name}}</a></td>
|
||||
<td>{{$File.Size}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Actions</h2>
|
||||
<div class="actions">
|
||||
<p>
|
||||
<a href="/vods{{.Directory}}?init=true">Initialise as VOD Directory</a>
|
||||
— Create a <code>metadata.toml</code> file here, initialising this directory as a VOD.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
{{end}}
|
||||
72
templates/html/vod.html
Normal file
72
templates/html/vod.html
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
{{define "head"}}
|
||||
<title>{{.Title}}</title>
|
||||
|
||||
<link rel="stylesheet" href="/style/vod.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>VOD Details</h1>
|
||||
|
||||
<nav>→ {{range Crumbs .Directory}}<a href="./{{.Path}}">{{.Name}}</a>/{{end}}</nav>
|
||||
|
||||
<form method="POST">
|
||||
<div class="metadata-top">
|
||||
<div>
|
||||
<label for="title">Title</label>
|
||||
<input type="text" name="title" value="{{.Title}}" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="part">Part</label>
|
||||
<input type="number" min="0" name="part" value="{{.Part}}" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="date">Date</label>
|
||||
<input type="date" name="date" value="{{FormatTime .Date "2006-01-02"}}" />
|
||||
</div>
|
||||
</div>
|
||||
<p>Formatted Title: <strong>{{.TitleFormatted}}</strong></p>
|
||||
|
||||
<p>Uploaded: <span id="upload-state">{{if .Uploaded}}Yes{{else}}No{{end}}</span></p>
|
||||
|
||||
<p>Tags:</p>
|
||||
<ul>
|
||||
{{range .Tags}}
|
||||
<li><code>{{.}}</code></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
<p>Category:</p>
|
||||
<blockquote>
|
||||
<label for="category-enabled">Enabled</label>
|
||||
<input type="checkbox" name="category-enabled" {{if .Category}}checked{{end}} />
|
||||
<label for="category-name">Name</label>
|
||||
<input type="text" name="category-name" value="{{if ne .Category nil}}{{.Category.Name}}{{end}}" />
|
||||
<label for="category-type">Type</label>
|
||||
<select name="category-type">
|
||||
<option value="entertainment" {{if ne .Category nil}}{{if eq .Category.Type "entertainment"}}selected{{end}}{{end}}>Entertainment</option>
|
||||
<option value="gaming" {{if ne .Category nil}}{{if eq .Category.Type "gaming"}}selected{{end}}{{end}}>Gaming</option>
|
||||
</select>
|
||||
<label for="category-url">URL</label>
|
||||
<input type="text" name="category-url" value="{{if ne .Category nil}}{{.Category.Url}}{{end}}" />
|
||||
</blockquote>
|
||||
|
||||
<p>Formatted Description:</p>
|
||||
<p><pre class="description">{{.DescriptionFormatted}}</pre></p>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
|
||||
<p>Footage Directory: <code>"{{.FootageDir}}"</code></p>
|
||||
<p>Footage Blocks:</p>
|
||||
<ul>
|
||||
{{range .Footage}}
|
||||
<li><code>{{.Name}} ({{.Size}})</code></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/script/vod.js" defer></script>
|
||||
{{end}}
|
||||
108
templates/text.go
Normal file
108
templates/text.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"arimelody.space/melody-vod-manager/scanner"
|
||||
)
|
||||
|
||||
|
||||
|
||||
type (
|
||||
TextTemplates struct {
|
||||
Title *template.Template
|
||||
Description *template.Template
|
||||
DefaultTags []string
|
||||
}
|
||||
|
||||
TextTemplateData struct {
|
||||
Title string
|
||||
Part uint
|
||||
Date time.Time
|
||||
Category *scanner.Category
|
||||
}
|
||||
)
|
||||
|
||||
const DefaultTitleTemplate =
|
||||
"{{.Title}} - {{FormatTime .Date \"02 Jan 2006\"}}"
|
||||
const DefaultDescriptionTemplate =
|
||||
"Streamed on {{FormatTime .Date \"02 January 2006\"}}"
|
||||
|
||||
func FetchTemplates(ctx context.Context, templateDir string) (*TextTemplates, error) {
|
||||
tmpl := TextTemplates{}
|
||||
logger := slog.Default().WithGroup("TEMPLATE")
|
||||
|
||||
var tagsPath = path.Join(templateDir, "tags.txt")
|
||||
var titlePath = path.Join(templateDir, "title.txt")
|
||||
var descriptionPath = path.Join(templateDir, "description.txt")
|
||||
|
||||
// tags
|
||||
if tagsFile, err := os.ReadFile(tagsPath); err == nil {
|
||||
tmpl.DefaultTags = strings.Split(string(tagsFile), "\n")
|
||||
} else {
|
||||
if !os.IsNotExist(err) { return nil, err }
|
||||
|
||||
logger.Info(fmt.Sprintf(
|
||||
"%s not found. No default tags will be used.",
|
||||
tagsPath,
|
||||
))
|
||||
tmpl.DefaultTags = []string{}
|
||||
}
|
||||
|
||||
// title
|
||||
titleTemplate := template.New("title").Funcs(templateFuncs)
|
||||
if titleFile, err := os.ReadFile(titlePath); err == nil {
|
||||
tmpl.Title, err = titleTemplate.Parse(string(titleFile))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse title template: %v", err)
|
||||
}
|
||||
} else {
|
||||
if !os.IsNotExist(err) { return nil, err }
|
||||
|
||||
logger.Info(
|
||||
fmt.Sprintf(
|
||||
"%s not found. Falling back to default template:",
|
||||
titlePath,
|
||||
),
|
||||
"template",
|
||||
DefaultTitleTemplate,
|
||||
)
|
||||
tmpl.Title, err = titleTemplate.Parse(DefaultTitleTemplate)
|
||||
if err != nil { panic(err) }
|
||||
|
||||
os.WriteFile(titlePath, []byte(DefaultTitleTemplate), 0644)
|
||||
}
|
||||
|
||||
// description
|
||||
descriptionTemplate := template.New("description").Funcs(templateFuncs)
|
||||
if descriptionFile, err := os.ReadFile(descriptionPath); err == nil {
|
||||
tmpl.Description, err = descriptionTemplate.Parse(string(descriptionFile))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse description template: %v", err)
|
||||
}
|
||||
} else {
|
||||
if !os.IsNotExist(err) { return nil, err }
|
||||
|
||||
logger.Info(
|
||||
fmt.Sprintf(
|
||||
"%s not found. Falling back to default template:",
|
||||
descriptionPath,
|
||||
),
|
||||
"template",
|
||||
DefaultDescriptionTemplate,
|
||||
)
|
||||
tmpl.Description, err = descriptionTemplate.Parse(DefaultDescriptionTemplate)
|
||||
if err != nil { panic(err) }
|
||||
|
||||
os.WriteFile(descriptionPath, []byte(DefaultDescriptionTemplate), 0644)
|
||||
}
|
||||
|
||||
return &tmpl, nil
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"path"
|
||||
"strconv"
|
||||
|
||||
"arimelody.space/vodular/youtube"
|
||||
"arimelody.space/melody-vod-manager/youtube"
|
||||
ffmpeg "github.com/u2takey/ffmpeg-go"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import (
|
|||
"text/template"
|
||||
"time"
|
||||
|
||||
"arimelody.space/vodular/scanner"
|
||||
"arimelody.space/melody-vod-manager/scanner"
|
||||
"arimelody.space/melody-vod-manager/templates"
|
||||
"golang.org/x/oauth2"
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/api/youtube/v3"
|
||||
|
|
@ -47,11 +48,6 @@ type (
|
|||
)
|
||||
|
||||
func BuildVideo(metadata *scanner.Metadata) (*Video, error) {
|
||||
videoDate, err := time.Parse("2006-01-02", metadata.Date)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse date from metadata: %v", err)
|
||||
}
|
||||
|
||||
var category *Category = nil
|
||||
if metadata.Category != nil {
|
||||
category = &Category{
|
||||
|
|
@ -68,13 +64,13 @@ func BuildVideo(metadata *scanner.Metadata) (*Video, error) {
|
|||
Title: metadata.Title,
|
||||
Category: category,
|
||||
Part: metadata.Part,
|
||||
Date: videoDate,
|
||||
Date: metadata.Date.AsTime(time.UTC),
|
||||
Tags: metadata.Tags,
|
||||
Filename: path.Join(
|
||||
metadata.FootageDir,
|
||||
fmt.Sprintf(
|
||||
"%s-fullvod.mkv",
|
||||
videoDate.Format("2006-01-02"),
|
||||
metadata.Date.String(),
|
||||
)),
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -92,12 +88,6 @@ type (
|
|||
Category *MetaCategory
|
||||
Part int
|
||||
}
|
||||
|
||||
Template struct {
|
||||
Title *template.Template
|
||||
Description *template.Template
|
||||
Tags []string
|
||||
}
|
||||
)
|
||||
|
||||
var videoCategoryTypeStrings = map[CategoryType]string{
|
||||
|
|
@ -113,88 +103,6 @@ var videoYtCategory = map[CategoryType]string {
|
|||
CATEGORY_ENTERTAINMENT: YT_CATEGORY_ENTERTAINMENT,
|
||||
}
|
||||
|
||||
const defaultTitleTemplate =
|
||||
"{{.Title}} - {{FormatTime .Date \"02 Jan 2006\"}}"
|
||||
const defaultDescriptionTemplate =
|
||||
"Streamed on {{FormatTime .Date \"02 January 2006\"}}"
|
||||
|
||||
var templateFuncs = template.FuncMap{
|
||||
"FormatTime": func (time time.Time, format string) string {
|
||||
return time.Format(format)
|
||||
},
|
||||
"ToLower": func (str string) string {
|
||||
return strings.ToLower(str)
|
||||
},
|
||||
"ToUpper": func (str string) string {
|
||||
return strings.ToUpper(str)
|
||||
},
|
||||
}
|
||||
|
||||
func FetchTemplates(templateDir string) (*Template, error) {
|
||||
tmpl := Template{}
|
||||
|
||||
var tagsPath = path.Join(templateDir, "tags.txt")
|
||||
var titlePath = path.Join(templateDir, "title.txt")
|
||||
var descriptionPath = path.Join(templateDir, "description.txt")
|
||||
|
||||
// tags
|
||||
if tagsFile, err := os.ReadFile(tagsPath); err == nil {
|
||||
tmpl.Tags = strings.Split(string(tagsFile), "\n")
|
||||
} else {
|
||||
if !os.IsNotExist(err) { return nil, err }
|
||||
|
||||
log.Fatalf(
|
||||
"%s not found. No default tags will be used.",
|
||||
tagsPath,
|
||||
)
|
||||
tmpl.Tags = []string{}
|
||||
}
|
||||
|
||||
// title
|
||||
titleTemplate := template.New("title").Funcs(templateFuncs)
|
||||
if titleFile, err := os.ReadFile(titlePath); err == nil {
|
||||
tmpl.Title, err = titleTemplate.Parse(string(titleFile))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse title template: %v", err)
|
||||
}
|
||||
} else {
|
||||
if !os.IsNotExist(err) { return nil, err }
|
||||
|
||||
log.Fatalf(
|
||||
"%s not found. Falling back to default template:\n%s",
|
||||
titlePath,
|
||||
defaultTitleTemplate,
|
||||
)
|
||||
tmpl.Title, err = titleTemplate.Parse(defaultTitleTemplate)
|
||||
if err != nil { panic(err) }
|
||||
|
||||
os.WriteFile(titlePath, []byte(defaultTitleTemplate), 0644)
|
||||
}
|
||||
|
||||
// description
|
||||
descriptionTemplate := template.New("description").Funcs(templateFuncs,)
|
||||
if descriptionFile, err := os.ReadFile(descriptionPath); err == nil {
|
||||
tmpl.Description, err = descriptionTemplate.Parse(string(descriptionFile))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse description template: %v", err)
|
||||
}
|
||||
} else {
|
||||
if !os.IsNotExist(err) { return nil, err }
|
||||
|
||||
log.Fatalf(
|
||||
"%s not found. Falling back to default template:\n%s",
|
||||
descriptionPath,
|
||||
defaultDescriptionTemplate,
|
||||
)
|
||||
tmpl.Description, err = descriptionTemplate.Parse(defaultDescriptionTemplate)
|
||||
if err != nil { panic(err) }
|
||||
|
||||
os.WriteFile(descriptionPath, []byte(defaultDescriptionTemplate), 0644)
|
||||
}
|
||||
|
||||
return &tmpl, nil
|
||||
}
|
||||
|
||||
func BuildTemplate(video *Video, tmpl *template.Template) (string, error) {
|
||||
out := &bytes.Buffer{}
|
||||
|
||||
|
|
@ -221,7 +129,7 @@ func UploadVideo(
|
|||
ctx context.Context,
|
||||
tokenSource oauth2.TokenSource,
|
||||
video *Video,
|
||||
templates *Template,
|
||||
templates *templates.TextTemplates,
|
||||
) (*youtube.Video, error) {
|
||||
title, err := BuildTemplate(video, templates.Title)
|
||||
if err != nil { return nil, fmt.Errorf("failed to build title: %v", err) }
|
||||
|
|
@ -253,7 +161,7 @@ func UploadVideo(
|
|||
Snippet: &youtube.VideoSnippet{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Tags: append(templates.Tags, video.Tags...),
|
||||
Tags: append(templates.DefaultTags, video.Tags...),
|
||||
CategoryId: categoryId, // gaming
|
||||
},
|
||||
Status: &youtube.VideoStatus{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue