From 1f94eecca9e139cb5f02684df863165385d5bf4f Mon Sep 17 00:00:00 2001 From: ari melody Date: Mon, 8 Jun 2026 02:49:39 +0100 Subject: [PATCH] HUGE refactor. working towards web UI --- .air.toml | 46 ++++ .gitignore | 1 + README.md | 22 +- cmd/server/main.go | 99 +++++++ main.go => cmd/vodular/main.go | 31 ++- colour/colour.go | 24 ++ config.example.toml | 9 + config/config.go | 20 +- go.mod | 34 ++- go.sum | 65 +++++ logger/slog.go | 189 +++++++++++++ public/script/vod.js | 26 ++ public/static.go | 9 + public/style/index.css | 1 + public/style/main.css | 237 ++++++++++++++++ public/style/vod-list.css | 5 + public/style/vod.css | 7 + res/help.txt | 8 +- res/res.go | 8 + scanner/scanner.go | 58 ++-- server/server.go | 482 +++++++++++++++++++++++++++++++++ templates/html.go | 59 ++++ templates/html/index.html | 18 ++ templates/html/layout.html | 32 +++ templates/html/vod-list.html | 55 ++++ templates/html/vod.html | 72 +++++ templates/text.go | 108 ++++++++ video/video.go | 2 +- youtube/youtube.go | 104 +------ 29 files changed, 1669 insertions(+), 162 deletions(-) create mode 100644 .air.toml create mode 100644 cmd/server/main.go rename main.go => cmd/vodular/main.go (92%) create mode 100644 colour/colour.go create mode 100644 config.example.toml create mode 100644 logger/slog.go create mode 100644 public/script/vod.js create mode 100644 public/static.go create mode 100644 public/style/index.css create mode 100644 public/style/main.css create mode 100644 public/style/vod-list.css create mode 100644 public/style/vod.css create mode 100644 res/res.go create mode 100644 server/server.go create mode 100644 templates/html.go create mode 100644 templates/html/index.html create mode 100644 templates/html/layout.html create mode 100644 templates/html/vod-list.html create mode 100644 templates/html/vod.html create mode 100644 templates/text.go diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..8c81593 --- /dev/null +++ b/.air.toml @@ -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 + diff --git a/.gitignore b/.gitignore index db85df0..4bd95b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store config.toml +tmp diff --git a/README.md b/README.md index ff53e8f..eb14fef 100644 --- a/README.md +++ b/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! diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..0c7c60e --- /dev/null +++ b/cmd/server/main.go @@ -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) + } + } +} diff --git a/main.go b/cmd/vodular/main.go similarity index 92% rename from main.go rename to cmd/vodular/main.go index 341711a..8ed502e 100644 --- a/main.go +++ b/cmd/vodular/main.go @@ -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 { diff --git a/colour/colour.go b/colour/colour.go new file mode 100644 index 0000000..b36a1f1 --- /dev/null +++ b/colour/colour.go @@ -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, +} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..8f808e1 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,9 @@ +host = '0.0.0.0' +secure = false +port = 8080 +vod_directory = '/media/videos' + +[google] +api_key = '' +client_id = '' +client_secret = '' diff --git a/config/config.go b/config/config.go index 28e0378..308d36e 100644 --- a/config/config.go +++ b/config/config.go @@ -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: "", ClientID: "", @@ -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 } diff --git a/go.mod b/go.mod index 2064c8f..086a55a 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index dae291f..03e56f8 100644 --- a/go.sum +++ b/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= diff --git a/logger/slog.go b/logger/slog.go new file mode 100644 index 0000000..19e3685 --- /dev/null +++ b/logger/slog.go @@ -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 +} diff --git a/public/script/vod.js b/public/script/vod.js new file mode 100644 index 0000000..77e394f --- /dev/null +++ b/public/script/vod.js @@ -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(); diff --git a/public/static.go b/public/static.go new file mode 100644 index 0000000..d4c73ff --- /dev/null +++ b/public/static.go @@ -0,0 +1,9 @@ +package public + +import "embed" + +//go:embed style +var StyleFS embed.FS + +//go:embed script +var ScriptFS embed.FS diff --git a/public/style/index.css b/public/style/index.css new file mode 100644 index 0000000..878a37f --- /dev/null +++ b/public/style/index.css @@ -0,0 +1 @@ +@import url("/style/main.css"); diff --git a/public/style/main.css b/public/style/main.css new file mode 100644 index 0000000..cdbb129 --- /dev/null +++ b/public/style/main.css @@ -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); + } +} diff --git a/public/style/vod-list.css b/public/style/vod-list.css new file mode 100644 index 0000000..9b08e76 --- /dev/null +++ b/public/style/vod-list.css @@ -0,0 +1,5 @@ +@import url("/style/main.css"); + +table { + +} diff --git a/public/style/vod.css b/public/style/vod.css new file mode 100644 index 0000000..e5ff827 --- /dev/null +++ b/public/style/vod.css @@ -0,0 +1,7 @@ +@import url("/style/main.css"); + +.metadata-top { + display: flex; + flex-direction: row; + gap: 1em; +} diff --git a/res/help.txt b/res/help.txt index c63605a..3163073 100644 --- a/res/help.txt +++ b/res/help.txt @@ -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 diff --git a/res/res.go b/res/res.go new file mode 100644 index 0000000..b890752 --- /dev/null +++ b/res/res.go @@ -0,0 +1,8 @@ +package res + +import ( + _ "embed" +) + +//go:embed help.txt +var HelpText string diff --git a/scanner/scanner.go b/scanner/scanner.go index 1ff5bd9..11c89c3 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -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) +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..686bbcd --- /dev/null +++ b/server/server.go @@ -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...") +} diff --git a/templates/html.go b/templates/html.go new file mode 100644 index 0000000..7badfa0 --- /dev/null +++ b/templates/html.go @@ -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)) diff --git a/templates/html/index.html b/templates/html/index.html new file mode 100644 index 0000000..2c0b067 --- /dev/null +++ b/templates/html/index.html @@ -0,0 +1,18 @@ +{{define "head"}} +Melody VOD Manager + + +{{end}} + +{{define "content"}} +
+

Melody VOD Manager

+ Vods +

Templates:

+ +
+{{end}} diff --git a/templates/html/layout.html b/templates/html/layout.html new file mode 100644 index 0000000..6faf78c --- /dev/null +++ b/templates/html/layout.html @@ -0,0 +1,32 @@ + + + + + + + + + + {{block "head" .}}{{end}} + + + +
+ +
+ + {{block "content" .}} +
+

hello, world!

+

+ this is a template page. + you probably shouldn't be seeing this! +

+
+ {{end}} + + + diff --git a/templates/html/vod-list.html b/templates/html/vod-list.html new file mode 100644 index 0000000..2158760 --- /dev/null +++ b/templates/html/vod-list.html @@ -0,0 +1,55 @@ +{{define "head"}} +Listing "{{.Directory}}" - Melody VOD Manager + + +{{end}} + +{{define "content"}} +
+

VODs

+ + + + + + + + + + + + {{range $VOD := .VODs}} + + + + + + {{end}} + + {{range $Name := .Directories}} + + + + + + {{end}} + + {{range $File := .Files}} + + + + + + {{end}} + +
NameSize
📼{{$VOD.Title}}
📁{{$Name}}
📄{{$File.Name}}{{$File.Size}}
+ +

Actions

+
+

+ Initialise as VOD Directory + — Create a metadata.toml file here, initialising this directory as a VOD. +

+
+
+{{end}} diff --git a/templates/html/vod.html b/templates/html/vod.html new file mode 100644 index 0000000..92f9080 --- /dev/null +++ b/templates/html/vod.html @@ -0,0 +1,72 @@ +{{define "head"}} +{{.Title}} + + +{{end}} + +{{define "content"}} +
+

VOD Details

+ + + +
+ +

Formatted Title: {{.TitleFormatted}}

+ +

Uploaded: {{if .Uploaded}}Yes{{else}}No{{end}}

+ +

Tags:

+
    + {{range .Tags}} +
  • {{.}}
  • + {{end}} +
+ +

Category:

+
+ + + + + + + + +
+ +

Formatted Description:

+

{{.DescriptionFormatted}}

+ + +
+ +
+ +

Footage Directory: "{{.FootageDir}}"

+

Footage Blocks:

+
    + {{range .Footage}} +
  • {{.Name}} ({{.Size}})
  • + {{end}} +
+
+ + +{{end}} diff --git a/templates/text.go b/templates/text.go new file mode 100644 index 0000000..7eae307 --- /dev/null +++ b/templates/text.go @@ -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 +} diff --git a/video/video.go b/video/video.go index 4d13987..38e37d6 100644 --- a/video/video.go +++ b/video/video.go @@ -8,7 +8,7 @@ import ( "path" "strconv" - "arimelody.space/vodular/youtube" + "arimelody.space/melody-vod-manager/youtube" ffmpeg "github.com/u2takey/ffmpeg-go" ) diff --git a/youtube/youtube.go b/youtube/youtube.go index c6ace15..2934d51 100644 --- a/youtube/youtube.go +++ b/youtube/youtube.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{