initial version

AuthorKonata <konata@posteo.jp>
Date
Commit735d7bd1a010ee9d88dd56086400f81d1ed396fa
32 files changed, 4393 insertions(+)
▾A.gitignore
@@ -0,0 +1,34 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Editor/IDE
# .idea/
# .vscode/
data/
▾AContainerfile
@@ -0,0 +1,27 @@
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o vidarchive ./cmd/vidarchive
FROM alpine:latest
RUN apk --no-cache add ca-certificates ffmpeg yt-dlp
WORKDIR /app
COPY --from=builder /build/vidarchive /app/vidarchive
COPY --from=builder /build/web /app/web
ENV VIDARCHIVE_DATA_DIR=/data
ENV VIDARCHIVE_DOWNLOAD_DIR=/data/downloads
ENV VIDARCHIVE_PORT=8080
VOLUME ["/data"]
EXPOSE 8080
ENTRYPOINT ["/app/vidarchive"]
▾Acmd/vidarchive/main.go
@@ -0,0 +1,68 @@
package main
import (
"fmt"
"log"
"os"
"vidarchive/internal/config"
"vidarchive/internal/database"
"vidarchive/internal/handler"
"vidarchive/internal/repository"
"vidarchive/internal/server"
"vidarchive/internal/service"
"vidarchive/internal/worker"
)
func main() {
cfg := config.New()
// Ensure directories exist
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
log.Fatalf("Failed to create data dir: %v", err)
}
if err := os.MkdirAll(cfg.DownloadDir, 0755); err != nil {
log.Fatalf("Failed to create download dir: %v", err)
}
// Initialize database
db, err := database.New(cfg)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer db.Close()
// Initialize repositories
presetRepo := repository.NewPresetRepository(db)
mediaRepo := repository.NewMediaRepository(db)
downloadRepo := repository.NewDownloadRepository(db)
settingsRepo := repository.NewSettingsRepository(db)
// Initialize services
presetSvc := service.NewPresetService(presetRepo)
mediaSvc := service.NewMediaService(mediaRepo, cfg.DownloadDir)
downloadSvc := service.NewDownloadService(downloadRepo, mediaRepo, presetSvc, cfg)
settingsSvc := service.NewSettingsService(settingsRepo)
// Initialize worker pool
workerPool := worker.New(downloadSvc, cfg.Workers)
workerPool.Start()
defer workerPool.Stop()
// Initialize handler
h, err := handler.New(cfg, presetSvc, downloadSvc, mediaSvc, settingsSvc, workerPool)
if err != nil {
log.Fatalf("Failed to initialize handler: %v", err)
}
// Initialize and start server
srv := server.New(cfg, h)
fmt.Printf("VidArchive starting...\n")
fmt.Printf("Data directory: %s\n", cfg.DataDir)
fmt.Printf("Download directory: %s\n", cfg.DownloadDir)
if err := srv.Start(); err != nil {
log.Fatalf("Server error: %v", err)
}
}
▾Acompose.yml
@@ -0,0 +1,21 @@
services:
vidarchive:
build:
context: .
dockerfile: Containerfile
container_name: vidarchive
ports:
- "8080:8080"
volumes:
- ./data:/data
environment:
- VIDARCHIVE_PORT=8080
- VIDARCHIVE_DATA_DIR=/data
- VIDARCHIVE_DOWNLOAD_DIR=/data/downloads
- VIDARCHIVE_WORKERS=2
- VIDARCHIVE_REFRESH_INTERVAL=5
# Uncomment to run behind HTTPS proxy:
# - VIDARCHIVE_BASE_URL=https://vidarchive.example.com
# Uncomment to use a custom yt-dlp path:
# - VIDARCHIVE_YTDLP_PATH=/usr/bin/yt-dlp
restart: unless-stopped
▾Ago.mod
@@ -0,0 +1,20 @@
module vidarchive
go 1.26.2
require (
github.com/go-chi/chi/v5 v5.2.5
modernc.org/sqlite v1.50.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
▾Ago.sum
@@ -0,0 +1,53 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
▾Ainternal/config/config.go
@@ -0,0 +1,59 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type Config struct {
Port int
DataDir string
DBPath string
DownloadDir string
YTDLPPath string
BaseURL string
Workers int
RefreshInterval int
}
func New() *Config {
dataDir := getEnv("VIDARCHIVE_DATA_DIR", "./data")
return &Config{
Port: getEnvInt("VIDARCHIVE_PORT", 8080),
DataDir: dataDir,
DBPath: getEnv("VIDARCHIVE_DB_PATH", filepath.Join(dataDir, "vidarchive.db")),
DownloadDir: getEnv("VIDARCHIVE_DOWNLOAD_DIR", filepath.Join(dataDir, "downloads")),
YTDLPPath: getEnv("VIDARCHIVE_YTDLP_PATH", "yt-dlp"),
BaseURL: getEnv("VIDARCHIVE_BASE_URL", ""),
Workers: getEnvInt("VIDARCHIVE_WORKERS", 2),
RefreshInterval: getEnvInt("VIDARCHIVE_REFRESH_INTERVAL", 5),
}
}
func (c *Config) IsHTTPS() bool {
return strings.HasPrefix(c.BaseURL, "https://")
}
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
func getEnvInt(key string, defaultVal int) int {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
i, err := strconv.Atoi(v)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid %s: %v, using default %d\n", key, err, defaultVal)
return defaultVal
}
return i
}
▾Ainternal/config/config_test.go
@@ -0,0 +1,68 @@
package config
import (
"os"
"testing"
)
func TestNewDefaults(t *testing.T) {
cfg := New()
if cfg.Port != 8080 {
t.Errorf("expected default port 8080, got %d", cfg.Port)
}
if cfg.DataDir != "./data" {
t.Errorf("expected default data dir ./data, got %s", cfg.DataDir)
}
if cfg.Workers != 2 {
t.Errorf("expected default workers 2, got %d", cfg.Workers)
}
if cfg.RefreshInterval != 5 {
t.Errorf("expected default refresh interval 5, got %d", cfg.RefreshInterval)
}
}
func TestEnvOverrides(t *testing.T) {
os.Setenv("VIDARCHIVE_PORT", "9090")
os.Setenv("VIDARCHIVE_WORKERS", "4")
os.Setenv("VIDARCHIVE_BASE_URL", "https://vidarchive.example.com")
defer func() {
os.Unsetenv("VIDARCHIVE_PORT")
os.Unsetenv("VIDARCHIVE_WORKERS")
os.Unsetenv("VIDARCHIVE_BASE_URL")
}()
cfg := New()
if cfg.Port != 9090 {
t.Errorf("expected port 9090, got %d", cfg.Port)
}
if cfg.Workers != 4 {
t.Errorf("expected workers 4, got %d", cfg.Workers)
}
if cfg.BaseURL != "https://vidarchive.example.com" {
t.Errorf("expected base URL https://vidarchive.example.com, got %s", cfg.BaseURL)
}
if !cfg.IsHTTPS() {
t.Error("expected IsHTTPS() to be true")
}
}
func TestIsHTTPS(t *testing.T) {
tests := []struct {
url string
expected bool
}{
{"", false},
{"http://localhost:8080", false},
{"https://example.com", true},
{"https://vidarchive.example.com/path", true},
}
for _, test := range tests {
cfg := &Config{BaseURL: test.url}
if cfg.IsHTTPS() != test.expected {
t.Errorf("IsHTTPS() for %q = %v, expected %v", test.url, cfg.IsHTTPS(), test.expected)
}
}
}
▾Ainternal/database/database.go
@@ -0,0 +1,118 @@
package database
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"vidarchive/internal/config"
_ "modernc.org/sqlite"
)
func New(cfg *config.Config) (*sql.DB, error) {
if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0755); err != nil {
return nil, fmt.Errorf("create db dir: %w", err)
}
db, err := sql.Open("sqlite", cfg.DBPath)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
if err := migrate(db); err != nil {
return nil, fmt.Errorf("migrate: %w", err)
}
return db, nil
}
func migrate(db *sql.DB) error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)`); err != nil {
return fmt.Errorf("create migrations table: %w", err)
}
var currentVersion int
if err := db.QueryRow(`SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1`).Scan(&currentVersion); err != nil && err != sql.ErrNoRows {
return fmt.Errorf("get current version: %w", err)
}
migrations := []struct {
version int
sql string
}{
{1, `CREATE TABLE IF NOT EXISTS presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_default INTEGER DEFAULT 0,
format TEXT DEFAULT 'best',
quality TEXT,
extract_audio INTEGER DEFAULT 0,
audio_format TEXT DEFAULT 'mp3',
embed_subs INTEGER DEFAULT 0,
sub_langs TEXT DEFAULT 'en',
embed_thumbnail INTEGER DEFAULT 1,
embed_metadata INTEGER DEFAULT 1,
write_info_json INTEGER DEFAULT 1,
output_template TEXT DEFAULT '%(title)s [%(id)s].%(ext)s',
custom_flags TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`},
{2, `CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
title TEXT,
description TEXT,
duration INTEGER,
filepath TEXT NOT NULL,
relative_path TEXT NOT NULL,
is_audio INTEGER DEFAULT 0,
has_embedded_thumbnail INTEGER DEFAULT 0,
info_json_path TEXT,
playlist_id TEXT,
playlist_index INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`},
{3, `CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
status TEXT DEFAULT 'queued',
progress_percent INTEGER DEFAULT 0,
current_speed TEXT,
eta TEXT,
logs TEXT,
error_message TEXT,
preset_id INTEGER,
format_override TEXT,
custom_flags TEXT,
started_at DATETIME,
completed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (preset_id) REFERENCES presets(id)
)`},
{4, `CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)`},
{5, `INSERT OR IGNORE INTO settings (key, value) VALUES ('refresh_interval', '5')`},
{6, `INSERT OR IGNORE INTO settings (key, value) VALUES ('auto_refresh_library', '0')`},
{7, `INSERT OR IGNORE INTO settings (key, value) VALUES ('auto_refresh_downloads', '1')`},
{8, `ALTER TABLE presets ADD COLUMN format_mode TEXT DEFAULT 'preset'`},
{9, `ALTER TABLE presets ADD COLUMN custom_format TEXT`},
}
for _, m := range migrations {
if m.version <= currentVersion {
continue
}
if _, err := db.Exec(m.sql); err != nil {
return fmt.Errorf("migration %d: %w", m.version, err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations (version) VALUES (?)`, m.version); err != nil {
return fmt.Errorf("record migration %d: %w", m.version, err)
}
}
return nil
}
▾Ainternal/handler/handler.go
@@ -0,0 +1,603 @@
package handler
import (
"bytes"
"fmt"
"html/template"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"vidarchive/internal/config"
"vidarchive/internal/models"
"vidarchive/internal/service"
"vidarchive/internal/worker"
)
type Handler struct {
cfg *config.Config
templates *template.Template
presetSvc *service.PresetService
downloadSvc *service.DownloadService
mediaSvc *service.MediaService
settingsSvc *service.SettingsService
workerPool *worker.Pool
}
func New(cfg *config.Config, presetSvc *service.PresetService, downloadSvc *service.DownloadService, mediaSvc *service.MediaService, settingsSvc *service.SettingsService, workerPool *worker.Pool) (*Handler, error) {
presetSvcGlobal = presetSvc
tmpl, err := loadTemplates()
if err != nil {
return nil, fmt.Errorf("load templates: %w", err)
}
return &Handler{
cfg: cfg,
templates: tmpl,
presetSvc: presetSvc,
downloadSvc: downloadSvc,
mediaSvc: mediaSvc,
settingsSvc: settingsSvc,
workerPool: workerPool,
}, nil
}
func loadTemplates() (*template.Template, error) {
tmpl := template.New("").Funcs(template.FuncMap{
"formatDuration": formatDuration,
"formatFileSize": formatFileSize,
"splitPath": func(p string) []string { return strings.Split(strings.Trim(p, "/"), "/") },
"baseName": filepath.Base,
"ext": filepath.Ext,
"presetFlags": func(p *models.Preset) string { return presetSvcGlobal.EffectiveFlags(p, "", "") },
})
return tmpl.ParseGlob("web/templates/*.html")
}
var presetSvcGlobal *service.PresetService
func formatDuration(seconds int) string {
if seconds == 0 {
return "--:--"
}
m := seconds / 60
s := seconds % 60
h := m / 60
m = m % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
}
return fmt.Sprintf("%d:%02d", m, s)
}
func formatFileSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
type PageData struct {
Title string
ActiveTab string
Data interface{}
BaseURL string
AutoRefresh bool
RefreshSec int
Theme string
}
const cookieMaxAge = 365 * 24 * 60 * 60 // 1 year
func getCookie(r *http.Request, name, defaultVal string) string {
cookie, err := r.Cookie(name)
if err != nil {
return defaultVal
}
return cookie.Value
}
func setCookie(w http.ResponseWriter, name, value string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: cookieMaxAge,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func (h *Handler) renderWithRequest(w http.ResponseWriter, r *http.Request, contentTmpl string, data PageData) {
data.BaseURL = h.cfg.BaseURL
if data.Theme == "" {
data.Theme = getCookie(r, "theme", "auto")
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Render content first
var contentBuf bytes.Buffer
if err := h.templates.ExecuteTemplate(&contentBuf, contentTmpl, data); err != nil {
http.Error(w, fmt.Sprintf("render content: %v", err), http.StatusInternalServerError)
return
}
// Then render layout with content
layoutData := struct {
PageData
Content template.HTML
}{
PageData: data,
Content: template.HTML(contentBuf.String()),
}
if err := h.templates.ExecuteTemplate(w, "layout", layoutData); err != nil {
http.Error(w, fmt.Sprintf("render layout: %v", err), http.StatusInternalServerError)
}
}
// Library handlers
func (h *Handler) Library(w http.ResponseWriter, r *http.Request) {
path := r.URL.Query().Get("path")
sortBy := r.URL.Query().Get("sort")
filter := r.URL.Query().Get("filter")
if sortBy == "" {
sortBy = getCookie(r, "library_sort", "date")
} else {
setCookie(w, "library_sort", sortBy)
}
media, folders, err := h.mediaSvc.GetAll(path, sortBy, filter)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
settings, _ := h.settingsSvc.GetAll()
h.renderWithRequest(w, r, "library", PageData{
Title: "Library",
ActiveTab: "library",
AutoRefresh: settings.AutoRefreshLibrary,
RefreshSec: settings.RefreshInterval,
Data: struct {
Media []*models.Media
Folders []string
Path string
SortBy string
Filter string
}{
Media: media,
Folders: folders,
Path: path,
SortBy: sortBy,
Filter: filter,
},
})
}
func (h *Handler) LibraryDetail(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
media, err := h.mediaSvc.GetByID(id)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
h.renderWithRequest(w, r, "library_detail", PageData{
Title: media.Title,
ActiveTab: "library",
Data: media,
})
}
func (h *Handler) ServeMedia(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
media, err := h.mediaSvc.GetByID(id)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
http.ServeFile(w, r, media.Filepath)
}
// Queue handlers
func (h *Handler) Downloads(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
sortBy := r.URL.Query().Get("sort")
if sortBy == "" {
sortBy = getCookie(r, "queue_sort", "date")
} else {
setCookie(w, "queue_sort", sortBy)
}
downloads, err := h.downloadSvc.GetAll(status, sortBy)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
settings, _ := h.settingsSvc.GetAll()
h.renderWithRequest(w, r, "queue", PageData{
Title: "Queue",
ActiveTab: "queue",
AutoRefresh: settings.AutoRefreshDownloads,
RefreshSec: settings.RefreshInterval,
Data: struct {
Items []*models.Download
Status string
SortBy string
}{
Items: downloads,
Status: status,
SortBy: sortBy,
},
})
}
func (h *Handler) DownloadDetail(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
download, err := h.downloadSvc.GetByID(id)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
h.renderWithRequest(w, r, "queue_detail", PageData{
Title: "Queue Details",
ActiveTab: "queue",
Data: download,
})
}
func (h *Handler) CreateDownload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
url := r.FormValue("url")
if url == "" {
http.Error(w, "URL is required", http.StatusBadRequest)
return
}
var presetID *int64
if pid := r.FormValue("preset_id"); pid != "" {
id, err := strconv.ParseInt(pid, 10, 64)
if err == nil {
presetID = &id
}
}
formatOverride := r.FormValue("format_override")
customFlags := r.FormValue("custom_flags")
download, err := h.downloadSvc.Create(url, presetID, formatOverride, customFlags)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.workerPool.Submit(download)
http.Redirect(w, r, "/queue", http.StatusSeeOther)
}
func (h *Handler) ListFormats(w http.ResponseWriter, r *http.Request) {
url := r.URL.Query().Get("url")
if url == "" {
http.Error(w, "URL is required", http.StatusBadRequest)
return
}
formats, err := h.downloadSvc.ListFormats(url)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.renderWithRequest(w, r, "formats", PageData{
Title: "Available Formats",
ActiveTab: "download",
Data: struct {
URL string
Formats []*models.FormatInfo
}{
URL: url,
Formats: formats,
},
})
}
func (h *Handler) DeleteDownload(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
if err := h.downloadSvc.Delete(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/queue", http.StatusSeeOther)
}
// Settings handlers
func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
presets, err := h.presetSvc.GetAll()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
settings, err := h.settingsSvc.GetAll()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.renderWithRequest(w, r, "settings", PageData{
Title: "Settings",
ActiveTab: "settings",
Data: struct {
Presets []*models.Preset
Settings *models.Settings
}{
Presets: presets,
Settings: settings,
},
})
}
func (h *Handler) CreatePreset(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
preset := &models.Preset{
Name: r.FormValue("name"),
Description: r.FormValue("description"),
FormatMode: r.FormValue("format_mode"),
Format: r.FormValue("format"),
Quality: r.FormValue("quality"),
CustomFormat: r.FormValue("custom_format"),
AudioFormat: r.FormValue("audio_format"),
SubLangs: r.FormValue("sub_langs"),
OutputTemplate: r.FormValue("output_template"),
CustomFlags: r.FormValue("custom_flags"),
}
preset.IsDefault = r.FormValue("is_default") == "1"
preset.ExtractAudio = r.FormValue("extract_audio") == "1"
preset.EmbedSubs = r.FormValue("embed_subs") == "1"
preset.EmbedThumbnail = r.FormValue("embed_thumbnail") == "1"
preset.EmbedMetadata = r.FormValue("embed_metadata") == "1"
preset.WriteInfoJSON = r.FormValue("write_info_json") == "1"
if err := h.presetSvc.Create(preset); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (h *Handler) UpdatePreset(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
preset, err := h.presetSvc.GetByID(id)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
preset.Name = r.FormValue("name")
preset.Description = r.FormValue("description")
preset.FormatMode = r.FormValue("format_mode")
preset.Format = r.FormValue("format")
preset.Quality = r.FormValue("quality")
preset.CustomFormat = r.FormValue("custom_format")
preset.AudioFormat = r.FormValue("audio_format")
preset.SubLangs = r.FormValue("sub_langs")
preset.OutputTemplate = r.FormValue("output_template")
preset.CustomFlags = r.FormValue("custom_flags")
preset.IsDefault = r.FormValue("is_default") == "1"
preset.ExtractAudio = r.FormValue("extract_audio") == "1"
preset.EmbedSubs = r.FormValue("embed_subs") == "1"
preset.EmbedThumbnail = r.FormValue("embed_thumbnail") == "1"
preset.EmbedMetadata = r.FormValue("embed_metadata") == "1"
preset.WriteInfoJSON = r.FormValue("write_info_json") == "1"
if err := h.presetSvc.Update(preset); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (h *Handler) DeletePreset(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
if err := h.presetSvc.Delete(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if interval := r.FormValue("refresh_interval"); interval != "" {
h.settingsSvc.SetRefreshInterval(interval)
}
h.settingsSvc.SetAutoRefreshLibrary(r.FormValue("auto_refresh_library") == "1")
h.settingsSvc.SetAutoRefreshDownloads(r.FormValue("auto_refresh_downloads") == "1")
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
// Download form page
func (h *Handler) DownloadForm(w http.ResponseWriter, r *http.Request) {
presets, err := h.presetSvc.GetAll()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defaultPreset, _ := h.presetSvc.GetDefault()
url := r.URL.Query().Get("url")
var formats []*models.FormatInfo
if r.URL.Query().Get("list_formats") == "1" && url != "" {
formats, _ = h.downloadSvc.ListFormats(url)
}
h.renderWithRequest(w, r, "download_form", PageData{
Title: "Download",
ActiveTab: "download",
Data: struct {
Presets []*models.Preset
DefaultPreset *models.Preset
URL string
FormatOverride string
CustomFlags string
Formats []*models.FormatInfo
ShowFormats bool
}{
Presets: presets,
DefaultPreset: defaultPreset,
URL: url,
FormatOverride: r.URL.Query().Get("format_override"),
CustomFlags: r.URL.Query().Get("custom_flags"),
Formats: formats,
ShowFormats: r.URL.Query().Get("list_formats") == "1",
},
})
}
// API handlers for HTMX-style interactions
func (h *Handler) GetPresetFlags(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
preset, err := h.presetSvc.GetByID(id)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
flags := h.presetSvc.EffectiveFlags(preset, "", "")
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(flags))
}
// Theme handler
func (h *Handler) Theme(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
theme := r.FormValue("theme")
if theme == "" {
theme = "auto"
}
setCookie(w, "theme", theme)
// Redirect back to the referring page, or home
referer := r.Header.Get("Referer")
if referer == "" {
referer = "/"
}
http.Redirect(w, r, referer, http.StatusSeeOther)
}
// Scan library
func (h *Handler) ScanLibrary(w http.ResponseWriter, r *http.Request) {
if err := h.mediaSvc.ScanLibrary(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/library", http.StatusSeeOther)
}
// Delete media
func (h *Handler) DeleteMedia(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
if err := h.mediaSvc.Delete(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/library", http.StatusSeeOther)
}
▾Ainternal/models/models.go
@@ -0,0 +1,77 @@
package models
import (
"database/sql"
"time"
)
type Preset struct {
ID int64
Name string
Description string
IsDefault bool
FormatMode string
Format string
Quality string
CustomFormat string
ExtractAudio bool
AudioFormat string
EmbedSubs bool
SubLangs string
EmbedThumbnail bool
EmbedMetadata bool
WriteInfoJSON bool
OutputTemplate string
CustomFlags string
CreatedAt time.Time
}
type Media struct {
ID int64
URL string
Title string
Description string
Duration int
Filepath string
RelativePath string
IsAudio bool
HasEmbeddedThumbnail bool
InfoJSONPath string
PlaylistID sql.NullString
PlaylistIndex sql.NullInt64
CreatedAt time.Time
}
type Download struct {
ID int64
URL string
Status string
ProgressPercent int
CurrentSpeed string
ETA string
Logs string
ErrorMessage string
PresetID sql.NullInt64
FormatOverride string
CustomFlags string
StartedAt sql.NullTime
CompletedAt sql.NullTime
CreatedAt time.Time
}
type Settings struct {
RefreshInterval int
AutoRefreshLibrary bool
AutoRefreshDownloads bool
}
type FormatInfo struct {
ID string
Ext string
Resolution string
FPS string
Channels string
FileSize string
Codec string
Note string
}
▾Ainternal/repository/download.go
@@ -0,0 +1,164 @@
package repository
import (
"database/sql"
"vidarchive/internal/models"
)
type DownloadRepository struct {
db *sql.DB
}
func NewDownloadRepository(db *sql.DB) *DownloadRepository {
return &DownloadRepository{db: db}
}
func (r *DownloadRepository) Create(d *models.Download) error {
result, err := r.db.Exec(
`INSERT INTO downloads (url, status, preset_id, format_override, custom_flags)
VALUES (?, ?, ?, ?, ?)`,
d.URL, d.Status, d.PresetID, d.FormatOverride, d.CustomFlags,
)
if err != nil {
return err
}
d.ID, _ = result.LastInsertId()
return nil
}
func (r *DownloadRepository) GetByID(id int64) (*models.Download, error) {
row := r.db.QueryRow(
`SELECT id, url, status, progress_percent, current_speed, eta, logs, error_message, preset_id, format_override, custom_flags, started_at, completed_at, created_at
FROM downloads WHERE id = ?`, id,
)
return scanDownload(row)
}
func (r *DownloadRepository) GetAll(status, sortBy string) ([]*models.Download, error) {
query := `SELECT id, url, status, progress_percent, current_speed, eta, logs, error_message, preset_id, format_override, custom_flags, started_at, completed_at, created_at
FROM downloads WHERE 1=1`
var args []interface{}
if status != "" && status != "all" {
query += ` AND status = ?`
args = append(args, status)
}
switch sortBy {
case "date":
query += ` ORDER BY created_at DESC`
case "progress":
query += ` ORDER BY progress_percent DESC`
case "status":
query += ` ORDER BY status, created_at DESC`
default:
query += ` ORDER BY created_at DESC`
}
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var downloads []*models.Download
for rows.Next() {
d, err := scanDownload(rows)
if err != nil {
return nil, err
}
downloads = append(downloads, d)
}
return downloads, rows.Err()
}
func (r *DownloadRepository) GetQueued(limit int) ([]*models.Download, error) {
rows, err := r.db.Query(
`SELECT id, url, status, progress_percent, current_speed, eta, logs, error_message, preset_id, format_override, custom_flags, started_at, completed_at, created_at
FROM downloads WHERE status = 'queued' ORDER BY created_at ASC LIMIT ?`,
limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var downloads []*models.Download
for rows.Next() {
d, err := scanDownload(rows)
if err != nil {
return nil, err
}
downloads = append(downloads, d)
}
return downloads, rows.Err()
}
func (r *DownloadRepository) UpdateStatus(id int64, status string) error {
_, err := r.db.Exec(`UPDATE downloads SET status = ? WHERE id = ?`, status, id)
return err
}
func (r *DownloadRepository) UpdateProgress(id int64, progress int, speed, eta string) error {
_, err := r.db.Exec(
`UPDATE downloads SET progress_percent = ?, current_speed = ?, eta = ? WHERE id = ?`,
progress, speed, eta, id,
)
return err
}
func (r *DownloadRepository) UpdateLogs(id int64, logs string) error {
_, err := r.db.Exec(`UPDATE downloads SET logs = ? WHERE id = ?`, logs, id)
return err
}
func (r *DownloadRepository) AppendLogs(id int64, logs string) error {
_, err := r.db.Exec(
`UPDATE downloads SET logs = COALESCE(logs, '') || ? WHERE id = ?`,
logs, id,
)
return err
}
func (r *DownloadRepository) MarkStarted(id int64) error {
_, err := r.db.Exec(
`UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?`,
id,
)
return err
}
func (r *DownloadRepository) MarkCompleted(id int64, status string) error {
_, err := r.db.Exec(
`UPDATE downloads SET status = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?`,
status, id,
)
return err
}
func (r *DownloadRepository) MarkError(id int64, errMsg string) error {
_, err := r.db.Exec(
`UPDATE downloads SET status = 'error', error_message = ? WHERE id = ?`,
errMsg, id,
)
return err
}
func (r *DownloadRepository) Delete(id int64) error {
_, err := r.db.Exec(`DELETE FROM downloads WHERE id = ?`, id)
return err
}
func scanDownload(row interface{ Scan(...interface{}) error }) (*models.Download, error) {
var d models.Download
err := row.Scan(
&d.ID, &d.URL, &d.Status, &d.ProgressPercent, &d.CurrentSpeed, &d.ETA,
&d.Logs, &d.ErrorMessage, &d.PresetID, &d.FormatOverride, &d.CustomFlags,
&d.StartedAt, &d.CompletedAt, &d.CreatedAt,
)
if err != nil {
return nil, err
}
return &d, nil
}
▾Ainternal/repository/media.go
@@ -0,0 +1,139 @@
package repository
import (
"database/sql"
"strings"
"vidarchive/internal/models"
)
type MediaRepository struct {
db *sql.DB
}
func NewMediaRepository(db *sql.DB) *MediaRepository {
return &MediaRepository{db: db}
}
func (r *MediaRepository) Create(m *models.Media) error {
result, err := r.db.Exec(
`INSERT INTO media (url, title, description, duration, filepath, relative_path, is_audio, has_embedded_thumbnail, info_json_path, playlist_id, playlist_index)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
m.URL, m.Title, m.Description, m.Duration, m.Filepath, m.RelativePath,
boolToInt(m.IsAudio), boolToInt(m.HasEmbeddedThumbnail), m.InfoJSONPath,
m.PlaylistID, m.PlaylistIndex,
)
if err != nil {
return err
}
m.ID, _ = result.LastInsertId()
return nil
}
func (r *MediaRepository) GetByID(id int64) (*models.Media, error) {
row := r.db.QueryRow(
`SELECT id, url, title, description, duration, filepath, relative_path, is_audio, has_embedded_thumbnail, info_json_path, playlist_id, playlist_index, created_at
FROM media WHERE id = ?`, id,
)
return scanMedia(row)
}
func (r *MediaRepository) GetAll(path string, sortBy, filter string) ([]*models.Media, error) {
query := `SELECT id, url, title, description, duration, filepath, relative_path, is_audio, has_embedded_thumbnail, info_json_path, playlist_id, playlist_index, created_at
FROM media WHERE relative_path LIKE ?`
args := []interface{}{path + "%"}
if filter != "" {
query += ` AND (title LIKE ? OR description LIKE ?)`
args = append(args, "%"+filter+"%", "%"+filter+"%")
}
switch sortBy {
case "title":
query += ` ORDER BY title`
case "date":
query += ` ORDER BY created_at DESC`
case "duration":
query += ` ORDER BY duration DESC`
default:
query += ` ORDER BY created_at DESC`
}
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var media []*models.Media
for rows.Next() {
m, err := scanMedia(rows)
if err != nil {
return nil, err
}
media = append(media, m)
}
return media, rows.Err()
}
func (r *MediaRepository) GetByRelativePath(relPath string) (*models.Media, error) {
row := r.db.QueryRow(
`SELECT id, url, title, description, duration, filepath, relative_path, is_audio, has_embedded_thumbnail, info_json_path, playlist_id, playlist_index, created_at
FROM media WHERE relative_path = ?`, relPath,
)
return scanMedia(row)
}
func (r *MediaRepository) Delete(id int64) error {
_, err := r.db.Exec(`DELETE FROM media WHERE id = ?`, id)
return err
}
func (r *MediaRepository) GetFolders(parentPath string) ([]string, error) {
rows, err := r.db.Query(
`SELECT relative_path FROM media WHERE relative_path LIKE ?`,
parentPath+"%",
)
if err != nil {
return nil, err
}
defer rows.Close()
folders := make(map[string]bool)
for rows.Next() {
var relPath string
if err := rows.Scan(&relPath); err != nil {
return nil, err
}
// Get the folder part after parentPath
remaining := strings.TrimPrefix(relPath, parentPath)
remaining = strings.TrimPrefix(remaining, "/")
if idx := strings.Index(remaining, "/"); idx >= 0 {
folder := remaining[:idx]
if folder != "" {
folders[parentPath+"/"+folder] = true
}
}
}
var result []string
for f := range folders {
result = append(result, f)
}
return result, rows.Err()
}
func scanMedia(row interface{ Scan(...interface{}) error }) (*models.Media, error) {
var m models.Media
var isAudio, hasThumb int
err := row.Scan(
&m.ID, &m.URL, &m.Title, &m.Description, &m.Duration, &m.Filepath,
&m.RelativePath, &isAudio, &hasThumb, &m.InfoJSONPath,
&m.PlaylistID, &m.PlaylistIndex, &m.CreatedAt,
)
if err != nil {
return nil, err
}
m.IsAudio = isAudio == 1
m.HasEmbeddedThumbnail = hasThumb == 1
return &m, nil
}
▾Ainternal/repository/preset.go
@@ -0,0 +1,128 @@
package repository
import (
"database/sql"
"vidarchive/internal/models"
)
type PresetRepository struct {
db *sql.DB
}
func NewPresetRepository(db *sql.DB) *PresetRepository {
return &PresetRepository{db: db}
}
func (r *PresetRepository) Create(p *models.Preset) error {
result, err := r.db.Exec(
`INSERT INTO presets (name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, output_template, custom_flags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Name, p.Description, boolToInt(p.IsDefault), p.FormatMode, p.Format, p.Quality, p.CustomFormat,
boolToInt(p.ExtractAudio), p.AudioFormat, boolToInt(p.EmbedSubs), p.SubLangs,
boolToInt(p.EmbedThumbnail), boolToInt(p.EmbedMetadata), boolToInt(p.WriteInfoJSON),
p.OutputTemplate, p.CustomFlags,
)
if err != nil {
return err
}
p.ID, _ = result.LastInsertId()
return nil
}
func (r *PresetRepository) GetByID(id int64) (*models.Preset, error) {
row := r.db.QueryRow(
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, output_template, custom_flags, created_at
FROM presets WHERE id = ?`, id,
)
return scanPreset(row)
}
func (r *PresetRepository) GetAll() ([]*models.Preset, error) {
rows, err := r.db.Query(
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, output_template, custom_flags, created_at
FROM presets ORDER BY name`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var presets []*models.Preset
for rows.Next() {
p, err := scanPreset(rows)
if err != nil {
return nil, err
}
presets = append(presets, p)
}
return presets, rows.Err()
}
func (r *PresetRepository) GetDefault() (*models.Preset, error) {
row := r.db.QueryRow(
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, output_template, custom_flags, created_at
FROM presets WHERE is_default = 1 LIMIT 1`,
)
return scanPreset(row)
}
func (r *PresetRepository) Update(p *models.Preset) error {
_, err := r.db.Exec(
`UPDATE presets SET name=?, description=?, is_default=?, format_mode=?, format=?, quality=?, custom_format=?, extract_audio=?, audio_format=?, embed_subs=?, sub_langs=?, embed_thumbnail=?, embed_metadata=?, write_info_json=?, output_template=?, custom_flags=?
WHERE id=?`,
p.Name, p.Description, boolToInt(p.IsDefault), p.FormatMode, p.Format, p.Quality, p.CustomFormat,
boolToInt(p.ExtractAudio), p.AudioFormat, boolToInt(p.EmbedSubs), p.SubLangs,
boolToInt(p.EmbedThumbnail), boolToInt(p.EmbedMetadata), boolToInt(p.WriteInfoJSON),
p.OutputTemplate, p.CustomFlags, p.ID,
)
return err
}
func (r *PresetRepository) Delete(id int64) error {
_, err := r.db.Exec(`DELETE FROM presets WHERE id = ?`, id)
return err
}
func (r *PresetRepository) ClearDefault() error {
_, err := r.db.Exec(`UPDATE presets SET is_default = 0`)
return err
}
func scanPreset(row interface{ Scan(...interface{}) error }) (*models.Preset, error) {
var p models.Preset
var isDefault, extractAudio, embedSubs, embedThumbnail, embedMetadata, writeInfoJSON int
var description, formatMode, format, quality, customFormat, audioFormat, subLangs, outputTemplate, customFlags sql.NullString
err := row.Scan(
&p.ID, &p.Name, &description, &isDefault, &formatMode, &format, &quality, &customFormat,
&extractAudio, &audioFormat, &embedSubs, &subLangs,
&embedThumbnail, &embedMetadata, &writeInfoJSON,
&outputTemplate, &customFlags, &p.CreatedAt,
)
if err != nil {
return nil, err
}
p.Description = description.String
p.FormatMode = formatMode.String
p.Format = format.String
p.Quality = quality.String
p.CustomFormat = customFormat.String
p.AudioFormat = audioFormat.String
p.SubLangs = subLangs.String
p.OutputTemplate = outputTemplate.String
p.CustomFlags = customFlags.String
p.IsDefault = isDefault == 1
p.ExtractAudio = extractAudio == 1
p.EmbedSubs = embedSubs == 1
p.EmbedThumbnail = embedThumbnail == 1
p.EmbedMetadata = embedMetadata == 1
p.WriteInfoJSON = writeInfoJSON == 1
return &p, nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
▾Ainternal/repository/preset_test.go
@@ -0,0 +1,119 @@
package repository
import (
"database/sql"
"testing"
"vidarchive/internal/config"
"vidarchive/internal/database"
"vidarchive/internal/models"
)
func setupTestDB(t *testing.T) *sql.DB {
cfg := &config.Config{
DBPath: ":memory:",
}
db, err := database.New(cfg)
if err != nil {
t.Fatalf("failed to create test db: %v", err)
}
return db
}
func TestPresetRepository(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewPresetRepository(db)
// Test Create
preset := &models.Preset{
Name: "Test Preset",
Description: "Test description",
Format: "best",
Quality: "1080",
}
if err := repo.Create(preset); err != nil {
t.Fatalf("failed to create preset: %v", err)
}
if preset.ID == 0 {
t.Error("expected preset ID to be set after create")
}
// Test GetByID
found, err := repo.GetByID(preset.ID)
if err != nil {
t.Fatalf("failed to get preset by ID: %v", err)
}
if found.Name != "Test Preset" {
t.Errorf("expected name 'Test Preset', got %q", found.Name)
}
// Test GetAll
all, err := repo.GetAll()
if err != nil {
t.Fatalf("failed to get all presets: %v", err)
}
if len(all) < 1 {
t.Errorf("expected at least 1 preset, got %d", len(all))
}
// Test Update
preset.Name = "Updated Preset"
if err := repo.Update(preset); err != nil {
t.Fatalf("failed to update preset: %v", err)
}
updated, _ := repo.GetByID(preset.ID)
if updated.Name != "Updated Preset" {
t.Errorf("expected updated name 'Updated Preset', got %q", updated.Name)
}
// Test Delete
if err := repo.Delete(preset.ID); err != nil {
t.Fatalf("failed to delete preset: %v", err)
}
_, err = repo.GetByID(preset.ID)
if err == nil {
t.Error("expected error when getting deleted preset")
}
}
func TestPresetRepositoryDefault(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewPresetRepository(db)
// Clear existing default from migration
repo.ClearDefault()
// Create default preset with unique name
preset := &models.Preset{
Name: "TestDefault",
IsDefault: true,
}
if err := repo.Create(preset); err != nil {
t.Fatalf("failed to create preset: %v", err)
}
// Test GetDefault
found, err := repo.GetDefault()
if err != nil {
t.Fatalf("failed to get default preset: %v", err)
}
if found.Name != "TestDefault" {
t.Errorf("expected default preset name 'TestDefault', got %q", found.Name)
}
if !found.IsDefault {
t.Error("expected preset to be default")
}
// Test ClearDefault
if err := repo.ClearDefault(); err != nil {
t.Fatalf("failed to clear default: %v", err)
}
_, err = repo.GetDefault()
if err == nil {
t.Error("expected no default preset after clearing")
}
}
▾Ainternal/repository/settings.go
@@ -0,0 +1,64 @@
package repository
import (
"database/sql"
"strconv"
"vidarchive/internal/models"
)
type SettingsRepository struct {
db *sql.DB
}
func NewSettingsRepository(db *sql.DB) *SettingsRepository {
return &SettingsRepository{db: db}
}
func (r *SettingsRepository) GetAll() (*models.Settings, error) {
settings := &models.Settings{
RefreshInterval: 5,
AutoRefreshLibrary: false,
AutoRefreshDownloads: true,
}
rows, err := r.db.Query(`SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
return nil, err
}
switch key {
case "refresh_interval":
if v, err := strconv.Atoi(value); err == nil {
settings.RefreshInterval = v
}
case "auto_refresh_library":
settings.AutoRefreshLibrary = value == "1" || value == "true"
case "auto_refresh_downloads":
settings.AutoRefreshDownloads = value == "1" || value == "true"
}
}
return settings, rows.Err()
}
func (r *SettingsRepository) Set(key, value string) error {
_, err := r.db.Exec(
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
key, value,
)
return err
}
func (r *SettingsRepository) Get(key string) (string, error) {
var value string
err := r.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}
▾Ainternal/server/server.go
@@ -0,0 +1,94 @@
package server
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"vidarchive/internal/config"
"vidarchive/internal/handler"
)
type Server struct {
router *chi.Mux
handler *handler.Handler
cfg *config.Config
}
func New(cfg *config.Config, h *handler.Handler) *Server {
s := &Server{
router: chi.NewRouter(),
handler: h,
cfg: cfg,
}
s.setupRoutes()
return s
}
func (s *Server) setupRoutes() {
// Middleware
s.router.Use(middleware.Logger)
s.router.Use(middleware.Recoverer)
s.router.Use(s.securityHeaders)
// Static files
s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
// Media serving (with range request support via http.ServeFile)
s.router.Get("/media/{id}", s.handler.ServeMedia)
// Pages
s.router.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/library", http.StatusSeeOther)
})
s.router.Get("/library", s.handler.Library)
s.router.Get("/library/{id}", s.handler.LibraryDetail)
s.router.Post("/library/scan", s.handler.ScanLibrary)
s.router.Post("/library/{id}/delete", s.handler.DeleteMedia)
s.router.Get("/queue", s.handler.Downloads)
s.router.Get("/queue/{id}", s.handler.DownloadDetail)
s.router.Post("/queue/{id}/delete", s.handler.DeleteDownload)
s.router.Get("/download", s.handler.DownloadForm)
s.router.Post("/download", s.handler.CreateDownload)
s.router.Get("/settings", s.handler.Settings)
s.router.Post("/settings/presets", s.handler.CreatePreset)
s.router.Post("/settings/presets/{id}", s.handler.UpdatePreset)
s.router.Post("/settings/presets/{id}/delete", s.handler.DeletePreset)
s.router.Post("/settings", s.handler.UpdateSettings)
s.router.Post("/theme", s.handler.Theme)
// API endpoints
s.router.Get("/api/presets/{id}/flags", s.handler.GetPresetFlags)
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
if s.cfg.IsHTTPS() {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; media-src 'self' blob:;")
}
next.ServeHTTP(w, r)
})
}
func (s *Server) Start() error {
addr := fmt.Sprintf(":%d", s.cfg.Port)
fmt.Printf("Starting server on %s\n", addr)
if s.cfg.BaseURL != "" {
fmt.Printf("Base URL: %s\n", s.cfg.BaseURL)
fmt.Printf("HTTPS mode: %v\n", s.cfg.IsHTTPS())
}
return http.ListenAndServe(addr, s.router)
}
▾Ainternal/service/download.go
@@ -0,0 +1,241 @@
package service
import (
"database/sql"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"vidarchive/internal/config"
"vidarchive/internal/models"
"vidarchive/internal/repository"
)
type DownloadService struct {
repo *repository.DownloadRepository
mediaRepo *repository.MediaRepository
presetSvc *PresetService
cfg *config.Config
}
func NewDownloadService(repo *repository.DownloadRepository, mediaRepo *repository.MediaRepository, presetSvc *PresetService, cfg *config.Config) *DownloadService {
return &DownloadService{
repo: repo,
mediaRepo: mediaRepo,
presetSvc: presetSvc,
cfg: cfg,
}
}
func (s *DownloadService) Create(url string, presetID *int64, formatOverride, customFlags string) (*models.Download, error) {
d := &models.Download{
URL: url,
Status: "queued",
FormatOverride: formatOverride,
CustomFlags: customFlags,
}
if presetID != nil {
d.PresetID = sqlNullInt64(*presetID)
}
if err := s.repo.Create(d); err != nil {
return nil, err
}
return d, nil
}
func (s *DownloadService) GetByID(id int64) (*models.Download, error) {
return s.repo.GetByID(id)
}
func (s *DownloadService) GetAll(status, sortBy string) ([]*models.Download, error) {
return s.repo.GetAll(status, sortBy)
}
func (s *DownloadService) GetQueued(limit int) ([]*models.Download, error) {
return s.repo.GetQueued(limit)
}
func (s *DownloadService) Delete(id int64) error {
return s.repo.Delete(id)
}
func (s *DownloadService) ListFormats(url string) ([]*models.FormatInfo, error) {
cmd := exec.Command(s.cfg.YTDLPPath, "-F", "--no-warnings", url)
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("yt-dlp -F failed: %w\nOutput: %s", err, string(output))
}
return parseFormatList(string(output)), nil
}
func (s *DownloadService) ExecuteDownload(d *models.Download) error {
if err := s.repo.MarkStarted(d.ID); err != nil {
return err
}
var preset *models.Preset
var err error
if d.PresetID.Valid {
preset, err = s.presetSvc.GetByID(d.PresetID.Int64)
if err != nil {
preset, _ = s.presetSvc.GetDefault()
}
} else {
preset, _ = s.presetSvc.GetDefault()
}
if preset == nil {
preset = &models.Preset{
OutputTemplate: "%(title)s [%(id)s].%(ext)s",
}
}
args := s.presetSvc.BuildArgs(preset, d.FormatOverride, d.CustomFlags)
args = append(args, "--newline", "--progress", "-P", s.cfg.DownloadDir)
args = append(args, d.URL)
cmd := exec.Command(s.cfg.YTDLPPath, args...)
// Set up log capture
var logBuilder strings.Builder
// Start the command
if err := cmd.Start(); err != nil {
s.repo.MarkError(d.ID, err.Error())
return err
}
// TODO: In a real implementation, we'd parse stdout in real-time
// For now, just wait and capture output
output, err := cmd.CombinedOutput()
logBuilder.Write(output)
s.repo.UpdateLogs(d.ID, logBuilder.String())
if err != nil {
s.repo.MarkError(d.ID, err.Error())
return err
}
// Mark as completed
if err := s.repo.MarkCompleted(d.ID, "completed"); err != nil {
return err
}
// Scan for new media files
s.scanForNewMedia(d.URL)
return nil
}
func (s *DownloadService) scanForNewMedia(url string) {
// Simple scan - in production, parse yt-dlp output to know exactly what was downloaded
time.Sleep(100 * time.Millisecond) // Give filesystem a moment
// Walk download dir and add any new files
filepath.Walk(s.cfg.DownloadDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
relPath, _ := filepath.Rel(s.cfg.DownloadDir, path)
relPath = filepath.ToSlash(relPath)
_, err = s.mediaRepo.GetByRelativePath(relPath)
if err == nil {
return nil // already exists
}
ext := strings.ToLower(filepath.Ext(path))
isAudio := ext == ".mp3" || ext == ".m4a" || ext == ".ogg" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus"
isVideo := ext == ".mp4" || ext == ".webm" || ext == ".mkv" || ext == ".avi" || ext == ".mov"
if !isAudio && !isVideo {
return nil
}
basePath := strings.TrimSuffix(path, ext)
infoJSONPath := basePath + ".info.json"
if _, err := os.Stat(infoJSONPath); err != nil {
infoJSONPath = ""
}
media := &models.Media{
URL: url,
Filepath: path,
RelativePath: relPath,
IsAudio: isAudio,
HasEmbeddedThumbnail: true, // yt-dlp usually embeds when requested
InfoJSONPath: infoJSONPath,
Title: strings.TrimSuffix(filepath.Base(path), ext),
}
s.mediaRepo.Create(media)
return nil
})
}
func parseFormatList(output string) []*models.FormatInfo {
// Simple parser for yt-dlp -F output
// This is a basic implementation - yt-dlp format can vary
lines := strings.Split(output, "\n")
var formats []*models.FormatInfo
inFormats := false
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Look for the format table header
if strings.Contains(line, "ID") && strings.Contains(line, "EXT") {
inFormats = true
continue
}
if !inFormats {
continue
}
// Parse format line
parts := strings.Fields(line)
if len(parts) >= 4 {
format := &models.FormatInfo{
ID: parts[0],
Ext: parts[1],
}
// Try to extract resolution
for i, part := range parts {
if strings.Contains(part, "x") && !strings.Contains(part, "http") {
format.Resolution = part
if i+1 < len(parts) {
format.FPS = parts[i+1]
}
break
}
}
// Note is everything after resolution
if len(parts) > 3 {
format.Note = strings.Join(parts[3:], " ")
}
formats = append(formats, format)
}
}
return formats
}
func sqlNullInt64(v int64) sql.NullInt64 {
return sql.NullInt64{Int64: v, Valid: true}
}
▾Ainternal/service/media.go
@@ -0,0 +1,113 @@
package service
import (
"fmt"
"os"
"path/filepath"
"strings"
"vidarchive/internal/models"
"vidarchive/internal/repository"
)
type MediaService struct {
repo *repository.MediaRepository
downloadDir string
}
func NewMediaService(repo *repository.MediaRepository, downloadDir string) *MediaService {
return &MediaService{repo: repo, downloadDir: downloadDir}
}
func (s *MediaService) GetByID(id int64) (*models.Media, error) {
return s.repo.GetByID(id)
}
func (s *MediaService) GetByRelativePath(relPath string) (*models.Media, error) {
return s.repo.GetByRelativePath(relPath)
}
func (s *MediaService) GetAll(path, sortBy, filter string) ([]*models.Media, []string, error) {
if path != "" && !strings.HasSuffix(path, "/") {
path += "/"
}
media, err := s.repo.GetAll(path, sortBy, filter)
if err != nil {
return nil, nil, err
}
folders, err := s.repo.GetFolders(path)
if err != nil {
return nil, nil, err
}
return media, folders, nil
}
func (s *MediaService) ScanLibrary() error {
return filepath.Walk(s.downloadDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
// Check if this file is already in DB
relPath, _ := filepath.Rel(s.downloadDir, path)
relPath = filepath.ToSlash(relPath)
_, err = s.repo.GetByRelativePath(relPath)
if err == nil {
return nil // already exists
}
// Determine file type
ext := strings.ToLower(filepath.Ext(path))
isAudio := ext == ".mp3" || ext == ".m4a" || ext == ".ogg" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus"
isVideo := ext == ".mp4" || ext == ".webm" || ext == ".mkv" || ext == ".avi" || ext == ".mov"
if !isAudio && !isVideo {
return nil
}
// Try to find info.json
basePath := strings.TrimSuffix(path, ext)
infoJSONPath := basePath + ".info.json"
if _, err := os.Stat(infoJSONPath); err != nil {
infoJSONPath = ""
}
media := &models.Media{
Filepath: path,
RelativePath: relPath,
IsAudio: isAudio,
InfoJSONPath: infoJSONPath,
}
// Try to extract title from filename
media.Title = strings.TrimSuffix(filepath.Base(path), ext)
return s.repo.Create(media)
})
}
func (s *MediaService) Delete(id int64) error {
media, err := s.repo.GetByID(id)
if err != nil {
return err
}
// Delete file
if err := os.Remove(media.Filepath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove file: %w", err)
}
// Delete info json if exists
if media.InfoJSONPath != "" {
os.Remove(media.InfoJSONPath)
}
return s.repo.Delete(id)
}
▾Ainternal/service/preset.go
@@ -0,0 +1,120 @@
package service
import (
"fmt"
"strings"
"vidarchive/internal/models"
"vidarchive/internal/repository"
)
type PresetService struct {
repo *repository.PresetRepository
}
func NewPresetService(repo *repository.PresetRepository) *PresetService {
return &PresetService{repo: repo}
}
func (s *PresetService) Create(p *models.Preset) error {
if p.IsDefault {
if err := s.repo.ClearDefault(); err != nil {
return err
}
}
return s.repo.Create(p)
}
func (s *PresetService) GetByID(id int64) (*models.Preset, error) {
return s.repo.GetByID(id)
}
func (s *PresetService) GetAll() ([]*models.Preset, error) {
return s.repo.GetAll()
}
func (s *PresetService) GetDefault() (*models.Preset, error) {
return s.repo.GetDefault()
}
func (s *PresetService) Update(p *models.Preset) error {
if p.IsDefault {
if err := s.repo.ClearDefault(); err != nil {
return err
}
}
return s.repo.Update(p)
}
func (s *PresetService) Delete(id int64) error {
return s.repo.Delete(id)
}
func (s *PresetService) BuildArgs(p *models.Preset, formatOverride, customFlags string) []string {
var args []string
// Format selection
if formatOverride != "" {
args = append(args, "-f", formatOverride)
} else if p.FormatMode == "custom" && p.CustomFormat != "" {
args = append(args, "-f", p.CustomFormat)
} else if p.FormatMode == "preset" && p.Format != "" {
if p.Quality != "" {
args = append(args, "-f", fmt.Sprintf("%s[height<=%s]/%s", p.Format, p.Quality, p.Format))
} else {
args = append(args, "-f", p.Format)
}
}
// If FormatMode is "default" or empty, don't pass -f (let yt-dlp choose)
// Audio extraction
if p.ExtractAudio {
args = append(args, "--extract-audio")
if p.AudioFormat != "" {
args = append(args, "--audio-format", p.AudioFormat)
}
}
// Subtitles
if p.EmbedSubs {
args = append(args, "--embed-subs")
if p.SubLangs != "" {
args = append(args, "--sub-langs", p.SubLangs)
}
}
// Thumbnail
if p.EmbedThumbnail {
args = append(args, "--embed-thumbnail")
}
// Metadata
if p.EmbedMetadata {
args = append(args, "--embed-metadata")
}
// Info JSON
if p.WriteInfoJSON {
args = append(args, "--write-info-json")
}
// Output template
if p.OutputTemplate != "" {
args = append(args, "-o", p.OutputTemplate)
}
// Custom flags
if p.CustomFlags != "" {
args = append(args, strings.Fields(p.CustomFlags)...)
}
if customFlags != "" {
args = append(args, strings.Fields(customFlags)...)
}
return args
}
func (s *PresetService) EffectiveFlags(p *models.Preset, formatOverride, customFlags string) string {
args := s.BuildArgs(p, formatOverride, customFlags)
return strings.Join(args, " ")
}
▾Ainternal/service/preset_test.go
@@ -0,0 +1,150 @@
package service
import (
"testing"
"vidarchive/internal/models"
)
func TestPresetServiceBuildArgs(t *testing.T) {
// Create a mock preset
preset := &models.Preset{
FormatMode: "preset",
Format: "best",
Quality: "1080",
ExtractAudio: true,
AudioFormat: "mp3",
EmbedSubs: true,
SubLangs: "en,de",
EmbedThumbnail: true,
EmbedMetadata: true,
WriteInfoJSON: true,
OutputTemplate: "%(title)s.%(ext)s",
CustomFlags: "--no-playlist",
}
svc := &PresetService{}
args := svc.BuildArgs(preset, "", "")
// Check that expected args are present
hasFormat := false
hasAudio := false
hasSubs := false
hasOutput := false
for i := 0; i < len(args); i++ {
switch args[i] {
case "-f":
if i+1 < len(args) {
if args[i+1] == "best[height<=1080]/best" {
hasFormat = true
}
}
case "--extract-audio":
hasAudio = true
case "--embed-subs":
hasSubs = true
case "-o":
if i+1 < len(args) {
if args[i+1] == "%(title)s.%(ext)s" {
hasOutput = true
}
}
}
}
if !hasFormat {
t.Error("expected format arg with quality limit")
}
if !hasAudio {
t.Error("expected --extract-audio flag")
}
if !hasSubs {
t.Error("expected --embed-subs flag")
}
if !hasOutput {
t.Error("expected output template")
}
}
func TestPresetServiceBuildArgsWithOverride(t *testing.T) {
preset := &models.Preset{
Format: "best",
}
svc := &PresetService{}
args := svc.BuildArgs(preset, "22", "--verbose")
// Format override should take precedence
hasOverride := false
hasCustom := false
for i := 0; i < len(args); i++ {
if args[i] == "-f" && i+1 < len(args) && args[i+1] == "22" {
hasOverride = true
}
if args[i] == "--verbose" {
hasCustom = true
}
}
if !hasOverride {
t.Error("expected format override to be present")
}
if !hasCustom {
t.Error("expected custom flags to be present")
}
}
func TestPresetServiceEffectiveFlags(t *testing.T) {
preset := &models.Preset{
FormatMode: "preset",
Format: "best",
Quality: "720",
}
svc := &PresetService{}
flags := svc.EffectiveFlags(preset, "", "")
if flags == "" {
t.Error("expected non-empty flags string")
}
if flags != "-f best[height<=720]/best" {
t.Errorf("expected '-f best[height<=720]/best', got %q", flags)
}
}
func TestPresetServiceEffectiveFlagsWithCustom(t *testing.T) {
preset := &models.Preset{
FormatMode: "preset",
Format: "best",
CustomFlags: "--no-warnings",
}
svc := &PresetService{}
flags := svc.EffectiveFlags(preset, "", "--verbose")
if flags == "" {
t.Error("expected non-empty flags string")
}
// Should contain both preset custom flags and passed custom flags
if !contains(flags, "--no-warnings") {
t.Errorf("expected flags to contain '--no-warnings', got %q", flags)
}
if !contains(flags, "--verbose") {
t.Errorf("expected flags to contain '--verbose', got %q", flags)
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
▾Ainternal/service/settings.go
@@ -0,0 +1,50 @@
package service
import (
"strconv"
"vidarchive/internal/models"
"vidarchive/internal/repository"
)
type SettingsService struct {
repo *repository.SettingsRepository
}
func NewSettingsService(repo *repository.SettingsRepository) *SettingsService {
return &SettingsService{repo: repo}
}
func (s *SettingsService) GetAll() (*models.Settings, error) {
return s.repo.GetAll()
}
func (s *SettingsService) SetRefreshInterval(interval string) error {
return s.repo.Set("refresh_interval", interval)
}
func (s *SettingsService) SetAutoRefreshLibrary(enabled bool) error {
val := "0"
if enabled {
val = "1"
}
return s.repo.Set("auto_refresh_library", val)
}
func (s *SettingsService) SetAutoRefreshDownloads(enabled bool) error {
val := "0"
if enabled {
val = "1"
}
return s.repo.Set("auto_refresh_downloads", val)
}
func (s *SettingsService) GetRefreshInterval() (int, error) {
val, err := s.repo.Get("refresh_interval")
if err != nil {
return 5, err
}
if val == "" {
return 5, nil
}
return strconv.Atoi(val)
}
▾Ainternal/service/settings_test.go
@@ -0,0 +1,83 @@
package service
import (
"database/sql"
"testing"
"vidarchive/internal/config"
"vidarchive/internal/database"
"vidarchive/internal/repository"
)
func setupTestDB(t *testing.T) *sql.DB {
cfg := &config.Config{
DBPath: ":memory:",
}
db, err := database.New(cfg)
if err != nil {
t.Fatalf("failed to create test db: %v", err)
}
return db
}
func TestSettingsService(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
settingsRepo := repository.NewSettingsRepository(db)
svc := NewSettingsService(settingsRepo)
// Test GetAll defaults
settings, err := svc.GetAll()
if err != nil {
t.Fatalf("failed to get settings: %v", err)
}
if settings.RefreshInterval != 5 {
t.Errorf("expected default refresh interval 5, got %d", settings.RefreshInterval)
}
if settings.AutoRefreshLibrary {
t.Error("expected auto_refresh_library to be false by default")
}
if !settings.AutoRefreshDownloads {
t.Error("expected auto_refresh_downloads to be true by default")
}
// Test SetRefreshInterval
if err := svc.SetRefreshInterval("10"); err != nil {
t.Fatalf("failed to set refresh interval: %v", err)
}
settings, _ = svc.GetAll()
if settings.RefreshInterval != 10 {
t.Errorf("expected refresh interval 10, got %d", settings.RefreshInterval)
}
// Test SetAutoRefreshLibrary
if err := svc.SetAutoRefreshLibrary(true); err != nil {
t.Fatalf("failed to set auto refresh library: %v", err)
}
settings, _ = svc.GetAll()
if !settings.AutoRefreshLibrary {
t.Error("expected auto_refresh_library to be true")
}
// Test SetAutoRefreshDownloads
if err := svc.SetAutoRefreshDownloads(false); err != nil {
t.Fatalf("failed to set auto refresh downloads: %v", err)
}
settings, _ = svc.GetAll()
if settings.AutoRefreshDownloads {
t.Error("expected auto_refresh_downloads to be false")
}
// Test GetRefreshInterval
interval, err := svc.GetRefreshInterval()
if err != nil {
t.Fatalf("failed to get refresh interval: %v", err)
}
if interval != 10 {
t.Errorf("expected refresh interval 10, got %d", interval)
}
}
▾Ainternal/worker/pool.go
@@ -0,0 +1,127 @@
package worker
import (
"context"
"fmt"
"log"
"time"
"vidarchive/internal/models"
"vidarchive/internal/service"
)
type Pool struct {
downloadSvc *service.DownloadService
workers int
queue chan *models.Download
ctx context.Context
cancel context.CancelFunc
}
func New(downloadSvc *service.DownloadService, workers int) *Pool {
ctx, cancel := context.WithCancel(context.Background())
return &Pool{
downloadSvc: downloadSvc,
workers: workers,
queue: make(chan *models.Download, 100),
ctx: ctx,
cancel: cancel,
}
}
func (p *Pool) Start() {
for i := 0; i < p.workers; i++ {
go p.worker(i)
}
// Queue checker - polls DB for queued downloads
go p.queueChecker()
}
func (p *Pool) Stop() {
p.cancel()
}
func (p *Pool) Submit(d *models.Download) {
select {
case p.queue <- d:
log.Printf("Download %d queued", d.ID)
case <-p.ctx.Done():
}
}
func (p *Pool) worker(id int) {
log.Printf("Worker %d started", id)
for {
select {
case d := <-p.queue:
if d == nil {
continue
}
log.Printf("Worker %d processing download %d", id, d.ID)
if err := p.downloadSvc.ExecuteDownload(d); err != nil {
log.Printf("Worker %d download %d failed: %v", id, d.ID, err)
} else {
log.Printf("Worker %d download %d completed", id, d.ID)
}
case <-p.ctx.Done():
log.Printf("Worker %d stopped", id)
return
}
}
}
func (p *Pool) queueChecker() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
p.checkQueue()
case <-p.ctx.Done():
return
}
}
}
func (p *Pool) checkQueue() {
// Check how many items are in queue vs active
downloads, err := p.downloadSvc.GetAll("queued", "date")
if err != nil {
log.Printf("Queue check error: %v", err)
return
}
for _, d := range downloads {
// Try to submit - if queue is full, it'll block briefly
select {
case p.queue <- d:
default:
// Queue is full, skip for now
return
}
}
}
func (p *Pool) GetQueueStatus() (queued, active, completed, failed int, err error) {
all, err := p.downloadSvc.GetAll("all", "date")
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("get all downloads: %w", err)
}
for _, d := range all {
switch d.Status {
case "queued":
queued++
case "downloading":
active++
case "completed":
completed++
case "error":
failed++
}
}
return queued, active, completed, failed, nil
}
▾Aweb/static/style.css
@@ -0,0 +1,893 @@
:root {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-tertiary: #e0e0e0;
--text-primary: #212121;
--text-secondary: #666666;
--accent: #1976d2;
--accent-hover: #1565c0;
--danger: #d32f2f;
--danger-hover: #b71c1c;
--success: #388e3c;
--warning: #f57c00;
--border: #e0e0e0;
--shadow: rgba(0, 0, 0, 0.1);
--player-bg: #000000;
}
[data-theme="dark"] {
--bg-primary: #121212;
--bg-secondary: #1e1e1e;
--bg-tertiary: #2d2d2d;
--text-primary: #ffffff;
--text-secondary: #b0b0b0;
--accent: #90caf9;
--accent-hover: #64b5f6;
--danger: #ef5350;
--danger-hover: #e53935;
--success: #66bb6a;
--warning: #ffa726;
--border: #424242;
--shadow: rgba(0, 0, 0, 0.3);
--player-bg: #000000;
}
@media (prefers-color-scheme: dark) {
[data-theme="auto"] {
--bg-primary: #121212;
--bg-secondary: #1e1e1e;
--bg-tertiary: #2d2d2d;
--text-primary: #ffffff;
--text-secondary: #b0b0b0;
--accent: #90caf9;
--accent-hover: #64b5f6;
--danger: #ef5350;
--danger-hover: #e53935;
--success: #66bb6a;
--warning: #ffa726;
--border: #424242;
--shadow: rgba(0, 0, 0, 0.3);
--player-bg: #000000;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 1rem 2rem;
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
gap: 2rem;
}
header h1 {
margin: 0;
flex-shrink: 0;
}
header h1 a {
color: var(--accent);
text-decoration: none;
font-size: 1.5rem;
}
.theme-toggle {
display: flex;
gap: 0.5rem;
}
.theme-toggle button {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 0.4rem 0.6rem;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.theme-toggle button:hover {
background: var(--accent);
color: white;
}
.theme-toggle button.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
nav {
display: flex;
gap: 1rem;
}
nav a {
color: var(--text-secondary);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: all 0.2s;
}
nav a:hover,
nav a.active {
color: var(--accent);
background-color: var(--bg-tertiary);
}
main {
flex: 1;
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
footer {
background-color: var(--bg-secondary);
border-top: 1px solid var(--border);
padding: 1rem 2rem;
text-align: center;
color: var(--text-secondary);
font-size: 0.9rem;
}
/* Page headers */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.page-header h2 {
font-size: 1.5rem;
}
.actions {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
/* Forms */
.form-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
max-width: 800px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="url"],
.form-group input[type="number"],
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.6rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-primary);
color: var(--text-primary);
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent);
}
.form-group.checkbox label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.form-group.checkbox input[type="checkbox"] {
width: auto;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.radio-group label {
display: flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
font-weight: normal;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
.form-group small {
display: block;
margin-top: 0.3rem;
color: var(--text-secondary);
font-size: 0.85rem;
}
/* Buttons */
button,
.button {
padding: 0.6rem 1.2rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 0.95rem;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
button.primary,
.button.primary {
background: var(--accent);
color: white;
}
button.primary:hover,
.button.primary:hover {
background: var(--accent-hover);
}
button.secondary,
.button.secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
}
button.secondary:hover,
.button.secondary:hover {
background: var(--border);
}
button.danger,
.button.danger {
background: var(--danger);
color: white;
}
button.danger:hover,
.button.danger:hover {
background: var(--danger-hover);
}
button.small {
padding: 0.3rem 0.6rem;
font-size: 0.85rem;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Library grid */
.breadcrumbs {
margin-bottom: 1rem;
padding: 0.5rem;
background: var(--bg-secondary);
border-radius: 4px;
}
.breadcrumbs a {
color: var(--accent);
text-decoration: none;
}
.breadcrumbs a:hover {
text-decoration: underline;
}
.breadcrumbs span {
margin: 0 0.3rem;
color: var(--text-secondary);
}
.library-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.folder-item,
.media-item {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
text-decoration: none;
color: var(--text-primary);
transition: transform 0.2s, box-shadow 0.2s;
}
.folder-item:hover,
.media-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px var(--shadow);
}
.folder-icon,
.media-thumbnail {
aspect-ratio: 16/9;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
}
.media-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.audio-icon,
.video-icon {
font-size: 3rem;
opacity: 0.5;
}
.folder-name,
.media-info {
padding: 0.75rem;
}
.media-title {
font-weight: 500;
margin-bottom: 0.3rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-duration,
.media-path {
font-size: 0.85rem;
color: var(--text-secondary);
}
/* Detail view */
.detail-view {
max-width: 900px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.back-link {
color: var(--accent);
text-decoration: none;
}
.player-container {
background: var(--player-bg);
border-radius: 8px;
overflow: hidden;
margin-bottom: 1.5rem;
}
.player-container video,
.player-container audio {
width: 100%;
display: block;
}
.detail-meta dl {
display: grid;
grid-template-columns: 150px 1fr;
gap: 0.5rem 1rem;
}
.detail-meta dt {
color: var(--text-secondary);
font-weight: 500;
}
.detail-meta dd {
word-break: break-all;
}
.detail-meta a {
color: var(--accent);
}
/* Queue */
.queue-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.queue-item {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.queue-url {
flex: 1;
min-width: 0;
}
.queue-url a {
color: var(--accent);
text-decoration: none;
word-break: break-all;
}
.queue-status {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.status-badge {
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.status-badge.queued {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.status-badge.downloading {
background: var(--accent);
color: white;
}
.status-badge.completed {
background: var(--success);
color: white;
}
.status-badge.error {
background: var(--danger);
color: white;
}
.progress-bar {
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
margin: 0.5rem 0;
}
.progress-bar.large {
height: 12px;
border-radius: 6px;
}
.progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.3s;
}
.error-message {
color: var(--danger);
font-size: 0.9rem;
margin-top: 0.5rem;
}
.queue-meta {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
display: flex;
gap: 1rem;
}
/* Logs */
.logs-container {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
}
.logs-container h3 {
margin-bottom: 0.5rem;
}
.logs {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1rem;
max-height: 500px;
overflow-y: auto;
font-family: "Courier New", monospace;
font-size: 0.85rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
}
/* Settings */
.settings-layout {
display: flex;
flex-direction: column;
gap: 2rem;
}
.settings-section {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
}
.settings-section h3 {
margin-bottom: 1rem;
}
.presets-list {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.preset-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem;
}
.preset-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.preset-header h4 {
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge {
background: var(--accent);
color: white;
padding: 0.1rem 0.4rem;
border-radius: 10px;
font-size: 0.75rem;
}
.preset-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.preset-description {
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.preset-flags {
margin-top: 0.5rem;
}
.preset-flags pre {
background: var(--bg-tertiary);
padding: 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
overflow-x: auto;
margin-top: 0.3rem;
}
/* Toggle text: Edit vs Cancel */
details .cancel-text {
display: none;
}
details[open] .edit-text {
display: none;
}
details[open] .cancel-text {
display: inline;
}
/* Delete overlay popover */
.delete-overlay {
position: relative;
}
.delete-overlay summary {
list-style: none;
cursor: pointer;
}
.delete-overlay summary::-webkit-details-marker {
display: none;
}
.delete-overlay .overlay-panel {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
z-index: 1000;
width: 260px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
box-shadow: 0 4px 12px var(--shadow);
}
/* Popover arrow */
.delete-overlay .overlay-panel::before {
content: '';
position: absolute;
top: -6px;
right: 1rem;
width: 10px;
height: 10px;
background: var(--bg-primary);
border-left: 1px solid var(--border);
border-top: 1px solid var(--border);
transform: rotate(45deg);
}
.delete-overlay .overlay-panel p {
margin-bottom: 1rem;
font-size: 0.9rem;
}
.delete-overlay .overlay-panel .form-actions {
margin-top: 0;
justify-content: flex-end;
}
/* Inline edit form */
.edit-form {
padding: 1rem;
}
/* Conditional sections (no-JS show/hide) */
.conditional-section {
margin-bottom: 1rem;
}
.conditional-section .conditional-content {
display: none;
margin-top: 1rem;
}
/* Checkbox triggers */
.conditional-section:has(input[type="checkbox"].conditional-trigger:checked) .conditional-content {
display: block;
}
/* Radio triggers for format mode */
.conditional-section:has(.radio-group input[value="preset"]:checked) .conditional-content.format-preset,
.conditional-section:has(.radio-group input[value="custom"]:checked) .conditional-content.format-custom {
display: block;
}
/* Format mode selector - hidden radio inputs with button-like labels */
.radio-group.format-mode {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.radio-group.format-mode input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.radio-group.format-mode label {
padding: 0.4rem 0.8rem;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
background: var(--bg-primary);
transition: all 0.2s;
font-weight: normal;
}
.radio-group.format-mode label:hover {
background: var(--bg-tertiary);
}
.radio-group.format-mode label:has(input:checked) {
background: var(--accent);
color: white;
border-color: var(--accent);
}
/* Theme selector */
.theme-selector {
display: flex;
gap: 0.5rem;
}
.theme-selector button {
min-width: 80px;
}
.theme-selector button.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
/* Download form */
.download-form {
max-width: 900px;
}
.formats-table-container {
margin: 1rem 0;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
.formats-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.formats-table th,
.formats-table td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.formats-table th {
background: var(--bg-tertiary);
font-weight: 500;
}
.formats-table tr:hover {
background: var(--bg-primary);
}
/* Filter form inline */
.filter-form {
display: flex;
gap: 0.5rem;
align-items: center;
}
.filter-form input,
.filter-form select {
padding: 0.4rem;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
.empty-state a {
color: var(--accent);
}
/* Utility */
.inline {
display: inline;
}
/* Responsive */
@media (max-width: 768px) {
main {
padding: 1rem;
}
.page-header {
flex-direction: column;
align-items: flex-start;
}
.library-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.form-row {
grid-template-columns: 1fr;
}
.detail-meta dl {
grid-template-columns: 1fr;
}
.queue-header {
flex-direction: column;
}
}
▾Aweb/templates/download_form.html
@@ -0,0 +1,78 @@
{{define "download_form"}}
<div class="page-header">
<h2>Download</h2>
</div>
<div class="download-form">
<form method="post" action="/download" class="form-card">
<div class="form-group">
<label for="url">Video/Playlist URL</label>
<input type="url" id="url" name="url" placeholder="https://youtube.com/watch?v=..." required value="{{.Data.URL}}">
</div>
<div class="form-group">
<label for="preset_id">Preset</label>
<select id="preset_id" name="preset_id">
<option value="">-- No preset (manual) --</option>
{{range .Data.Presets}}
<option value="{{.ID}}" {{if $.Data.DefaultPreset}}{{if eq .ID $.Data.DefaultPreset.ID}}selected{{end}}{{end}}>
{{.Name}} {{if .IsDefault}}(default){{end}}
</option>
{{end}}
</select>
</div>
<div class="form-group">
<label for="format_override">Format Override</label>
<input type="text" id="format_override" name="format_override" placeholder="e.g., bestvideo+bestaudio/best or format ID like 22" value="{{.Data.FormatOverride}}">
<small>Overrides the preset format selection. Use yt-dlp format syntax or specific format IDs.</small>
</div>
<div class="form-group">
<label for="custom_flags">Custom Flags</label>
<textarea id="custom_flags" name="custom_flags" rows="3" placeholder="Any additional yt-dlp flags...">{{.Data.CustomFlags}}</textarea>
<small>Additional flags appended after preset flags. Example: --no-playlist --write-thumbnail</small>
</div>
<div class="form-actions">
<button type="submit" class="primary">Start Download</button>
<button type="submit" class="secondary" formaction="/download" formmethod="get" name="list_formats" value="1">List Available Formats</button>
<a href="/queue" class="button secondary">Cancel</a>
</div>
</form>
{{if .Data.ShowFormats}}
{{if .Data.Formats}}
<div class="formats-table-container">
<h4>Available Formats</h4>
<table class="formats-table">
<thead>
<tr>
<th>ID</th>
<th>Extension</th>
<th>Resolution</th>
<th>FPS</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{{range .Data.Formats}}
<tr>
<td><code>{{.ID}}</code></td>
<td>{{.Ext}}</td>
<td>{{.Resolution}}</td>
<td>{{.FPS}}</td>
<td>{{.Note}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty-state">
<p>No formats found. Check the URL and try again.</p>
</div>
{{end}}
{{end}}
</div>
{{end}}
▾Aweb/templates/layout.html
@@ -0,0 +1,33 @@
{{define "layout"}}
<!DOCTYPE html>
<html lang="en" data-theme="{{.Theme}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - VidArchive</title>
<link rel="stylesheet" href="/static/style.css">
{{if .AutoRefresh}}
<meta http-equiv="refresh" content="{{.RefreshSec}}">
{{end}}
</head>
<body>
<header>
<h1><a href="/">VidArchive</a></h1>
<nav>
<a href="/library" class="{{if eq .ActiveTab "library"}}active{{end}}">Library</a>
<a href="/queue" class="{{if eq .ActiveTab "queue"}}active{{end}}">Queue</a>
<a href="/download" class="{{if eq .ActiveTab "download"}}active{{end}}">Download</a>
<a href="/settings" class="{{if eq .ActiveTab "settings"}}active{{end}}">Settings</a>
</nav>
</header>
<main>
{{.Content}}
</main>
<footer>
<p>VidArchive - Video Archive Manager</p>
</footer>
</body>
</html>
{{end}}
▾Aweb/templates/library.html
@@ -0,0 +1,71 @@
{{define "library"}}
<div class="page-header">
<h2>Library</h2>
<div class="actions">
<form method="post" action="/library/scan" class="inline">
<button type="submit">Scan Library</button>
</form>
<form method="get" action="/library" class="inline filter-form">
<input type="hidden" name="path" value="{{.Data.Path}}">
<input type="text" name="filter" placeholder="Search..." value="{{.Data.Filter}}">
<select name="sort">
<option value="date" {{if eq .Data.SortBy "date"}}selected{{end}}>Date</option>
<option value="title" {{if eq .Data.SortBy "title"}}selected{{end}}>Title</option>
<option value="duration" {{if eq .Data.SortBy "duration"}}selected{{end}}>Duration</option>
</select>
<button type="submit">Filter</button>
</form>
</div>
</div>
{{if .Data.Path}}
<nav class="breadcrumbs">
<a href="/library">Home</a>
{{$path := .Data.Path}}
{{$parts := splitPath $path}}
{{$currentPath := ""}}
{{range $i, $part := $parts}}
{{$currentPath = printf "%s/%s" $currentPath $part}}
<span>/</span>
<a href="/library?path={{$currentPath}}">{{$part}}</a>
{{end}}
</nav>
{{end}}
<div class="library-grid">
{{range .Data.Folders}}
<a href="/library?path={{.}}" class="folder-item">
<div class="folder-icon">📁</div>
<div class="folder-name">{{baseName .}}</div>
</a>
{{end}}
{{range .Data.Media}}
<a href="/library/{{.ID}}" class="media-item {{if .IsAudio}}audio{{else}}video{{end}}">
<div class="media-thumbnail">
{{if .HasEmbeddedThumbnail}}
<img src="/media/{{.ID}}" alt="{{.Title}}" loading="lazy">
{{else if .IsAudio}}
<div class="audio-icon">🎵</div>
{{else}}
<div class="video-icon">🎬</div>
{{end}}
</div>
<div class="media-info">
<div class="media-title">{{.Title}}</div>
{{if .Duration}}
<div class="media-duration">{{formatDuration .Duration}}</div>
{{end}}
<div class="media-path">{{.RelativePath}}</div>
</div>
</a>
{{end}}
</div>
{{if and (eq (len .Data.Folders) 0) (eq (len .Data.Media) 0)}}
<div class="empty-state">
<p>No media found. <a href="/download">Start a download</a> or <button type="submit" form="scan-form">scan</button> for existing files.</p>
<form id="scan-form" method="post" action="/library/scan" style="display:none;"></form>
</div>
{{end}}
{{end}}
▾Aweb/templates/library_detail.html
@@ -0,0 +1,56 @@
{{define "library_detail"}}
<div class="detail-view">
<div class="detail-header">
<a href="/library" class="back-link">← Back to Library</a>
<h2>{{.Data.Title}}</h2>
<details class="inline danger-details">
<summary class="danger">Delete</summary>
<form method="post" action="/library/{{.Data.ID}}/delete" class="confirm-form">
<p>Are you sure you want to delete this media?</p>
<div class="form-actions">
<button type="submit" class="danger">Yes, Delete</button>
</div>
</form>
</details>
</div>
<div class="player-container">
{{if .Data.IsAudio}}
<audio controls>
<source src="/media/{{.Data.ID}}" type="audio/{{ext .Data.Filepath}}">
Your browser does not support the audio element.
</audio>
{{else}}
<video controls preload="metadata">
<source src="/media/{{.Data.ID}}" type="video/{{ext .Data.Filepath}}">
Your browser does not support the video element.
</video>
{{end}}
</div>
<div class="detail-meta">
<dl>
<dt>Path</dt>
<dd>{{.Data.RelativePath}}</dd>
{{if .Data.Duration}}
<dt>Duration</dt>
<dd>{{formatDuration .Data.Duration}}</dd>
{{end}}
{{if .Data.URL}}
<dt>Source URL</dt>
<dd><a href="{{.Data.URL}}" target="_blank">{{.Data.URL}}</a></dd>
{{end}}
{{if .Data.Description}}
<dt>Description</dt>
<dd>{{.Data.Description}}</dd>
{{end}}
<dt>File</dt>
<dd>{{.Data.Filepath}}</dd>
</dl>
</div>
</div>
{{end}}
▾Aweb/templates/queue.html
@@ -0,0 +1,61 @@
{{define "queue"}}
<div class="page-header">
<h2>Queue</h2>
<div class="actions">
<form method="get" action="/queue" class="inline filter-form">
<select name="status">
<option value="all" {{if eq .Data.Status "all"}}selected{{end}}>All</option>
<option value="queued" {{if eq .Data.Status "queued"}}selected{{end}}>Queued</option>
<option value="downloading" {{if eq .Data.Status "downloading"}}selected{{end}}>Active</option>
<option value="completed" {{if eq .Data.Status "completed"}}selected{{end}}>Completed</option>
<option value="error" {{if eq .Data.Status "error"}}selected{{end}}>Failed</option>
</select>
<select name="sort">
<option value="date" {{if eq .Data.SortBy "date"}}selected{{end}}>Date</option>
<option value="progress" {{if eq .Data.SortBy "progress"}}selected{{end}}>Progress</option>
<option value="status" {{if eq .Data.SortBy "status"}}selected{{end}}>Status</option>
</select>
<button type="submit">Filter</button>
</form>
</div>
</div>
<div class="queue-list">
{{range .Data.Items}}
<div class="queue-item {{.Status}}">
<div class="queue-header">
<div class="queue-url"><a href="/queue/{{.ID}}">{{.URL}}</a></div>
<div class="queue-status">
<span class="status-badge {{.Status}}">{{.Status}}</span>
{{if eq .Status "downloading"}}
<span class="progress">{{.ProgressPercent}}%</span>
{{if .CurrentSpeed}}<span class="speed">{{.CurrentSpeed}}</span>{{end}}
{{if .ETA}}<span class="eta">ETA: {{.ETA}}</span>{{end}}
{{end}}
</div>
<form method="post" action="/queue/{{.ID}}/delete" class="inline">
<button type="submit" class="small danger">Remove</button>
</form>
</div>
{{if eq .Status "downloading"}}
<div class="progress-bar">
<div class="progress-fill" style="width: {{.ProgressPercent}}%"></div>
</div>
{{end}}
{{if .ErrorMessage}}
<div class="error-message">{{.ErrorMessage}}</div>
{{end}}
<div class="queue-meta">
<span>Created: {{.CreatedAt.Format "2006-01-02 15:04"}}</span>
{{if .StartedAt.Valid}}
<span>Started: {{.StartedAt.Time.Format "2006-01-02 15:04"}}</span>
{{end}}
</div>
</div>
{{else}}
<div class="empty-state">
<p>Queue is empty. <a href="/download">Start a download</a>.</p>
</div>
{{end}}
</div>
{{end}}
▾Aweb/templates/queue_detail.html
@@ -0,0 +1,50 @@
{{define "queue_detail"}}
<div class="detail-view">
<div class="detail-header">
<a href="/queue" class="back-link">← Back to Queue</a>
<h2>Queue Details</h2>
<div class="download-status-header">
<span class="status-badge {{.Data.Status}}">{{.Data.Status}}</span>
{{if eq .Data.Status "downloading"}}
<span class="progress">{{.Data.ProgressPercent}}%</span>
{{end}}
</div>
</div>
<div class="download-info">
<dl>
<dt>URL</dt>
<dd><a href="{{.Data.URL}}" target="_blank">{{.Data.URL}}</a></dd>
<dt>Created</dt>
<dd>{{.Data.CreatedAt.Format "2006-01-02 15:04:05"}}</dd>
{{if .Data.StartedAt.Valid}}
<dt>Started</dt>
<dd>{{.Data.StartedAt.Time.Format "2006-01-02 15:04:05"}}</dd>
{{end}}
{{if .Data.CompletedAt.Valid}}
<dt>Completed</dt>
<dd>{{.Data.CompletedAt.Time.Format "2006-01-02 15:04:05"}}</dd>
{{end}}
{{if .Data.ErrorMessage}}
<dt>Error</dt>
<dd class="error-text">{{.Data.ErrorMessage}}</dd>
{{end}}
</dl>
</div>
{{if eq .Data.Status "downloading"}}
<div class="progress-bar large">
<div class="progress-fill" style="width: {{.Data.ProgressPercent}}%"></div>
</div>
{{end}}
<div class="logs-container">
<h3>Logs</h3>
<pre class="logs">{{if .Data.Logs}}{{.Data.Logs}}{{else}}No logs available yet...{{end}}</pre>
</div>
</div>
{{end}}
▾Aweb/templates/settings.html
@@ -0,0 +1,411 @@
{{define "settings"}}
<div class="page-header">
<h2>Settings</h2>
</div>
<div class="settings-layout">
<section class="settings-section">
<h3>Global Settings</h3>
<form method="post" action="/settings">
<div class="form-group">
<label for="refresh_interval">Auto-refresh Interval (seconds)</label>
<input type="number" id="refresh_interval" name="refresh_interval" min="1" max="300"
value="{{.Data.Settings.RefreshInterval}}">
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="auto_refresh_library" value="1"
{{if .Data.Settings.AutoRefreshLibrary}}checked{{end}}>
Auto-refresh Library page
</label>
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="auto_refresh_downloads" value="1"
{{if .Data.Settings.AutoRefreshDownloads}}checked{{end}}>
Auto-refresh Queue page
</label>
</div>
<div class="form-actions">
<button type="submit" class="primary">Save Settings</button>
</div>
</form>
</section>
<section class="settings-section">
<h3>Theme</h3>
<div class="theme-selector">
<form method="post" action="/theme" class="inline">
<input type="hidden" name="theme" value="light">
<button type="submit" class="secondary {{if eq .Theme "light"}}active{{end}}">Light</button>
</form>
<form method="post" action="/theme" class="inline">
<input type="hidden" name="theme" value="dark">
<button type="submit" class="secondary {{if eq .Theme "dark"}}active{{end}}">Dark</button>
</form>
<form method="post" action="/theme" class="inline">
<input type="hidden" name="theme" value="auto">
<button type="submit" class="secondary {{if eq .Theme "auto"}}active{{end}}">Auto</button>
</form>
</div>
</section>
<section class="settings-section">
<h3>Presets</h3>
<div class="presets-list">
{{range .Data.Presets}}
<div class="preset-card">
<div class="preset-header">
<h4>{{.Name}} {{if .IsDefault}}<span class="badge">default</span>{{end}}</h4>
<details class="delete-overlay">
<summary class="button small danger">Delete</summary>
<div class="overlay-panel">
<p>Are you sure you want to delete preset <strong>{{.Name}}</strong>?</p>
<form method="post" action="/settings/presets/{{.ID}}/delete" class="form-actions">
<button type="submit" class="danger">Yes, Delete</button>
</form>
</div>
</details>
</div>
{{if .Description}}
<p class="preset-description">{{.Description}}</p>
{{end}}
<div class="preset-flags">
<strong>Effective flags:</strong>
<pre>{{presetFlags .}}</pre>
</div>
<div class="preset-actions">
<details>
<summary class="button small"><span class="edit-text">Edit</span><span class="cancel-text">Cancel</span></summary>
<form method="post" action="/settings/presets/{{.ID}}" class="edit-form">
{{template "preset_form" .}}
<div class="form-actions">
<button type="submit" class="primary">Update Preset</button>
</div>
</form>
</details>
</div>
</div>
{{else}}
<p>No presets yet. Create one below.</p>
{{end}}
</div>
<h4>Create Preset</h4>
<form method="post" action="/settings/presets" class="form-card">
{{template "preset_form_create" .}}
<div class="form-actions">
<button type="submit" class="primary">Create Preset</button>
</div>
</form>
</section>
</div>
{{end}}
{{define "preset_form"}}
<div class="form-group">
<label for="name">Name *</label>
<input type="text" name="name" required
value="{{if .}}{{.Name}}{{end}}">
</div>
<div class="form-group">
<label for="description">Description</label>
<input type="text" name="description"
value="{{if .}}{{.Description}}{{end}}">
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="is_default" value="1"
{{if .}}{{if .IsDefault}}checked{{end}}{{end}}>
Set as default preset
</label>
</div>
<div class="conditional-section">
<label>Format Selection</label>
<div class="radio-group format-mode">
<label>
<input type="radio" name="format_mode" value="default" class="conditional-trigger"
{{if .}}{{if or (eq .FormatMode "default") (eq .FormatMode "")}}checked{{end}}{{else}}checked{{end}}>
yt-dlp default
</label>
<label>
<input type="radio" name="format_mode" value="preset" class="conditional-trigger"
{{if .}}{{if eq .FormatMode "preset"}}checked{{end}}{{end}}>
Preset format
</label>
<label>
<input type="radio" name="format_mode" value="custom" class="conditional-trigger"
{{if .}}{{if eq .FormatMode "custom"}}checked{{end}}{{end}}>
Custom format
</label>
</div>
<div class="conditional-content format-preset">
<div class="form-row">
<div class="form-group">
<label for="format">Format</label>
<select name="format">
<option value="best" {{if .}}{{if eq .Format "best"}}selected{{end}}{{end}}>best</option>
<option value="worst" {{if .}}{{if eq .Format "worst"}}selected{{end}}{{end}}>worst</option>
<option value="bestvideo+bestaudio" {{if .}}{{if eq .Format "bestvideo+bestaudio"}}selected{{end}}{{end}}>bestvideo+bestaudio</option>
<option value="bestvideo" {{if .}}{{if eq .Format "bestvideo"}}selected{{end}}{{end}}>bestvideo</option>
<option value="bestaudio" {{if .}}{{if eq .Format "bestaudio"}}selected{{end}}{{end}}>bestaudio</option>
</select>
</div>
<div class="form-group">
<label for="quality">Max Quality</label>
<select name="quality">
<option value="" {{if .}}{{if eq .Quality ""}}selected{{end}}{{end}}>No limit</option>
<option value="720" {{if .}}{{if eq .Quality "720"}}selected{{end}}{{end}}>720p</option>
<option value="1080" {{if .}}{{if eq .Quality "1080"}}selected{{end}}{{end}}>1080p</option>
<option value="1440" {{if .}}{{if eq .Quality "1440"}}selected{{end}}{{end}}>1440p</option>
<option value="2160" {{if .}}{{if eq .Quality "2160"}}selected{{end}}{{end}}>4K (2160p)</option>
</select>
</div>
</div>
</div>
<div class="conditional-content format-custom">
<div class="form-group">
<label for="custom_format">Custom Format</label>
<input type="text" name="custom_format" placeholder="e.g., bestvideo+bestaudio/best or format ID like 22"
value="{{if .}}{{.CustomFormat}}{{end}}">
<small>Overrides the format selection. Use yt-dlp format syntax.</small>
</div>
</div>
</div>
<div class="conditional-section">
<div class="form-group checkbox">
<label>
<input type="checkbox" name="extract_audio" value="1" class="conditional-trigger"
{{if .}}{{if .ExtractAudio}}checked{{end}}{{end}}>
Extract audio only
</label>
</div>
<div class="conditional-content">
<div class="form-group">
<label>Audio Format</label>
<div class="radio-group">
<label><input type="radio" name="audio_format" value="mp3" {{if .}}{{if eq .AudioFormat "mp3"}}checked{{end}}{{end}}> MP3</label>
<label><input type="radio" name="audio_format" value="m4a" {{if .}}{{if eq .AudioFormat "m4a"}}checked{{end}}{{end}}> M4A</label>
<label><input type="radio" name="audio_format" value="ogg" {{if .}}{{if eq .AudioFormat "ogg"}}checked{{end}}{{end}}> OGG</label>
<label><input type="radio" name="audio_format" value="flac" {{if .}}{{if eq .AudioFormat "flac"}}checked{{end}}{{end}}> FLAC</label>
<label><input type="radio" name="audio_format" value="wav" {{if .}}{{if eq .AudioFormat "wav"}}checked{{end}}{{end}}> WAV</label>
<label><input type="radio" name="audio_format" value="opus" {{if .}}{{if eq .AudioFormat "opus"}}checked{{end}}{{end}}> Opus</label>
<label><input type="radio" name="audio_format" value="aac" {{if .}}{{if eq .AudioFormat "aac"}}checked{{end}}{{end}}> AAC</label>
<label><input type="radio" name="audio_format" value="" {{if .}}{{if eq .AudioFormat ""}}checked{{end}}{{else}}checked{{end}}> Default (no conversion)</label>
</div>
</div>
</div>
</div>
<div class="conditional-section">
<div class="form-group checkbox">
<label>
<input type="checkbox" name="embed_subs" value="1" class="conditional-trigger"
{{if .}}{{if .EmbedSubs}}checked{{end}}{{end}}>
Embed subtitles
</label>
</div>
<div class="conditional-content">
<div class="form-group">
<label for="sub_langs">Subtitle Languages</label>
<input type="text" name="sub_langs" placeholder="e.g., en,de,fr or all"
value="{{if .}}{{.SubLangs}}{{else}}en{{end}}">
<small>Comma-separated language codes or "all"</small>
</div>
</div>
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="embed_thumbnail" value="1"
{{if .}}{{if .EmbedThumbnail}}checked{{end}}{{else}}checked{{end}}>
Embed thumbnail
</label>
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="embed_metadata" value="1"
{{if .}}{{if .EmbedMetadata}}checked{{end}}{{else}}checked{{end}}>
Embed metadata
</label>
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="write_info_json" value="1"
{{if .}}{{if .WriteInfoJSON}}checked{{end}}{{else}}checked{{end}}>
Write info JSON
</label>
</div>
<div class="form-group">
<label for="output_template">Output Template</label>
<input type="text" name="output_template"
value="{{if .}}{{.OutputTemplate}}{{else}}%(title)s [%(id)s].%(ext)s{{end}}">
<small>Use / for subfolders. Example: %(playlist_title)s/%(title)s.%(ext)s</small>
</div>
<div class="form-group">
<label for="custom_flags">Custom Flags</label>
<textarea name="custom_flags" rows="2" placeholder="Any additional yt-dlp flags...">{{if .}}{{.CustomFlags}}{{end}}</textarea>
</div>
{{end}}
{{define "preset_form_create"}}
<div class="form-group">
<label for="name">Name *</label>
<input type="text" name="name" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<input type="text" name="description">
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="is_default" value="1">
Set as default preset
</label>
</div>
<div class="conditional-section">
<label>Format Selection</label>
<div class="radio-group format-mode">
<label>
<input type="radio" name="format_mode" value="default" class="conditional-trigger" checked>
yt-dlp default
</label>
<label>
<input type="radio" name="format_mode" value="preset" class="conditional-trigger">
Preset format
</label>
<label>
<input type="radio" name="format_mode" value="custom" class="conditional-trigger">
Custom format
</label>
</div>
<div class="conditional-content format-preset">
<div class="form-row">
<div class="form-group">
<label for="format">Format</label>
<select name="format">
<option value="best" selected>best</option>
<option value="worst">worst</option>
<option value="bestvideo+bestaudio">bestvideo+bestaudio</option>
<option value="bestvideo">bestvideo</option>
<option value="bestaudio">bestaudio</option>
</select>
</div>
<div class="form-group">
<label for="quality">Max Quality</label>
<select name="quality">
<option value="" selected>No limit</option>
<option value="720">720p</option>
<option value="1080">1080p</option>
<option value="1440">1440p</option>
<option value="2160">4K (2160p)</option>
</select>
</div>
</div>
</div>
<div class="conditional-content format-custom">
<div class="form-group">
<label for="custom_format">Custom Format</label>
<input type="text" name="custom_format" placeholder="e.g., bestvideo+bestaudio/best or format ID like 22">
<small>Overrides the format selection. Use yt-dlp format syntax.</small>
</div>
</div>
</div>
<div class="conditional-section">
<div class="form-group checkbox">
<label>
<input type="checkbox" name="extract_audio" value="1" class="conditional-trigger">
Extract audio only
</label>
</div>
<div class="conditional-content">
<div class="form-group">
<label>Audio Format</label>
<div class="radio-group">
<label><input type="radio" name="audio_format" value="mp3"> MP3</label>
<label><input type="radio" name="audio_format" value="m4a"> M4A</label>
<label><input type="radio" name="audio_format" value="ogg"> OGG</label>
<label><input type="radio" name="audio_format" value="flac"> FLAC</label>
<label><input type="radio" name="audio_format" value="wav"> WAV</label>
<label><input type="radio" name="audio_format" value="opus"> Opus</label>
<label><input type="radio" name="audio_format" value="aac"> AAC</label>
<label><input type="radio" name="audio_format" value="" checked> Default (no conversion)</label>
</div>
</div>
</div>
</div>
<div class="conditional-section">
<div class="form-group checkbox">
<label>
<input type="checkbox" name="embed_subs" value="1" class="conditional-trigger">
Embed subtitles
</label>
</div>
<div class="conditional-content">
<div class="form-group">
<label for="sub_langs">Subtitle Languages</label>
<input type="text" name="sub_langs" placeholder="e.g., en,de,fr or all" value="en">
<small>Comma-separated language codes or "all"</small>
</div>
</div>
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="embed_thumbnail" value="1" checked>
Embed thumbnail
</label>
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="embed_metadata" value="1" checked>
Embed metadata
</label>
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="write_info_json" value="1" checked>
Write info JSON
</label>
</div>
<div class="form-group">
<label for="output_template">Output Template</label>
<input type="text" name="output_template" value="%(title)s [%(id)s].%(ext)s">
<small>Use / for subfolders. Example: %(playlist_title)s/%(title)s.%(ext)s</small>
</div>
<div class="form-group">
<label for="custom_flags">Custom Flags</label>
<textarea name="custom_flags" rows="2" placeholder="Any additional yt-dlp flags..."></textarea>
</div>
{{end}}