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
+
+
+
+
+
+
+ | Name |
+ Size |
+
+
+
+ {{range $VOD := .VODs}}
+
+ | 📼 |
+ {{$VOD.Title}} |
+ — |
+
+ {{end}}
+
+ {{range $Name := .Directories}}
+
+ | 📁 |
+ {{$Name}} |
+ — |
+
+ {{end}}
+
+ {{range $File := .Files}}
+
+ | 📄 |
+ {{$File.Name}} |
+ {{$File.Size}} |
+
+ {{end}}
+
+
+
+ Actions
+
+
+{{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
+
+
+
+
+
+
+
+
+
+
+ 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{