HUGE refactor. working towards web UI

This commit is contained in:
ari melody 2026-06-08 02:49:39 +01:00
parent dd54e8cc49
commit 1f94eecca9
Signed by: ari
GPG key ID: 60B5F0386E3DDB7E
29 changed files with 1669 additions and 162 deletions

46
.air.toml Normal file
View 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
View file

@ -1,2 +1,3 @@
.DS_Store .DS_Store
config.toml config.toml
tmp

View file

@ -1,8 +1,10 @@
# Vodular # 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. 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! 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 ## Quick Jump
- [Basic Usage](#basic-usage) - [Basic Usage](#basic-usage)
- [VOD Metadata](#vod-metadata) - [VOD Metadata](#vod-metadata)
@ -11,21 +13,21 @@ I built this to greatly simplify the process of getting my full-quality livestre
## Basic usage ## Basic usage
1. Run the tool for the first time to generate a starter configuration file: 1. Run the tool for the first time to generate a starter configuration file:
```sh ```sh
$ vodular $ melody-vod-manager
New config file created (config.toml). Please edit this file before running again! New config file created (config.toml). Please edit this file before running again!
``` ```
The directory which holds your configuration file and templates varies, The directory which holds your configuration file and templates varies,
depending on platform: depending on platform:
- **Linux:** `~/.config/vodular/templates` - **Linux:** `~/.config/melody-vod-manager/templates`
- **macOS:** `~/Library/Application Support/vodular/templates` - **macOS:** `~/Library/Application Support/melody-vod-manager/templates`
- **Windows:** `%AppData%/vodular/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). 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. **IMPORTANT:** `config.toml` contains very sensitive credentials. Do not share this file with anyone.
3. Initialise a VOD directory: 3. Initialise a VOD directory:
```sh ```sh
$ vodular --init /path/to/vod $ melody-vod-manager --init /path/to/vod
Directory successfully initialised. Be sure to update metadata.toml before uploading! 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): 5. Upload a VOD (Optionally, delete the redundant full VOD export afterwards):
```sh ```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 ## VOD Metadata
When `--init`ialising a directory, a `metadata.toml` file is created. This is a When `--init`ialising a directory, a `metadata.toml` file is created. This is a
@ -69,7 +71,7 @@ url = 'https://example.org'
## Templates ## Templates
There are three template files, `title.txt`, `description.txt`, and `tags.txt`, 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 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 with Go's [template format](https://pkg.go.dev/text/template) to inject
information provided in `metadata.toml`, and other neat functionality! information provided in `metadata.toml`, and other neat functionality!

99
cmd/server/main.go Normal file
View 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)
}
}
}

View file

@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
_ "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@ -17,21 +16,20 @@ import (
"golang.org/x/oauth2/google" "golang.org/x/oauth2/google"
"google.golang.org/api/youtube/v3" "google.golang.org/api/youtube/v3"
"arimelody.space/vodular/config" "arimelody.space/melody-vod-manager/config"
"arimelody.space/vodular/scanner" "arimelody.space/melody-vod-manager/res"
vid "arimelody.space/vodular/video" "arimelody.space/melody-vod-manager/scanner"
yt "arimelody.space/vodular/youtube" "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 SEGMENT_EXTENSION = "mkv"
const MAX_TITLE_LEN = 100 const MAX_TITLE_LEN = 100
const MAX_DESCRIPTION_LEN = 5000 const MAX_DESCRIPTION_LEN = 5000
func showHelp() { func showHelp() {
fmt.Println(helpText) fmt.Println(res.HelpText)
os.Exit(0) os.Exit(0)
} }
@ -42,7 +40,7 @@ func main() {
log.Fatalf("Could not determine user configuration directory: %v", err) log.Fatalf("Could not determine user configuration directory: %v", err)
os.Exit(1) 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) cfg, err := config.ReadConfig(config.CONFIG_FILENAME)
if err != nil { if err != nil {
log.Fatalf("Failed to read config: %v", err) log.Fatalf("Failed to read config: %v", err)
@ -154,7 +152,7 @@ func main() {
} }
// good to have early on // 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 { if err != nil {
log.Fatalf("Failed to fetch templates: %v", err) log.Fatalf("Failed to fetch templates: %v", err)
os.Exit(1) os.Exit(1)
@ -288,12 +286,21 @@ func main() {
// youtube oauth flow // youtube oauth flow
ctx := context.Background() 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{ oauth2Config := &oauth2.Config{
ClientID: cfg.Google.ClientID, ClientID: cfg.Google.ClientID,
ClientSecret: cfg.Google.ClientSecret, ClientSecret: cfg.Google.ClientSecret,
Endpoint: google.Endpoint, Endpoint: google.Endpoint,
Scopes: []string{ youtube.YoutubeScope }, Scopes: []string{ youtube.YoutubeScope },
RedirectURL: cfg.RedirectUri, RedirectURL: redirectUri,
} }
var token *oauth2.Token var token *oauth2.Token
if cfg.Token != nil { if cfg.Token != nil {

24
colour/colour.go Normal file
View 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
View 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>'

View file

@ -17,15 +17,18 @@ type (
Config struct { Config struct {
Host string `toml:"host" comment:"Address to host OAuth2 redirect flow"` Host string `toml:"host" comment:"Address to host OAuth2 redirect flow"`
RedirectUri string `toml:"redirect_uri" comment:"URI to use in Google OAuth2 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"` Google GoogleConfig `toml:"google"`
Token *oauth2.Token `toml:"token" comment:"This section is filled in automatically on a successful authentication flow."` Token *oauth2.Token `toml:"token" comment:"This section is filled in automatically on a successful authentication flow."`
} }
) )
var defaultConfig = Config{ var defaultConfig = Config{
Host: "localhost:8090", Host: "localhost",
RedirectUri: "http://localhost:8090", Secure: false,
Port: 8080,
Google: GoogleConfig{ Google: GoogleConfig{
ApiKey: "<your API key here>", ApiKey: "<your API key here>",
ClientID: "<your client ID here>", ClientID: "<your client ID here>",
@ -38,15 +41,12 @@ var CONFIG_FILENAME string = "config.toml"
func ReadConfig(filename string) (*Config, error) { func ReadConfig(filename string) (*Config, error) {
cfgBytes, err := os.ReadFile(filename) cfgBytes, err := os.ReadFile(filename)
if err != nil { if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to open file: %v", err) return nil, fmt.Errorf("failed to open file: %v", err)
} }
var config Config var config Config
err = toml.Unmarshal(cfgBytes, &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 return &config, nil
} }

34
go.mod
View file

@ -1,4 +1,4 @@
module arimelody.space/vodular module arimelody.space/melody-vod-manager
go 1.25.3 go 1.25.3
@ -13,25 +13,49 @@ require (
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/aws/aws-sdk-go v1.38.20 // 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/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/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // 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/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/jmespath/go-jmespath v0.4.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/ffmpeg-go v0.5.0 // indirect
github.com/u2takey/go-utils v0.3.1 // 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/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.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 v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect
golang.org/x/crypto v0.43.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.46.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/text v0.30.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/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
google.golang.org/grpc v1.76.0 // indirect google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect

65
go.sum
View file

@ -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= 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 h1:QbzNx/tdfATbdKfubBpkt84OM6oBkxQZRw6+bW2GyeA=
github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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/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 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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 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/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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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 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/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.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/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/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-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/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 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.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/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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/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.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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 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.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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU=
github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc= 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 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys=
github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs= 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 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 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= 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 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 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= 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-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.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 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 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/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-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.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 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 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 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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-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-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-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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.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.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.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 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 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/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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/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.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.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=

189
logger/slog.go Normal file
View 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
View 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
View 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
View file

@ -0,0 +1 @@
@import url("/style/main.css");

237
public/style/main.css Normal file
View 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);
}
}

View file

@ -0,0 +1,5 @@
@import url("/style/main.css");
table {
}

7
public/style/vod.css Normal file
View file

@ -0,0 +1,7 @@
@import url("/style/main.css");
.metadata-top {
display: flex;
flex-direction: row;
gap: 1em;
}

View file

@ -1,6 +1,6 @@
Vodular Melody VOD Manager
USAGE: vodular [options] [directory] USAGE: melody-vod-manager [options] [directory]
OPTIONS: OPTIONS:
-h, --help: Show this help message. -h, --help: Show this help message.
@ -10,7 +10,7 @@ OPTIONS:
-d, --deleteAfter: Deletes the full VOD after upload. -d, --deleteAfter: Deletes the full VOD after upload.
-f, --force: Force uploading the VOD, even if it already exists. -f, --force: Force uploading the VOD, even if it already exists.
SOURCE: https://codeberg.org/arimelody/vodular SOURCE: https://codeberg.org/arimelody/melody-vod-manager
ISSUES: https://codeberg.org/arimelody/vodular/issues ISSUES: https://codeberg.org/arimelody/melody-vod-manager/issues
made with <3 by ari melody, 2026 made with <3 by ari melody, 2026

8
res/res.go Normal file
View file

@ -0,0 +1,8 @@
package res
import (
_ "embed"
)
//go:embed help.txt
var HelpText string

View file

@ -1,11 +1,14 @@
package scanner package scanner
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path" "path"
"strconv" "strconv"
"strings" "strings"
"text/template"
"time" "time"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
@ -14,19 +17,19 @@ import (
type ( type (
Category struct { Category struct {
Name string `toml:"name"` Name string `toml:"name" json:"name"`
Type string `toml:"type" comment:"Valid types: gaming, other (default: other)"` Type string `toml:"type" json:"type" comment:"Valid types: gaming, other (default: other)"`
Url string `toml:"url"` Url string `toml:"url" json:"url"`
} }
Metadata struct { Metadata struct {
Title string `toml:"title"` Title string `toml:"title" json:"title"`
Part int `toml:"part"` Part int `toml:"part" json:"part"`
Date string `toml:"date"` Date toml.LocalDate `toml:"date" json:"date"`
Tags []string `toml:"tags"` Tags []string `toml:"tags" json:"tags"`
FootageDir string `toml:"footage_dir"` FootageDir string `toml:"footage_dir" json:"footage_dir"`
Uploaded bool `toml:"uploaded"` Uploaded bool `toml:"uploaded" json:"uploaded"`
Category *Category `toml:"category" comment:"(Optional) Category details, for additional credits."` Category *Category `toml:"category" json:"category" comment:"(Optional) Category details, for additional credits."`
} }
FFprobeFormat struct { FFprobeFormat struct {
@ -107,25 +110,36 @@ func ReadMetadata(directory string) (*Metadata, error) {
} }
func WriteMetadata(directory string, metadata *Metadata) (error) { func WriteMetadata(directory string, metadata *Metadata) (error) {
file, err := os.OpenFile( data, err := toml.Marshal(metadata)
path.Join(directory, METADATA_FILENAME),
os.O_CREATE | os.O_RDWR, 0644,
)
if err != nil { return err } if err != nil { return err }
err = os.WriteFile(path.Join(directory, METADATA_FILENAME), data, 0644)
err = toml.NewEncoder(file).Encode(metadata)
return err return err
} }
func DefaultMetadata() *Metadata { func DefaultMetadata() *Metadata {
return &Metadata{ return &Metadata{
Title: "Untitled Stream", Title: "Untitled Stream",
Date: time.Now().Format("2006-01-02"), Date: toml.LocalDate{
Part: 0, Year: time.Now().Year(),
Category: &Category{ Month: int(time.Now().Month()),
Name: "Something", Day: time.Now().Day(),
Type: "",
Url: "",
}, },
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
View 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
View 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
View 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}}

View 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>

View 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>&rarr; {{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>&mdash;</td>
</tr>
{{end}}
{{range $Name := .Directories}}
<tr>
<td>📁</td>
<td><a href="/vods{{$.Directory}}/{{$Name}}">{{$Name}}</a></td>
<td>&mdash;</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>
&mdash; 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
View 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>&rarr; {{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
View 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
}

View file

@ -8,7 +8,7 @@ import (
"path" "path"
"strconv" "strconv"
"arimelody.space/vodular/youtube" "arimelody.space/melody-vod-manager/youtube"
ffmpeg "github.com/u2takey/ffmpeg-go" ffmpeg "github.com/u2takey/ffmpeg-go"
) )

View file

@ -11,7 +11,8 @@ import (
"text/template" "text/template"
"time" "time"
"arimelody.space/vodular/scanner" "arimelody.space/melody-vod-manager/scanner"
"arimelody.space/melody-vod-manager/templates"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"google.golang.org/api/option" "google.golang.org/api/option"
"google.golang.org/api/youtube/v3" "google.golang.org/api/youtube/v3"
@ -47,11 +48,6 @@ type (
) )
func BuildVideo(metadata *scanner.Metadata) (*Video, error) { 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 var category *Category = nil
if metadata.Category != nil { if metadata.Category != nil {
category = &Category{ category = &Category{
@ -68,13 +64,13 @@ func BuildVideo(metadata *scanner.Metadata) (*Video, error) {
Title: metadata.Title, Title: metadata.Title,
Category: category, Category: category,
Part: metadata.Part, Part: metadata.Part,
Date: videoDate, Date: metadata.Date.AsTime(time.UTC),
Tags: metadata.Tags, Tags: metadata.Tags,
Filename: path.Join( Filename: path.Join(
metadata.FootageDir, metadata.FootageDir,
fmt.Sprintf( fmt.Sprintf(
"%s-fullvod.mkv", "%s-fullvod.mkv",
videoDate.Format("2006-01-02"), metadata.Date.String(),
)), )),
}, nil }, nil
} }
@ -92,12 +88,6 @@ type (
Category *MetaCategory Category *MetaCategory
Part int Part int
} }
Template struct {
Title *template.Template
Description *template.Template
Tags []string
}
) )
var videoCategoryTypeStrings = map[CategoryType]string{ var videoCategoryTypeStrings = map[CategoryType]string{
@ -113,88 +103,6 @@ var videoYtCategory = map[CategoryType]string {
CATEGORY_ENTERTAINMENT: YT_CATEGORY_ENTERTAINMENT, 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) { func BuildTemplate(video *Video, tmpl *template.Template) (string, error) {
out := &bytes.Buffer{} out := &bytes.Buffer{}
@ -221,7 +129,7 @@ func UploadVideo(
ctx context.Context, ctx context.Context,
tokenSource oauth2.TokenSource, tokenSource oauth2.TokenSource,
video *Video, video *Video,
templates *Template, templates *templates.TextTemplates,
) (*youtube.Video, error) { ) (*youtube.Video, error) {
title, err := BuildTemplate(video, templates.Title) title, err := BuildTemplate(video, templates.Title)
if err != nil { return nil, fmt.Errorf("failed to build title: %v", err) } if err != nil { return nil, fmt.Errorf("failed to build title: %v", err) }
@ -253,7 +161,7 @@ func UploadVideo(
Snippet: &youtube.VideoSnippet{ Snippet: &youtube.VideoSnippet{
Title: title, Title: title,
Description: description, Description: description,
Tags: append(templates.Tags, video.Tags...), Tags: append(templates.DefaultTags, video.Tags...),
CategoryId: categoryId, // gaming CategoryId: categoryId, // gaming
}, },
Status: &youtube.VideoStatus{ Status: &youtube.VideoStatus{