feat: subtitle support, media metadata, cookies, custom output dirs
Mcmd/vidarchive/main.go
@@ -24,6 +24,9 @@ func main() {
if err := os.MkdirAll(cfg.DownloadDir, 0755); err != nil {
log.Fatalf("Failed to create download dir: %v", err)
}
if err := os.MkdirAll(cfg.SubtitleCacheDir, 0755); err != nil {
log.Fatalf("Failed to create subtitle cache dir: %v", err)
}
// Initialize database
db, err := database.New(cfg)
@@ -40,9 +43,9 @@ func main() {
// Initialize services
presetSvc := service.NewPresetService(presetRepo)
mediaSvc := service.NewMediaService(mediaRepo, cfg.DownloadDir)
downloadSvc := service.NewDownloadService(downloadRepo, mediaRepo, presetSvc, cfg)
mediaSvc := service.NewMediaService(mediaRepo, cfg.DownloadDir, cfg.SubtitleCacheDir)
settingsSvc := service.NewSettingsService(settingsRepo)
downloadSvc := service.NewDownloadService(downloadRepo, mediaRepo, presetSvc, settingsSvc, cfg)
// Initialize worker pool
workerPool := worker.New(downloadSvc, cfg.Workers)
Mgo.mod
@@ -3,6 +3,7 @@ module vidarchive
go 1.26.2
require (
github.com/gabriel-vasile/mimetype v1.4.13
github.com/go-chi/chi/v5 v5.2.5
modernc.org/sqlite v1.50.0
)
Mgo.sum
@@ -1,5 +1,7 @@
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/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
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=
Minternal/config/config.go
@@ -9,25 +9,27 @@ import (
)
type Config struct {
Port int
DataDir string
DBPath string
DownloadDir string
YTDLPPath string
BaseURL string
Workers int
RefreshInterval int
Port int
DataDir string
DBPath string
DownloadDir string
SubtitleCacheDir 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"),
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")),
SubtitleCacheDir: getEnv("VIDARCHIVE_SUBTITLE_CACHE_DIR", filepath.Join(dataDir, "subtitles")),
YTDLPPath: getEnv("VIDARCHIVE_YTDLP_PATH", "yt-dlp"),
BaseURL: getEnv("VIDARCHIVE_BASE_URL", ""),
Workers: getEnvInt("VIDARCHIVE_WORKERS", 2),
RefreshInterval: getEnvInt("VIDARCHIVE_REFRESH_INTERVAL", 5),
Minternal/database/database.go
@@ -21,6 +21,16 @@ func New(cfg *config.Config) (*sql.DB, error) {
return nil, fmt.Errorf("open db: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
return nil, fmt.Errorf("enable wal mode: %w", err)
}
if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
return nil, fmt.Errorf("set busy timeout: %w", err)
}
if err := migrate(db); err != nil {
return nil, fmt.Errorf("migrate: %w", err)
}
@@ -56,7 +66,7 @@ func migrate(db *sql.DB) error {
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',
output_template TEXT DEFAULT '%(title)s.%(ext)s',
custom_flags TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`},
@@ -101,6 +111,11 @@ func migrate(db *sql.DB) error {
{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`},
{10, `ALTER TABLE downloads ADD COLUMN output_dir TEXT`},
{11, `ALTER TABLE downloads DROP COLUMN progress_percent`},
{12, `ALTER TABLE downloads DROP COLUMN current_speed`},
{13, `ALTER TABLE downloads DROP COLUMN eta`},
{14, `INSERT OR IGNORE INTO settings (key, value) VALUES ('cookies', '')`},
}
for _, m := range migrations {
Minternal/handler/handler.go
@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"github.com/gabriel-vasile/mimetype"
"github.com/go-chi/chi/v5"
"vidarchive/internal/config"
@@ -52,6 +53,7 @@ func loadTemplates() (*template.Template, error) {
"splitPath": func(p string) []string { return strings.Split(strings.Trim(p, "/"), "/") },
"baseName": filepath.Base,
"ext": filepath.Ext,
"mimeType": mimeTypeFromFile,
"presetFlags": func(p *models.Preset) string { return presetSvcGlobal.EffectiveFlags(p, "", "") },
})
@@ -87,6 +89,14 @@ func formatFileSize(bytes int64) string {
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
func mimeTypeFromFile(path string) string {
mtype, err := mimetype.DetectFile(path)
if err != nil || mtype == nil {
return "application/octet-stream"
}
return mtype.String()
}
type PageData struct {
Title string
ActiveTab string
@@ -201,10 +211,21 @@ func (h *Handler) LibraryDetail(w http.ResponseWriter, r *http.Request) {
return
}
meta, _ := h.mediaSvc.GetMetadata(id)
subtitles, _ := h.mediaSvc.GetSubtitles(id)
h.renderWithRequest(w, r, "library_detail", PageData{
Title: media.Title,
ActiveTab: "library",
Data: media,
Data: struct {
Media *models.Media
Metadata *service.MediaMetadata
Subtitles []models.SubtitleTrack
}{
Media: media,
Metadata: meta,
Subtitles: subtitles,
},
})
}
@@ -225,6 +246,55 @@ func (h *Handler) ServeMedia(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, media.Filepath)
}
func (h *Handler) ServeThumbnail(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
}
thumbPath, err := h.mediaSvc.ExtractThumbnail(id)
if err != nil {
// Serve fallback icon instead of 404 so <img> always has a valid source
media, mediaErr := h.mediaSvc.GetByID(id)
if mediaErr == nil && media.IsAudio {
http.ServeFile(w, r, "web/static/icons/audio-icon.svg")
} else {
http.ServeFile(w, r, "web/static/icons/video-icon.svg")
}
return
}
http.ServeFile(w, r, thumbPath)
}
func (h *Handler) ServeSubtitles(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
}
lang := chi.URLParam(r, "lang")
if lang == "" {
http.Error(w, "Missing language", http.StatusBadRequest)
return
}
// Validate language to prevent path traversal
if strings.Contains(lang, "/") || strings.Contains(lang, "..") || strings.Contains(lang, "\\") {
http.Error(w, "Invalid language", http.StatusBadRequest)
return
}
subtitlePath := h.mediaSvc.GetSubtitlePath(id, lang)
w.Header().Set("Content-Type", "text/vtt")
http.ServeFile(w, r, subtitlePath)
}
// Queue handlers
func (h *Handler) Downloads(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
@@ -304,8 +374,9 @@ func (h *Handler) CreateDownload(w http.ResponseWriter, r *http.Request) {
formatOverride := r.FormValue("format_override")
customFlags := r.FormValue("custom_flags")
outputDir := r.FormValue("output_dir")
download, err := h.downloadSvc.Create(url, presetID, formatOverride, customFlags)
download, err := h.downloadSvc.Create(url, presetID, formatOverride, customFlags, outputDir)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -358,6 +429,15 @@ func (h *Handler) DeleteDownload(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/queue", http.StatusSeeOther)
}
func (h *Handler) ClearAllDownloads(w http.ResponseWriter, r *http.Request) {
if err := h.downloadSvc.DeleteAll(); 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()
@@ -491,6 +571,7 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
h.settingsSvc.SetAutoRefreshLibrary(r.FormValue("auto_refresh_library") == "1")
h.settingsSvc.SetAutoRefreshDownloads(r.FormValue("auto_refresh_downloads") == "1")
h.settingsSvc.SetCookies(r.FormValue("cookies"))
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
@@ -520,6 +601,7 @@ func (h *Handler) DownloadForm(w http.ResponseWriter, r *http.Request) {
URL string
FormatOverride string
CustomFlags string
OutputDir string
Formats []*models.FormatInfo
ShowFormats bool
}{
@@ -528,6 +610,7 @@ func (h *Handler) DownloadForm(w http.ResponseWriter, r *http.Request) {
URL: url,
FormatOverride: r.URL.Query().Get("format_override"),
CustomFlags: r.URL.Query().Get("custom_flags"),
OutputDir: r.URL.Query().Get("output_dir"),
Formats: formats,
ShowFormats: r.URL.Query().Get("list_formats") == "1",
},
Minternal/models/models.go
@@ -46,14 +46,12 @@ type Download struct {
ID int64
URL string
Status string
ProgressPercent int
CurrentSpeed string
ETA string
Logs string
ErrorMessage string
Logs sql.NullString
ErrorMessage sql.NullString
PresetID sql.NullInt64
FormatOverride string
CustomFlags string
OutputDir sql.NullString
StartedAt sql.NullTime
CompletedAt sql.NullTime
CreatedAt time.Time
@@ -63,6 +61,7 @@ type Settings struct {
RefreshInterval int
AutoRefreshLibrary bool
AutoRefreshDownloads bool
Cookies string
}
type FormatInfo struct {
@@ -75,3 +74,9 @@ type FormatInfo struct {
Codec string
Note string
}
type SubtitleTrack struct {
Lang string
Label string
Src string
}
Minternal/repository/download.go
@@ -16,9 +16,9 @@ func NewDownloadRepository(db *sql.DB) *DownloadRepository {
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,
`INSERT INTO downloads (url, status, preset_id, format_override, custom_flags, output_dir)
VALUES (?, ?, ?, ?, ?, ?)`,
d.URL, d.Status, d.PresetID, d.FormatOverride, d.CustomFlags, d.OutputDir,
)
if err != nil {
return err
@@ -29,14 +29,14 @@ func (r *DownloadRepository) Create(d *models.Download) error {
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
`SELECT id, url, status, logs, error_message, preset_id, format_override, custom_flags, output_dir, 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
query := `SELECT id, url, status, logs, error_message, preset_id, format_override, custom_flags, output_dir, started_at, completed_at, created_at
FROM downloads WHERE 1=1`
var args []interface{}
@@ -48,8 +48,6 @@ func (r *DownloadRepository) GetAll(status, sortBy string) ([]*models.Download,
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:
@@ -75,7 +73,7 @@ func (r *DownloadRepository) GetAll(status, sortBy string) ([]*models.Download,
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
`SELECT id, url, status, logs, error_message, preset_id, format_override, custom_flags, output_dir, started_at, completed_at, created_at
FROM downloads WHERE status = 'queued' ORDER BY created_at ASC LIMIT ?`,
limit,
)
@@ -100,14 +98,6 @@ func (r *DownloadRepository) UpdateStatus(id int64, status string) error {
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
@@ -150,11 +140,24 @@ func (r *DownloadRepository) Delete(id int64) error {
return err
}
func (r *DownloadRepository) DeleteAll() error {
_, err := r.db.Exec(`DELETE FROM downloads`)
return err
}
func (r *DownloadRepository) UpdateStatusWhere(oldStatus, newStatus string) error {
_, err := r.db.Exec(
`UPDATE downloads SET status = ? WHERE status = ?`,
newStatus, oldStatus,
)
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.ID, &d.URL, &d.Status,
&d.Logs, &d.ErrorMessage, &d.PresetID, &d.FormatOverride, &d.CustomFlags, &d.OutputDir,
&d.StartedAt, &d.CompletedAt, &d.CreatedAt,
)
if err != nil {
Minternal/repository/settings.go
@@ -41,6 +41,8 @@ func (r *SettingsRepository) GetAll() (*models.Settings, error) {
settings.AutoRefreshLibrary = value == "1" || value == "true"
case "auto_refresh_downloads":
settings.AutoRefreshDownloads = value == "1" || value == "true"
case "cookies":
settings.Cookies = value
}
}
return settings, rows.Err()
Minternal/server/server.go
@@ -38,6 +38,8 @@ func (s *Server) setupRoutes() {
// Media serving (with range request support via http.ServeFile)
s.router.Get("/media/{id}", s.handler.ServeMedia)
s.router.Get("/media/{id}/thumbnail", s.handler.ServeThumbnail)
s.router.Get("/media/{id}/subtitles/{lang}", s.handler.ServeSubtitles)
// Pages
s.router.Get("/", func(w http.ResponseWriter, r *http.Request) {
@@ -51,6 +53,7 @@ func (s *Server) setupRoutes() {
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.Post("/queue/clear", s.handler.ClearAllDownloads)
s.router.Get("/download", s.handler.DownloadForm)
s.router.Post("/download", s.handler.CreateDownload)
Minternal/service/download.go
@@ -1,14 +1,19 @@
package service
import (
"bufio"
"database/sql"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/gabriel-vasile/mimetype"
"vidarchive/internal/config"
"vidarchive/internal/models"
"vidarchive/internal/repository"
@@ -18,24 +23,32 @@ type DownloadService struct {
repo *repository.DownloadRepository
mediaRepo *repository.MediaRepository
presetSvc *PresetService
settingsSvc *SettingsService
cfg *config.Config
cache *ProgressCache
processMu sync.Mutex
processes map[int64]*os.Process
}
func NewDownloadService(repo *repository.DownloadRepository, mediaRepo *repository.MediaRepository, presetSvc *PresetService, cfg *config.Config) *DownloadService {
func NewDownloadService(repo *repository.DownloadRepository, mediaRepo *repository.MediaRepository, presetSvc *PresetService, settingsSvc *SettingsService, cfg *config.Config) *DownloadService {
return &DownloadService{
repo: repo,
mediaRepo: mediaRepo,
presetSvc: presetSvc,
cfg: cfg,
repo: repo,
mediaRepo: mediaRepo,
presetSvc: presetSvc,
settingsSvc: settingsSvc,
cfg: cfg,
cache: NewProgressCache(),
processes: make(map[int64]*os.Process),
}
}
func (s *DownloadService) Create(url string, presetID *int64, formatOverride, customFlags string) (*models.Download, error) {
func (s *DownloadService) Create(url string, presetID *int64, formatOverride, customFlags, outputDir string) (*models.Download, error) {
d := &models.Download{
URL: url,
Status: "queued",
FormatOverride: formatOverride,
CustomFlags: customFlags,
OutputDir: sql.NullString{String: outputDir, Valid: outputDir != ""},
}
if presetID != nil {
@@ -49,11 +62,38 @@ func (s *DownloadService) Create(url string, presetID *int64, formatOverride, cu
}
func (s *DownloadService) GetByID(id int64) (*models.Download, error) {
// Check cache first for live logs
if live, ok := s.cache.Get(id); ok {
d, err := s.repo.GetByID(id)
if err != nil {
return nil, err
}
logs := live.Logs.String()
if logs != "" {
d.Logs = sql.NullString{String: logs, Valid: true}
}
return d, nil
}
return s.repo.GetByID(id)
}
func (s *DownloadService) GetAll(status, sortBy string) ([]*models.Download, error) {
return s.repo.GetAll(status, sortBy)
downloads, err := s.repo.GetAll(status, sortBy)
if err != nil {
return nil, err
}
// Overlay live logs from cache
for _, d := range downloads {
if live, ok := s.cache.Get(d.ID); ok {
logs := live.Logs.String()
if logs != "" {
d.Logs = sql.NullString{String: logs, Valid: true}
}
}
}
return downloads, nil
}
func (s *DownloadService) GetQueued(limit int) ([]*models.Download, error) {
@@ -61,9 +101,34 @@ func (s *DownloadService) GetQueued(limit int) ([]*models.Download, error) {
}
func (s *DownloadService) Delete(id int64) error {
s.killProcess(id)
s.cache.Delete(id)
return s.repo.Delete(id)
}
func (s *DownloadService) killProcess(id int64) {
s.processMu.Lock()
proc, ok := s.processes[id]
delete(s.processes, id)
s.processMu.Unlock()
if !ok || proc == nil {
return
}
// Kill the process group (negative PID kills the group on Linux)
// The process group was created by Setpgid in ExecuteDownload
_ = syscall.Kill(-proc.Pid, syscall.SIGKILL)
}
func (s *DownloadService) DeleteAll() error {
return s.repo.DeleteAll()
}
func (s *DownloadService) ResetStalledDownloads() error {
return s.repo.UpdateStatusWhere("downloading", "queued")
}
func (s *DownloadService) ListFormats(url string) ([]*models.FormatInfo, error) {
cmd := exec.Command(s.cfg.YTDLPPath, "-F", "--no-warnings", url)
output, err := cmd.CombinedOutput()
@@ -79,6 +144,12 @@ func (s *DownloadService) ExecuteDownload(d *models.Download) error {
return err
}
// Initialize in-memory cache for logs
s.cache.Set(d.ID, &LiveDownload{
LastUpdate: time.Now(),
})
defer s.cache.Delete(d.ID)
var preset *models.Preset
var err error
@@ -93,34 +164,108 @@ func (s *DownloadService) ExecuteDownload(d *models.Download) error {
if preset == nil {
preset = &models.Preset{
OutputTemplate: "%(title)s [%(id)s].%(ext)s",
OutputTemplate: "%(title)s.%(ext)s",
}
}
// Build output path with optional subdirectory
downloadDir := s.cfg.DownloadDir
if d.OutputDir.Valid && d.OutputDir.String != "" {
// Sanitize and validate output directory
cleanDir := filepath.Clean(d.OutputDir.String)
// Prevent path traversal: ensure the cleaned path doesn't escape the download dir
fullPath := filepath.Join(downloadDir, cleanDir)
resolvedPath, err := filepath.Abs(fullPath)
if err != nil {
return fmt.Errorf("invalid output directory: %w", err)
}
resolvedDownloadDir, _ := filepath.Abs(downloadDir)
if !strings.HasPrefix(resolvedPath, resolvedDownloadDir+string(filepath.Separator)) && resolvedPath != resolvedDownloadDir {
return fmt.Errorf("invalid output directory: path traversal attempt detected")
}
downloadDir = fullPath
// Ensure directory exists
if err := os.MkdirAll(downloadDir, 0755); err != nil {
return fmt.Errorf("create output directory: %w", err)
}
}
args := s.presetSvc.BuildArgs(preset, d.FormatOverride, d.CustomFlags)
args = append(args, "--newline", "--progress", "-P", s.cfg.DownloadDir)
// Write cookies to temp file if configured
cookies, err := s.settingsSvc.GetCookies()
if err == nil && strings.TrimSpace(cookies) != "" {
tmpFile, err := os.CreateTemp("", "cookies-*.txt")
if err == nil {
tmpFile.WriteString(cookies)
tmpFile.Close()
args = append(args, "--cookies", tmpFile.Name())
defer os.Remove(tmpFile.Name())
}
}
args = append(args, "-P", downloadDir)
args = append(args, d.URL)
cmd := exec.Command(s.cfg.YTDLPPath, args...)
// Set up log capture
var logBuilder strings.Builder
// Start the command
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout, err := cmd.StdoutPipe()
if err != nil {
s.finalizeError(d.ID, err)
return err
}
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
s.repo.MarkError(d.ID, err.Error())
s.finalizeError(d.ID, err)
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)
// Track the process so it can be killed on delete
s.processMu.Lock()
s.processes[d.ID] = cmd.Process
s.processMu.Unlock()
defer func() {
s.processMu.Lock()
delete(s.processes, d.ID)
s.processMu.Unlock()
}()
scanner := bufio.NewScanner(stdout)
s.repo.UpdateLogs(d.ID, logBuilder.String())
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
done := make(chan struct{})
go func() {
for {
select {
case <-ticker.C:
logs := s.cache.FlushLogs(d.ID)
if logs != "" {
s.repo.UpdateLogs(d.ID, logs)
}
case <-done:
return
}
}
}()
if err != nil {
s.repo.MarkError(d.ID, err.Error())
for scanner.Scan() {
line := scanner.Text()
s.cache.AppendLog(d.ID, line)
}
close(done)
// Final log flush
logs := s.cache.FlushLogs(d.ID)
if logs != "" {
s.repo.UpdateLogs(d.ID, logs)
}
if err := cmd.Wait(); err != nil {
s.finalizeError(d.ID, err)
return err
}
@@ -130,17 +275,25 @@ func (s *DownloadService) ExecuteDownload(d *models.Download) error {
}
// Scan for new media files
s.scanForNewMedia(d.URL)
s.scanForNewMedia(d.URL, downloadDir)
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
func (s *DownloadService) finalizeError(id int64, err error) {
logs := s.cache.FlushLogs(id)
if logs != "" {
s.repo.UpdateLogs(id, logs)
}
s.repo.MarkError(id, err.Error())
}
func (s *DownloadService) scanForNewMedia(url string, downloadDir string) {
// Give filesystem a moment
time.Sleep(100 * time.Millisecond)
// Walk download dir and add any new files
filepath.Walk(s.cfg.DownloadDir, func(path string, info os.FileInfo, err error) error {
filepath.Walk(downloadDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
@@ -153,14 +306,20 @@ func (s *DownloadService) scanForNewMedia(url string) {
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"
// Use content-based MIME detection
mtype, err := mimetype.DetectFile(path)
if err != nil {
return nil
}
isAudio := mtype != nil && strings.HasPrefix(mtype.String(), "audio/")
isVideo := mtype != nil && strings.HasPrefix(mtype.String(), "video/")
if !isAudio && !isVideo {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
basePath := strings.TrimSuffix(path, ext)
infoJSONPath := basePath + ".info.json"
if _, err := os.Stat(infoJSONPath); err != nil {
@@ -172,7 +331,7 @@ func (s *DownloadService) scanForNewMedia(url string) {
Filepath: path,
RelativePath: relPath,
IsAudio: isAudio,
HasEmbeddedThumbnail: true, // yt-dlp usually embeds when requested
HasEmbeddedThumbnail: true,
InfoJSONPath: infoJSONPath,
Title: strings.TrimSuffix(filepath.Base(path), ext),
}
@@ -183,8 +342,6 @@ func (s *DownloadService) scanForNewMedia(url string) {
}
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
@@ -195,7 +352,6 @@ func parseFormatList(output string) []*models.FormatInfo {
continue
}
// Look for the format table header
if strings.Contains(line, "ID") && strings.Contains(line, "EXT") {
inFormats = true
continue
@@ -205,7 +361,6 @@ func parseFormatList(output string) []*models.FormatInfo {
continue
}
// Parse format line
parts := strings.Fields(line)
if len(parts) >= 4 {
format := &models.FormatInfo{
@@ -213,7 +368,6 @@ func parseFormatList(output string) []*models.FormatInfo {
Ext: parts[1],
}
// Try to extract resolution
for i, part := range parts {
if strings.Contains(part, "x") && !strings.Contains(part, "http") {
format.Resolution = part
@@ -224,7 +378,6 @@ func parseFormatList(output string) []*models.FormatInfo {
}
}
// Note is everything after resolution
if len(parts) > 3 {
format.Note = strings.Join(parts[3:], " ")
}
Minternal/service/media.go
@@ -1,9 +1,12 @@
package service
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"vidarchive/internal/models"
@@ -11,12 +14,13 @@ import (
)
type MediaService struct {
repo *repository.MediaRepository
downloadDir string
repo *repository.MediaRepository
downloadDir string
subtitleCacheDir string
}
func NewMediaService(repo *repository.MediaRepository, downloadDir string) *MediaService {
return &MediaService{repo: repo, downloadDir: downloadDir}
func NewMediaService(repo *repository.MediaRepository, downloadDir, subtitleCacheDir string) *MediaService {
return &MediaService{repo: repo, downloadDir: downloadDir, subtitleCacheDir: subtitleCacheDir}
}
func (s *MediaService) GetByID(id int64) (*models.Media, error) {
@@ -64,8 +68,8 @@ func (s *MediaService) ScanLibrary() error {
}
// Determine file type
ext := strings.ToLower(filepath.Ext(path))
isAudio := ext == ".mp3" || ext == ".m4a" || ext == ".ogg" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus"
ext := strings.ToLower(filepath.Ext(path))
isAudio := ext == ".mp3" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus"
isVideo := ext == ".mp4" || ext == ".webm" || ext == ".mkv" || ext == ".avi" || ext == ".mov"
if !isAudio && !isVideo {
@@ -109,5 +113,428 @@ func (s *MediaService) Delete(id int64) error {
os.Remove(media.InfoJSONPath)
}
// Delete thumbnail if exists
thumbPath := s.thumbnailPath(media.Filepath)
os.Remove(thumbPath)
// Delete subtitle cache if exists
os.RemoveAll(s.subtitleCachePath(id))
return s.repo.Delete(id)
}
func (s *MediaService) thumbnailPath(path string) string {
ext := filepath.Ext(path)
return strings.TrimSuffix(path, ext) + ".webp"
}
// ffprobeAttachmentStream represents an attachment stream from ffprobe output
type ffprobeAttachmentStream struct {
Index int `json:"index"`
CodecType string `json:"codec_type"`
Tags struct {
Mimetype string `json:"mimetype"`
Filename string `json:"filename"`
} `json:"tags"`
}
type ffprobeAttachmentOutput struct {
Streams []ffprobeAttachmentStream `json:"streams"`
}
// findImageAttachment uses ffprobe to find the best image attachment in a media file.
// It returns the attachment index (0-based among all attachment streams), or -1 if none found.
func (s *MediaService) findImageAttachment(path string) int {
cmd := exec.Command("ffprobe",
"-v", "error",
"-show_streams",
"-of", "json",
path,
)
output, err := cmd.Output()
if err != nil {
return -1
}
var probe ffprobeAttachmentOutput
if err := json.Unmarshal(output, &probe); err != nil {
return -1
}
bestAttachmentIndex := -1
bestScore := 0
attachmentCount := 0
for _, stream := range probe.Streams {
// Only consider attachment streams with image/* mimetype
if stream.CodecType != "attachment" {
continue
}
currentAttachmentIndex := attachmentCount
attachmentCount++
if !strings.HasPrefix(strings.ToLower(stream.Tags.Mimetype), "image/") {
continue
}
score := 0
filenameLower := strings.ToLower(stream.Tags.Filename)
switch {
case strings.Contains(filenameLower, "cover"):
score = 100
case strings.Contains(filenameLower, "thumbnail"), strings.Contains(filenameLower, "thumb"):
score = 80
case strings.Contains(filenameLower, "poster"):
score = 60
case strings.Contains(filenameLower, "art"):
score = 40
default:
score = 20
}
if score > bestScore {
bestScore = score
bestAttachmentIndex = currentAttachmentIndex
}
}
return bestAttachmentIndex
}
func (s *MediaService) ExtractThumbnail(id int64) (string, error) {
media, err := s.repo.GetByID(id)
if err != nil {
return "", err
}
if media.IsAudio {
return "", fmt.Errorf("audio files don't have thumbnails")
}
thumbPath := s.thumbnailPath(media.Filepath)
// Check if thumbnail already exists
if _, err := os.Stat(thumbPath); err == nil {
return thumbPath, nil
}
// Try to extract embedded image attachment
streamIndex := s.findImageAttachment(media.Filepath)
if streamIndex >= 0 {
// Create temp file for raw attachment
tempFile, err := os.CreateTemp("", "vidarchive-attachment-*")
if err != nil {
goto frameFallback
}
tempPath := tempFile.Name()
tempFile.Close()
defer os.Remove(tempPath)
// Extract attachment (-dump_attachment is an input option, must come before -i)
cmd := exec.Command("ffmpeg",
fmt.Sprintf("-dump_attachment:t:%d", streamIndex),
tempPath,
"-i", media.Filepath,
"-y",
"-t", "0",
"-f", "null", "-",
)
if err := cmd.Run(); err != nil {
goto frameFallback
}
// Convert to canonical WebP
cmd = exec.Command("ffmpeg",
"-i", tempPath,
thumbPath,
"-y",
)
if err := cmd.Run(); err != nil {
goto frameFallback
}
return thumbPath, nil
}
frameFallback:
// Extract a video frame at 50% duration
// Get video duration
durationCmd := exec.Command("ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
media.Filepath,
)
durationOut, durationErr := durationCmd.Output()
seekTime := "00:00:01"
if durationErr == nil {
if durationSec, parseErr := strconv.ParseFloat(strings.TrimSpace(string(durationOut)), 64); parseErr == nil && durationSec > 0 {
midpoint := durationSec / 2
// Format as HH:MM:SS or MM:SS
minutes := int(midpoint) / 60
seconds := int(midpoint) % 60
hours := minutes / 60
minutes = minutes % 60
if hours > 0 {
seekTime = fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
} else {
seekTime = fmt.Sprintf("%02d:%02d", minutes, seconds)
}
}
}
cmd := exec.Command("ffmpeg", "-ss", seekTime, "-i", media.Filepath, "-vframes", "1", thumbPath)
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("ffmpeg failed: %w", err)
}
return thumbPath, nil
}
func (s *MediaService) HasThumbnail(id int64) bool {
media, err := s.repo.GetByID(id)
if err != nil {
return false
}
thumbPath := s.thumbnailPath(media.Filepath)
_, err = os.Stat(thumbPath)
return err == nil
}
type MediaMetadata struct {
Container string
VideoCodec string
AudioCodec string
Resolution string
Bitrate string
FileSize int64
}
type ffprobeOutput struct {
Format struct {
FormatName string `json:"format_name"`
BitRate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
CodecType string `json:"codec_type"`
} `json:"streams"`
}
func (s *MediaService) GetMetadata(id int64) (*MediaMetadata, error) {
media, err := s.repo.GetByID(id)
if err != nil {
return nil, err
}
// Get file size
info, err := os.Stat(media.Filepath)
if err != nil {
return nil, err
}
meta := &MediaMetadata{
FileSize: info.Size(),
}
// Get metadata via ffprobe JSON output
cmd := exec.Command("ffprobe",
"-v", "error",
"-show_format",
"-show_streams",
"-of", "json",
media.Filepath,
)
output, err := cmd.Output()
if err != nil {
return meta, nil // Return partial metadata (file size only)
}
var probe ffprobeOutput
if err := json.Unmarshal(output, &probe); err != nil {
return meta, nil
}
// Container format
if probe.Format.FormatName != "" {
parts := strings.Split(probe.Format.FormatName, ",")
meta.Container = parts[0]
}
// Overall bitrate
if probe.Format.BitRate != "" {
if br, err := strconv.ParseInt(probe.Format.BitRate, 10, 64); err == nil {
meta.Bitrate = fmt.Sprintf("%d", br/1000)
}
}
// Stream info
for _, stream := range probe.Streams {
switch stream.CodecType {
case "video":
meta.VideoCodec = stream.CodecName
if stream.Width > 0 && stream.Height > 0 {
meta.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
}
case "audio":
meta.AudioCodec = stream.CodecName
}
}
return meta, nil
}
func (s *MediaService) subtitleCachePath(id int64) string {
return filepath.Join(s.subtitleCacheDir, fmt.Sprintf("%d", id))
}
// GetSubtitlePath returns the full path to a cached subtitle file for a given media ID and language.
func (s *MediaService) GetSubtitlePath(id int64, lang string) string {
return filepath.Join(s.subtitleCachePath(id), lang+".vtt")
}
// GetSubtitles extracts embedded text subtitles from a media file, converts them to VTT,
// and returns track info for the HTML5 video player. Results are cached.
func (s *MediaService) GetSubtitles(id int64) ([]models.SubtitleTrack, error) {
media, err := s.repo.GetByID(id)
if err != nil {
return nil, err
}
cacheDir := s.subtitleCachePath(id)
// Check cache first
entries, err := os.ReadDir(cacheDir)
if err == nil && len(entries) > 0 {
var tracks []models.SubtitleTrack
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".vtt" {
continue
}
lang := strings.TrimSuffix(entry.Name(), ".vtt")
label := lang
tracks = append(tracks, models.SubtitleTrack{
Lang: lang,
Label: label,
Src: fmt.Sprintf("/media/%d/subtitles/%s", id, lang),
})
}
return tracks, nil
}
// Extract subtitle streams
streams, err := s.extractSubtitleInfo(media.Filepath)
if err != nil || len(streams) == 0 {
return nil, err
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return nil, err
}
var tracks []models.SubtitleTrack
for _, stream := range streams {
lang := stream.Lang
if lang == "" {
lang = fmt.Sprintf("track%d", stream.Index)
}
outPath := filepath.Join(cacheDir, lang+".vtt")
if err := s.extractSubtitleToVTT(media.Filepath, outPath, stream.Index); err != nil {
continue // Skip failed extractions
}
tracks = append(tracks, models.SubtitleTrack{
Lang: lang,
Label: stream.Label,
Src: fmt.Sprintf("/media/%d/subtitles/%s", id, lang),
})
}
return tracks, nil
}
type subtitleStream struct {
Index int
Lang string
Label string
}
// extractSubtitleInfo finds text-based subtitle streams in a media file using ffprobe.
func (s *MediaService) extractSubtitleInfo(path string) ([]subtitleStream, error) {
cmd := exec.Command("ffprobe",
"-v", "error",
"-show_streams",
"-select_streams", "s",
"-of", "json",
path,
)
output, err := cmd.Output()
if err != nil {
return nil, err
}
var probe struct {
Streams []struct {
Index int `json:"index"`
CodecName string `json:"codec_name"`
Tags struct {
Language string `json:"language"`
Title string `json:"title"`
} `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &probe); err != nil {
return nil, err
}
var streams []subtitleStream
subIndex := 0
for _, stream := range probe.Streams {
// Only text-based subtitles
switch stream.CodecName {
case "subrip", "ass", "ssa", "webvtt", "mov_text":
lang := stream.Tags.Language
if lang == "" {
lang = fmt.Sprintf("track%d", subIndex)
}
label := stream.Tags.Title
if label == "" {
label = strings.ToUpper(lang)
}
streams = append(streams, subtitleStream{
Index: subIndex,
Lang: lang,
Label: label,
})
subIndex++
}
}
return streams, nil
}
// extractSubtitleToVTT extracts a subtitle stream and converts it to WebVTT format.
func (s *MediaService) extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error {
cmd := exec.Command("ffmpeg",
"-i", inputPath,
"-map", fmt.Sprintf("0:s:%d", streamIndex),
"-f", "webvtt",
outputPath,
"-y",
)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg subtitle extraction failed: %w\nOutput: %s", err, string(output))
}
return nil
}
Minternal/service/settings.go
@@ -48,3 +48,11 @@ func (s *SettingsService) GetRefreshInterval() (int, error) {
}
return strconv.Atoi(val)
}
func (s *SettingsService) SetCookies(cookies string) error {
return s.repo.Set("cookies", cookies)
}
func (s *SettingsService) GetCookies() (string, error) {
return s.repo.Get("cookies")
}
Minternal/worker/pool.go
@@ -30,6 +30,11 @@ func New(downloadSvc *service.DownloadService, workers int) *Pool {
}
func (p *Pool) Start() {
// Reset any downloads that were in progress during a previous run
if err := p.downloadSvc.ResetStalledDownloads(); err != nil {
log.Printf("Warning: failed to reset stalled downloads: %v", err)
}
for i := 0; i < p.workers; i++ {
go p.worker(i)
}
Aweb/static/pico.min.css
@@ -0,0 +1,4 @@
@charset "UTF-8";/*!
* Pico CSS ✨ v2.1.1 (https://picocss.com)
* Copyright 2019-2025 - Licensed under MIT
*/:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(2, 154, 232, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#0172ad;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 114, 173, 0.5);--pico-primary-hover:#015887;--pico-primary-hover-background:#02659a;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(2, 154, 232, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(243, 244.5, 246.75);--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(251, 251.5, 252.25);--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(251, 251.5, 252.25);--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme=dark])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:host(:not([data-theme])) details summary[role=button].contrast:not(.outline)::after,:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:host(:not([data-theme])) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before,:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:host),:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:host),svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code,pre samp{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre,samp{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd,samp{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code,pre>samp{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown>a::after,details.dropdown>button::after,details.dropdown>summary::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown>summary:not([role]):active,details.dropdown>summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown>summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown>summary:not([role]):focus-visible{outline:0}details.dropdown>summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown>summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown>summary::after{transform:rotate(0) translateX(0)}nav details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown>summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown>summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown>summary+ul[dir=rtl]{right:0;left:auto}details.dropdown>summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown>summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown>summary+ul li a:active,details.dropdown>summary+ul li a:focus,details.dropdown>summary+ul li a:focus-visible,details.dropdown>summary+ul li a:hover,details.dropdown>summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown>summary+ul li label{width:100%}details.dropdown>summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open]>summary{margin-bottom:0}details.dropdown[open]>summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open]>summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html,form){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html,form)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html,form):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html,form):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:host,:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog>article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog>article{max-width:510px}}@media (min-width:768px){dialog>article{max-width:700px}}dialog>article>header>*{margin-bottom:0}dialog>article>header .close,dialog>article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog>article>footer{text-align:right}dialog>article>footer [role=button],dialog>article>footer button{margin-bottom:0}dialog>article>footer [role=button]:not(:first-of-type),dialog>article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog>article .close,dialog>article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog>article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog>article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:""}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input,[role=button]){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}}
Mweb/static/style.css
@@ -1,79 +1,31 @@
: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;
}
/* Custom layout and components on top of Pico.css */
/* Scale down Pico's default sizing */
html {
font-size: 16px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
/* Remove Pico's default bottom margin on button types */
[type="button"],
[type="reset"],
[type="submit"] {
margin-bottom: 0;
}
/* Layout */
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;
background-color: var(--pico-card-background-color);
border-bottom: 1px solid var(--pico-muted-border-color);
display: flex;
align-items: center;
gap: 2rem;
@@ -82,74 +34,51 @@ header {
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);
color: var(--pico-muted-color);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
border-radius: var(--pico-border-radius);
transition: all 0.2s;
}
nav a:hover,
nav a:hover {
color: var(--pico-primary);
background-color: var(--pico-card-sectioning-background-color);
}
nav a.active {
color: var(--accent);
background-color: var(--bg-tertiary);
background-color: var(--pico-primary);
color: var(--pico-primary-inverse);
font-weight: 600;
}
main {
flex: 1;
padding: 2rem;
padding: 2rem 1rem;
}
main > * {
max-width: 1400px;
margin: 0 auto;
width: 100%;
margin-left: auto;
margin-right: auto;
}
footer {
background-color: var(--bg-secondary);
border-top: 1px solid var(--border);
padding: 1rem 2rem;
text-align: center;
color: var(--text-secondary);
color: var(--pico-muted-color);
font-size: 0.9rem;
background-color: var(--pico-card-background-color);
border-top: 1px solid var(--pico-muted-border-color);
}
/* Page headers */
@@ -164,6 +93,7 @@ footer {
.page-header h2 {
font-size: 1.5rem;
margin: 0;
}
.actions {
@@ -174,56 +104,10 @@ footer {
}
/* 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;
@@ -242,6 +126,7 @@ footer {
gap: 0.3rem;
cursor: pointer;
font-weight: normal;
margin-bottom: 0;
}
.form-actions {
@@ -250,66 +135,30 @@ footer {
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.small,
[role="button"].small {
padding: 0.3rem 0.6rem;
font-size: 0.85rem;
}
button.danger,
.button.danger {
background: var(--danger);
color: white;
}
button.danger:hover,
.button.danger:hover {
background: var(--danger-hover);
button.danger:focus,
[role="button"].danger,
[role="button"].danger:hover,
[role="button"].danger:focus {
--pico-background-color: #c62828;
--pico-border-color: #c62828;
--pico-color: white;
}
button.small {
padding: 0.3rem 0.6rem;
font-size: 0.85rem;
button.danger:hover,
button.danger:focus,
[role="button"].danger:hover,
[role="button"].danger:focus {
--pico-background-color: #b71c1c;
--pico-border-color: #b71c1c;
}
button:disabled {
@@ -317,26 +166,9 @@ button:disabled {
cursor: not-allowed;
}
/* Library grid */
/* Library */
.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 {
@@ -347,25 +179,26 @@ button:disabled {
.folder-item,
.media-item {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
background: var(--pico-card-background-color);
border: 1px solid var(--pico-card-border-color);
border-radius: var(--pico-border-radius);
overflow: hidden;
text-decoration: none;
color: var(--text-primary);
color: var(--pico-color);
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: var(--pico-card-box-shadow);
}
.folder-item:hover,
.media-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px var(--shadow);
box-shadow: var(--pico-card-box-shadow), 0 4px 12px rgba(0, 0, 0, 0.15);
}
.folder-icon,
.media-thumbnail {
aspect-ratio: 16/9;
background: var(--bg-tertiary);
background: var(--pico-card-sectioning-background-color);
display: flex;
align-items: center;
justify-content: center;
@@ -378,10 +211,11 @@ button:disabled {
object-fit: cover;
}
.audio-icon,
.video-icon {
font-size: 3rem;
.media-thumbnail img[src$=".svg"] {
width: 48px;
height: 48px;
opacity: 0.5;
object-fit: contain;
}
.folder-name,
@@ -400,7 +234,6 @@ button:disabled {
.media-duration,
.media-path {
font-size: 0.85rem;
color: var(--text-secondary);
}
/* Detail view */
@@ -417,21 +250,23 @@ button:disabled {
gap: 1rem;
}
.back-link {
color: var(--accent);
text-decoration: none;
}
.player-container {
background: var(--player-bg);
border-radius: 8px;
background: #000;
border-radius: var(--pico-border-radius);
overflow: hidden;
margin-bottom: 1.5rem;
max-height: 80vh;
display: flex;
align-items: center;
justify-content: center;
}
.player-container video,
.player-container audio {
width: 100%;
max-width: 100%;
max-height: 80vh;
width: auto;
height: auto;
display: block;
}
@@ -441,17 +276,9 @@ button:disabled {
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);
margin: 0;
}
/* Queue */
@@ -462,10 +289,8 @@ button:disabled {
}
.queue-item {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 0;
}
.queue-header {
@@ -483,8 +308,6 @@ button:disabled {
}
.queue-url a {
color: var(--accent);
text-decoration: none;
word-break: break-all;
}
@@ -503,85 +326,54 @@ button:disabled {
}
.status-badge.queued {
background: var(--bg-tertiary);
color: var(--text-secondary);
background: var(--pico-card-sectioning-background-color);
color: var(--pico-muted-color);
}
.status-badge.downloading {
background: var(--accent);
color: white;
background: var(--pico-primary);
color: var(--pico-primary-inverse);
}
.status-badge.completed {
background: var(--success);
background: var(--pico-ins-color);
color: white;
}
.status-badge.error {
background: var(--danger);
background: var(--pico-del-color);
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);
color: var(--pico-del-color);
font-size: 0.9rem;
margin-top: 0.5rem;
}
.error-text {
color: var(--pico-del-color);
}
.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;
}
pre {
padding: 1.5rem;
}
/* Settings */
.settings-layout {
display: flex;
@@ -589,17 +381,6 @@ button:disabled {
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;
@@ -607,13 +388,6 @@ button:disabled {
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;
@@ -625,11 +399,12 @@ button:disabled {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.badge {
background: var(--accent);
color: white;
background: var(--pico-primary);
color: var(--pico-primary-inverse);
padding: 0.1rem 0.4rem;
border-radius: 10px;
font-size: 0.75rem;
@@ -643,7 +418,6 @@ button:disabled {
}
.preset-description {
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
@@ -652,12 +426,24 @@ button:disabled {
}
.preset-flags pre {
background: var(--bg-tertiary);
padding: 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
overflow-x: auto;
margin-top: 0.3rem;
margin-bottom: 0;
}
/* Nested articles need visible borders */
.presets-list article {
border: 1px solid var(--pico-muted-border-color);
}
/* Suppress Pico details marker for button-like summaries */
details:has(summary[role="button"]) summary::after {
display: none !important;
}
details summary[role="button"] {
display: inline-flex;
width: auto;
}
/* Toggle text: Edit vs Cancel */
@@ -687,17 +473,21 @@ details[open] .cancel-text {
display: none;
}
.delete-overlay summary::after {
display: none !important;
}
.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;
background: var(--pico-background-color);
border: 1px solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
padding: 1rem;
box-shadow: 0 4px 12px var(--shadow);
box-shadow: var(--pico-dropdown-box-shadow);
}
/* Popover arrow */
@@ -708,9 +498,9 @@ details[open] .cancel-text {
right: 1rem;
width: 10px;
height: 10px;
background: var(--bg-primary);
border-left: 1px solid var(--border);
border-top: 1px solid var(--border);
background: var(--pico-background-color);
border-left: 1px solid var(--pico-muted-border-color);
border-top: 1px solid var(--pico-muted-border-color);
transform: rotate(45deg);
}
@@ -767,22 +557,22 @@ details[open] .cancel-text {
.radio-group.format-mode label {
padding: 0.4rem 0.8rem;
border: 1px solid var(--border);
border-radius: 4px;
border: 1px solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
cursor: pointer;
background: var(--bg-primary);
background: var(--pico-background-color);
transition: all 0.2s;
font-weight: normal;
}
.radio-group.format-mode label:hover {
background: var(--bg-tertiary);
background: var(--pico-card-sectioning-background-color);
}
.radio-group.format-mode label:has(input:checked) {
background: var(--accent);
color: white;
border-color: var(--accent);
background: var(--pico-primary);
color: var(--pico-primary-inverse);
border-color: var(--pico-primary);
}
/* Theme selector */
@@ -796,9 +586,9 @@ details[open] .cancel-text {
}
.theme-selector button.active {
background: var(--accent);
color: white;
border-color: var(--accent);
background: var(--pico-primary);
color: var(--pico-primary-inverse);
border-color: var(--pico-primary);
}
/* Download form */
@@ -806,34 +596,8 @@ details[open] .cancel-text {
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);
.download-form form {
max-width: 800px;
}
/* Filter form inline */
@@ -844,19 +608,22 @@ details[open] .cancel-text {
}
.filter-form input,
.filter-form select {
padding: 0.4rem;
.filter-form select,
.filter-form button {
margin-bottom: 0;
width: auto;
display: inline-block;
flex-shrink: 0;
}
.filter-form input[type="text"] {
min-width: 200px;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
.empty-state a {
color: var(--accent);
}
/* Utility */
@@ -890,4 +657,15 @@ details[open] .cancel-text {
.queue-header {
flex-direction: column;
}
header {
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
nav {
width: 100%;
justify-content: center;
}
}
Mweb/templates/download_form.html
@@ -4,48 +4,45 @@
</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>
<article>
<form method="post" action="/download">
<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 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>
<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 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>
<label for="output_dir">Output Directory</label>
<input type="text" id="output_dir" name="output_dir" placeholder="e.g., videos/music (relative to downloads folder)" value="{{.Data.OutputDir}}">
<small>Optional sub-directory within the downloads folder. Use / for nested paths.</small>
<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>
<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>
<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 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>
</article>
{{if .Data.ShowFormats}}
{{if .Data.Formats}}
<div class="formats-table-container">
<article>
<h4>Available Formats</h4>
<table class="formats-table">
<table>
<thead>
<tr>
<th>ID</th>
@@ -67,7 +64,7 @@
{{end}}
</tbody>
</table>
</div>
</article>
{{else}}
<div class="empty-state">
<p>No formats found. Check the URL and try again.</p>
Mweb/templates/layout.html
@@ -1,10 +1,11 @@
{{define "layout"}}
<!DOCTYPE html>
<html lang="en" data-theme="{{.Theme}}">
<html lang="en" {{if ne .Theme "auto"}}data-theme="{{.Theme}}"{{end}}>
<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/pico.min.css">
<link rel="stylesheet" href="/static/style.css">
{{if .AutoRefresh}}
<meta http-equiv="refresh" content="{{.RefreshSec}}">
Mweb/templates/library.html
@@ -43,12 +43,10 @@
{{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>
{{if .IsAudio}}
<img src="/static/icons/audio-icon.svg" alt="">
{{else}}
<div class="video-icon">🎬</div>
<img src="/media/{{.ID}}/thumbnail" alt="" loading="lazy">
{{end}}
</div>
<div class="media-info">
@@ -64,8 +62,7 @@
{{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>
<p>No media found. <a href="/download">Start a download</a> or scan for existing files.</p>
</div>
{{end}}
{{end}}
Mweb/templates/library_detail.html
@@ -2,27 +2,30 @@
<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">
<h2>{{.Data.Media.Title}}</h2>
<details class="delete-overlay">
<summary role="button" class="small danger">Delete</summary>
<div class="overlay-panel">
<p>Are you sure you want to delete this media?</p>
<div class="form-actions">
<form method="post" action="/library/{{.Data.Media.ID}}/delete" class="form-actions">
<button type="submit" class="danger">Yes, Delete</button>
</div>
</form>
</form>
</div>
</details>
</div>
<div class="player-container">
{{if .Data.IsAudio}}
{{if .Data.Media.IsAudio}}
<audio controls>
<source src="/media/{{.Data.ID}}" type="audio/{{ext .Data.Filepath}}">
<source src="/media/{{.Data.Media.ID}}" type="{{mimeType .Data.Media.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}}">
<source src="/media/{{.Data.Media.ID}}" type="{{mimeType .Data.Media.Filepath}}">
{{range .Data.Subtitles}}
<track kind="subtitles" src="{{.Src}}" srclang="{{.Lang}}" label="{{.Label}}">
{{end}}
Your browser does not support the video element.
</video>
{{end}}
@@ -31,25 +34,57 @@
<div class="detail-meta">
<dl>
<dt>Path</dt>
<dd>{{.Data.RelativePath}}</dd>
<dd>{{.Data.Media.RelativePath}}</dd>
{{if .Data.Duration}}
{{if .Data.Media.Duration}}
<dt>Duration</dt>
<dd>{{formatDuration .Data.Duration}}</dd>
<dd>{{formatDuration .Data.Media.Duration}}</dd>
{{end}}
{{if .Data.URL}}
{{if .Data.Media.URL}}
<dt>Source URL</dt>
<dd><a href="{{.Data.URL}}" target="_blank">{{.Data.URL}}</a></dd>
<dd><a href="{{.Data.Media.URL}}" target="_blank">{{.Data.Media.URL}}</a></dd>
{{end}}
{{if .Data.Description}}
{{if .Data.Media.Description}}
<dt>Description</dt>
<dd>{{.Data.Description}}</dd>
<dd>{{.Data.Media.Description}}</dd>
{{end}}
{{if .Data.Metadata}}
{{if .Data.Metadata.Container}}
<dt>Container</dt>
<dd>{{.Data.Metadata.Container}}</dd>
{{end}}
{{if .Data.Metadata.Resolution}}
<dt>Resolution</dt>
<dd>{{.Data.Metadata.Resolution}}</dd>
{{end}}
{{if .Data.Metadata.VideoCodec}}
<dt>Video Codec</dt>
<dd>{{.Data.Metadata.VideoCodec}}</dd>
{{end}}
{{if .Data.Metadata.AudioCodec}}
<dt>Audio Codec</dt>
<dd>{{.Data.Metadata.AudioCodec}}</dd>
{{end}}
{{if .Data.Metadata.Bitrate}}
<dt>Bitrate</dt>
<dd>{{.Data.Metadata.Bitrate}} kbps</dd>
{{end}}
{{if .Data.Metadata.FileSize}}
<dt>File Size</dt>
<dd>{{formatFileSize .Data.Metadata.FileSize}}</dd>
{{end}}
{{end}}
<dt>File</dt>
<dd>{{.Data.Filepath}}</dd>
<dd>{{.Data.Media.Filepath}}</dd>
</dl>
</div>
</div>
Mweb/templates/queue.html
@@ -2,6 +2,9 @@
<div class="page-header">
<h2>Queue</h2>
<div class="actions">
<form method="post" action="/queue/clear" class="inline">
<button type="submit" class="secondary">Clear All</button>
</form>
<form method="get" action="/queue" class="inline filter-form">
<select name="status">
<option value="all" {{if eq .Data.Status "all"}}selected{{end}}>All</option>
@@ -12,7 +15,6 @@
</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>
@@ -22,28 +24,18 @@
<div class="queue-list">
{{range .Data.Items}}
<div class="queue-item {{.Status}}">
<article 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>
{{if .ErrorMessage.Valid}}
<div class="error-message">{{.ErrorMessage.String}}</div>
{{end}}
<div class="queue-meta">
<span>Created: {{.CreatedAt.Format "2006-01-02 15:04"}}</span>
@@ -51,11 +43,11 @@
<span>Started: {{.StartedAt.Time.Format "2006-01-02 15:04"}}</span>
{{end}}
</div>
</div>
</article>
{{else}}
<div class="empty-state">
<p>Queue is empty. <a href="/download">Start a download</a>.</p>
</div>
{{end}}
</div>
{{end}}
{{end}}
Mweb/templates/queue_detail.html
@@ -5,9 +5,6 @@
<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>
@@ -29,22 +26,16 @@
<dd>{{.Data.CompletedAt.Time.Format "2006-01-02 15:04:05"}}</dd>
{{end}}
{{if .Data.ErrorMessage}}
{{if .Data.ErrorMessage.Valid}}
<dt>Error</dt>
<dd class="error-text">{{.Data.ErrorMessage}}</dd>
<dd class="error-text">{{.Data.ErrorMessage.String}}</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">
<article>
<h3>Logs</h3>
<pre class="logs">{{if .Data.Logs}}{{.Data.Logs}}{{else}}No logs available yet...{{end}}</pre>
</div>
<pre class="logs">{{if .Data.Logs.Valid}}{{.Data.Logs.String}}{{else}}No logs available yet...{{end}}</pre>
</article>
</div>
{{end}}
{{end}}
Mweb/templates/settings.html
@@ -4,38 +4,36 @@
</div>
<div class="settings-layout">
<section class="settings-section">
<article>
<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>
<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 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>
<label>
<input type="checkbox" name="auto_refresh_library" value="1"
{{if .Data.Settings.AutoRefreshLibrary}}checked{{end}}>
Auto-refresh Library page
</label>
<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>
<label>
<input type="checkbox" name="auto_refresh_downloads" value="1"
{{if .Data.Settings.AutoRefreshDownloads}}checked{{end}}>
Auto-refresh Queue page
</label>
<label for="cookies">Cookies (cookies.txt format)</label>
<textarea id="cookies" name="cookies" rows="6" placeholder="Paste cookies.txt content here...">{{.Data.Settings.Cookies}}</textarea>
<small>Paste the contents of a cookies.txt file. Will be passed to yt-dlp via --cookies.</small>
<div class="form-actions">
<button type="submit" class="primary">Save Settings</button>
</div>
</form>
</section>
</article>
<section class="settings-section">
<article>
<h3>Theme</h3>
<div class="theme-selector">
<form method="post" action="/theme" class="inline">
@@ -51,18 +49,18 @@
<button type="submit" class="secondary {{if eq .Theme "auto"}}active{{end}}">Auto</button>
</form>
</div>
</section>
</article>
<section class="settings-section">
<article>
<h3>Presets</h3>
<div class="presets-list">
{{range .Data.Presets}}
<div class="preset-card">
<article>
<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>
<summary role="button" class="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">
@@ -83,7 +81,7 @@
<div class="preset-actions">
<details>
<summary class="button small"><span class="edit-text">Edit</span><span class="cancel-text">Cancel</span></summary>
<summary role="button" class="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">
@@ -92,21 +90,23 @@
</form>
</details>
</div>
</div>
</article>
{{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" .}}
<article>
<h4>Create Preset</h4>
<form method="post" action="/settings/presets">
{{template "preset_form_create" .}}
<div class="form-actions">
<button type="submit" class="primary">Create Preset</button>
</div>
</form>
</section>
<div class="form-actions">
<button type="submit" class="primary">Create Preset</button>
</div>
</form>
</article>
</article>
</div>
{{end}}
@@ -123,7 +123,7 @@
value="{{if .}}{{.Description}}{{end}}">
</div>
<div class="form-group checkbox">
<div class="form-group">
<label>
<input type="checkbox" name="is_default" value="1"
{{if .}}{{if .IsDefault}}checked{{end}}{{end}}>
@@ -188,7 +188,7 @@
</div>
<div class="conditional-section">
<div class="form-group checkbox">
<div class="form-group">
<label>
<input type="checkbox" name="extract_audio" value="1" class="conditional-trigger"
{{if .}}{{if .ExtractAudio}}checked{{end}}{{end}}>
@@ -200,8 +200,6 @@
<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>
@@ -213,7 +211,7 @@
</div>
<div class="conditional-section">
<div class="form-group checkbox">
<div class="form-group">
<label>
<input type="checkbox" name="embed_subs" value="1" class="conditional-trigger"
{{if .}}{{if .EmbedSubs}}checked{{end}}{{end}}>
@@ -230,7 +228,7 @@
</div>
</div>
<div class="form-group checkbox">
<div class="form-group">
<label>
<input type="checkbox" name="embed_thumbnail" value="1"
{{if .}}{{if .EmbedThumbnail}}checked{{end}}{{else}}checked{{end}}>
@@ -238,7 +236,7 @@
</label>
</div>
<div class="form-group checkbox">
<div class="form-group">
<label>
<input type="checkbox" name="embed_metadata" value="1"
{{if .}}{{if .EmbedMetadata}}checked{{end}}{{else}}checked{{end}}>
@@ -246,7 +244,7 @@
</label>
</div>
<div class="form-group checkbox">
<div class="form-group">
<label>
<input type="checkbox" name="write_info_json" value="1"
{{if .}}{{if .WriteInfoJSON}}checked{{end}}{{else}}checked{{end}}>
@@ -278,7 +276,7 @@
<input type="text" name="description">
</div>
<div class="form-group checkbox">
<div class="form-group">
<label>
<input type="checkbox" name="is_default" value="1">
Set as default preset
@@ -338,7 +336,7 @@
</div>
<div class="conditional-section">
<div class="form-group checkbox">
<div class="form-group">
<label>
<input type="checkbox" name="extract_audio" value="1" class="conditional-trigger">
Extract audio only
@@ -349,8 +347,6 @@
<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>
@@ -362,7 +358,7 @@
</div>
<div class="conditional-section">
<div class="form-group checkbox">
<div class="form-group">
<label>
<input type="checkbox" name="embed_subs" value="1" class="conditional-trigger">
Embed subtitles
@@ -377,21 +373,21 @@
</div>
</div>
<div class="form-group checkbox">
<div class="form-group">
<label>
<input type="checkbox" name="embed_thumbnail" value="1" checked>
Embed thumbnail
</label>
</div>
<div class="form-group checkbox">
<div class="form-group">
<label>
<input type="checkbox" name="embed_metadata" value="1" checked>
Embed metadata
</label>
</div>
<div class="form-group checkbox">
<div class="form-group">
<label>
<input type="checkbox" name="write_info_json" value="1" checked>
Write info JSON
@@ -400,7 +396,8 @@
<div class="form-group">
<label for="output_template">Output Template</label>
<input type="text" name="output_template" value="%(title)s [%(id)s].%(ext)s">
<input type="text" name="output_template"
value="%(title)s.%(ext)s">
<small>Use / for subfolders. Example: %(playlist_title)s/%(title)s.%(ext)s</small>
</div>