fix: robustness, error handling, and UI feedback pass
Minternal/handler/handler.go
@@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"path/filepath"
@@ -68,6 +69,30 @@ func loadTemplates() (*template.Template, error) {
return strings.Join(parts, "/")
},
"sub": func(a, b int) int { return a - b },
// emptyPreset / newSubscription supply a zero value so the shared create and
// edit form partials can be rendered from the create page too. newSubscription
// carries the create-time defaults (overwrite mode, daily schedule).
"emptyPreset": func() *models.Preset { return nil },
"newSubscription": func() *models.Subscription {
return &models.Subscription{RefreshMode: "overwrite", ScheduleKind: "daily"}
},
// dict builds a map from alternating key/value args, so a template can pass
// more than one value into a sub-template (e.g. the subscription form needs
// both the subscription and the preset list).
"dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict expects an even number of arguments")
}
m := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict keys must be strings")
}
m[key] = values[i+1]
}
return m, nil
},
// isLongText reports whether text spans more than ~2 lines, so the detail
// view can make long descriptions collapsible. Uses rune count (not bytes)
// so CJK text isn't flagged early.
@@ -124,10 +149,68 @@ type PageData struct {
AutoRefresh bool
RefreshSec int
Theme string
Flash *Flash
}
// Flash is a one-shot message shown to the user after a redirect (the
// Post/Redirect/Get pattern). Kind is "success" or "error".
type Flash struct {
Kind string
Message string
}
const cookieMaxAge = 365 * 24 * 60 * 60
const flashCookie = "flash"
// setFlash stashes a one-shot message in a short-lived cookie. The next rendered
// page reads and clears it (see consumeFlash), so the message appears once after
// the redirect and never again.
func setFlash(w http.ResponseWriter, kind, message string) {
http.SetCookie(w, &http.Cookie{
Name: flashCookie,
Value: url.QueryEscape(kind + "|" + message),
Path: "/",
MaxAge: 30,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func flashSuccess(w http.ResponseWriter, message string) { setFlash(w, "success", message) }
func flashError(w http.ResponseWriter, message string) { setFlash(w, "error", message) }
// consumeFlash reads the flash cookie (if any) and immediately expires it, so a
// message is shown exactly once.
func consumeFlash(w http.ResponseWriter, r *http.Request) *Flash {
c, err := r.Cookie(flashCookie)
if err != nil || c.Value == "" {
return nil
}
http.SetCookie(w, &http.Cookie{
Name: flashCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
decoded, err := url.QueryUnescape(c.Value)
if err != nil {
return nil
}
kind, message, ok := strings.Cut(decoded, "|")
if !ok {
return nil
}
// The cookie is client-editable, so don't let an arbitrary kind flow into the
// banner's class name — clamp it to the two we render.
if kind != "success" {
kind = "error"
}
return &Flash{Kind: kind, Message: message}
}
func getCookie(r *http.Request, name, defaultVal string) string {
cookie, err := r.Cookie(name)
if err != nil {
@@ -152,6 +235,9 @@ func (h *Handler) renderWithRequest(w http.ResponseWriter, r *http.Request, cont
if data.Theme == "" {
data.Theme = getCookie(r, "theme", "auto")
}
if data.Flash == nil {
data.Flash = consumeFlash(w, r)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
var contentBuf bytes.Buffer
@@ -173,6 +259,17 @@ func (h *Handler) renderWithRequest(w http.ResponseWriter, r *http.Request, cont
}
}
// settingsOrDefault loads settings for page rendering, falling back to sane
// defaults (rather than a nil deref) if the store can't be read.
func (h *Handler) settingsOrDefault() *models.Settings {
settings, err := h.settingsSvc.GetAll()
if err != nil {
log.Printf("settings load failed, using defaults: %v", err)
return &models.Settings{RefreshInterval: 5, AutoRefreshDownloads: true}
}
return settings
}
func (h *Handler) Library(w http.ResponseWriter, r *http.Request) {
path := r.URL.Query().Get("path")
sortBy := r.URL.Query().Get("sort")
@@ -190,7 +287,7 @@ func (h *Handler) Library(w http.ResponseWriter, r *http.Request) {
return
}
settings, _ := h.settingsSvc.GetAll()
settings := h.settingsOrDefault()
h.renderWithRequest(w, r, "library", PageData{
Title: "Library",
@@ -219,7 +316,11 @@ func normalizeRelPath(r *http.Request) string {
// directory named "a+b" round-trips correctly.
relPath := chi.URLParam(r, "*")
relPath = strings.Trim(relPath, "/")
relPath, _ = url.PathUnescape(relPath)
if decoded, err := url.PathUnescape(relPath); err == nil {
relPath = decoded
} else {
log.Printf("normalizeRelPath: undecodable path %q: %v", relPath, err)
}
return relPath
}
@@ -247,8 +348,14 @@ func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.R
selectedFilename = item.MediaFiles[0].Filename
}
meta, _ := h.librarySvc.GetMetadata(ctx, relPath, selectedFilename)
subtitles, _ := h.librarySvc.GetSubtitles(ctx, relPath)
meta, err := h.librarySvc.GetMetadata(ctx, relPath, selectedFilename)
if err != nil {
log.Printf("libraryDetail %q: metadata unavailable: %v", relPath, err)
}
subtitles, err := h.librarySvc.GetSubtitles(ctx, relPath)
if err != nil {
log.Printf("libraryDetail %q: subtitles unavailable: %v", relPath, err)
}
h.renderWithRequest(w, r, "library_detail", PageData{
Title: item.Name,
@@ -385,7 +492,7 @@ func (h *Handler) Downloads(w http.ResponseWriter, r *http.Request) {
return
}
settings, _ := h.settingsSvc.GetAll()
settings := h.settingsOrDefault()
h.renderWithRequest(w, r, "queue", PageData{
Title: "Queue",
@@ -433,7 +540,8 @@ func (h *Handler) CreateDownload(w http.ResponseWriter, r *http.Request) {
url := r.FormValue("url")
if url == "" {
http.Error(w, "URL is required", http.StatusBadRequest)
flashError(w, "A URL is required to start a download.")
http.Redirect(w, r, "/download", http.StatusSeeOther)
return
}
@@ -451,12 +559,14 @@ func (h *Handler) CreateDownload(w http.ResponseWriter, r *http.Request) {
download, err := h.downloadSvc.Create(url, presetID, formatOverride, customFlags, outputDir)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
flashError(w, "Couldn't queue download: "+err.Error())
http.Redirect(w, r, "/download", http.StatusSeeOther)
return
}
h.workerPool.Submit(download)
flashSuccess(w, "Download queued.")
http.Redirect(w, r, "/queue", http.StatusSeeOther)
}
@@ -495,19 +605,23 @@ func (h *Handler) DeleteDownload(w http.ResponseWriter, r *http.Request) {
}
if err := h.downloadSvc.Delete(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
flashError(w, "Couldn't remove download: "+err.Error())
http.Redirect(w, r, "/queue", http.StatusSeeOther)
return
}
flashSuccess(w, "Download removed.")
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)
flashError(w, "Couldn't clear the queue: "+err.Error())
http.Redirect(w, r, "/queue", http.StatusSeeOther)
return
}
flashSuccess(w, "Queue cleared.")
http.Redirect(w, r, "/queue", http.StatusSeeOther)
}
@@ -563,10 +677,12 @@ func (h *Handler) CreatePreset(w http.ResponseWriter, r *http.Request) {
preset.WriteInfoJSON = r.FormValue("write_info_json") == "1"
if err := h.presetSvc.Create(preset); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
flashError(w, "Couldn't create preset: "+err.Error())
http.Redirect(w, r, "/settings", http.StatusSeeOther)
return
}
flashSuccess(w, "Preset created.")
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
@@ -606,10 +722,12 @@ func (h *Handler) UpdatePreset(w http.ResponseWriter, r *http.Request) {
preset.WriteInfoJSON = r.FormValue("write_info_json") == "1"
if err := h.presetSvc.Update(preset); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
flashError(w, "Couldn't update preset: "+err.Error())
http.Redirect(w, r, "/settings", http.StatusSeeOther)
return
}
flashSuccess(w, "Preset updated.")
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
@@ -622,10 +740,12 @@ func (h *Handler) DeletePreset(w http.ResponseWriter, r *http.Request) {
}
if err := h.presetSvc.Delete(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
flashError(w, "Couldn't delete preset: "+err.Error())
http.Redirect(w, r, "/settings", http.StatusSeeOther)
return
}
flashSuccess(w, "Preset deleted.")
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
@@ -635,14 +755,26 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
return
}
if interval := r.FormValue("refresh_interval"); interval != "" {
h.settingsSvc.SetRefreshInterval(interval)
var firstErr error
record := func(err error) {
if err != nil && firstErr == nil {
firstErr = err
}
}
h.settingsSvc.SetAutoRefreshLibrary(r.FormValue("auto_refresh_library") == "1")
h.settingsSvc.SetAutoRefreshDownloads(r.FormValue("auto_refresh_downloads") == "1")
h.settingsSvc.SetCookies(r.FormValue("cookies"))
if interval := r.FormValue("refresh_interval"); interval != "" {
record(h.settingsSvc.SetRefreshInterval(interval))
}
record(h.settingsSvc.SetAutoRefreshLibrary(r.FormValue("auto_refresh_library") == "1"))
record(h.settingsSvc.SetAutoRefreshDownloads(r.FormValue("auto_refresh_downloads") == "1"))
record(h.settingsSvc.SetCookies(r.FormValue("cookies")))
if firstErr != nil {
log.Printf("UpdateSettings: %v", firstErr)
flashError(w, "Some settings couldn't be saved: "+firstErr.Error())
} else {
flashSuccess(w, "Settings saved.")
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
@@ -653,17 +785,25 @@ func (h *Handler) DownloadForm(w http.ResponseWriter, r *http.Request) {
return
}
// A missing default preset is normal (the user may not have set one); the
// template handles a nil DefaultPreset, so this isn't surfaced as an error.
defaultPreset, _ := h.presetSvc.GetDefault()
url := r.URL.Query().Get("url")
var formats []*models.FormatInfo
var flash *Flash
if r.URL.Query().Get("list_formats") == "1" && url != "" {
formats, _ = h.downloadSvc.ListFormats(url)
var err error
if formats, err = h.downloadSvc.ListFormats(url); err != nil {
log.Printf("DownloadForm: listing formats for %q failed: %v", url, err)
flash = &Flash{Kind: "error", Message: "Couldn't list formats: " + err.Error()}
}
}
h.renderWithRequest(w, r, "download_form", PageData{
Title: "Download",
ActiveTab: "download",
Flash: flash,
Data: struct {
Presets []*models.Preset
DefaultPreset *models.Preset
Minternal/handler/subscription.go
@@ -96,7 +96,8 @@ func (h *Handler) CreateSubscription(w http.ResponseWriter, r *http.Request) {
sub, err := h.subscriptionFromForm(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
flashError(w, err.Error())
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
return
}
@@ -105,9 +106,11 @@ func (h *Handler) CreateSubscription(w http.ResponseWriter, r *http.Request) {
}
if err := h.subscriptionSvc.Create(sub); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
flashError(w, "Couldn't create subscription: "+err.Error())
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
return
}
flashSuccess(w, "Subscription created.")
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
}
@@ -130,7 +133,8 @@ func (h *Handler) UpdateSubscription(w http.ResponseWriter, r *http.Request) {
sub, err := h.subscriptionFromForm(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
flashError(w, err.Error())
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
return
}
sub.ID = id
@@ -141,9 +145,11 @@ func (h *Handler) UpdateSubscription(w http.ResponseWriter, r *http.Request) {
}
if err := h.subscriptionSvc.Update(sub); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
flashError(w, "Couldn't update subscription: "+err.Error())
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
return
}
flashSuccess(w, "Subscription updated.")
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
}
@@ -159,9 +165,15 @@ func (h *Handler) ToggleSubscription(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.subscriptionSvc.SetEnabled(id, !sub.Enabled); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
flashError(w, "Couldn't change subscription: "+err.Error())
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
return
}
if sub.Enabled {
flashSuccess(w, "Subscription paused.")
} else {
flashSuccess(w, "Subscription resumed.")
}
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
}
@@ -178,10 +190,12 @@ func (h *Handler) RunSubscription(w http.ResponseWriter, r *http.Request) {
}
download, err := h.downloadSvc.CreateForSubscription(sub)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
flashError(w, "Couldn't start subscription run: "+err.Error())
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
return
}
h.workerPool.Submit(download)
flashSuccess(w, "Subscription run queued.")
http.Redirect(w, r, "/queue", http.StatusSeeOther)
}
@@ -192,9 +206,11 @@ func (h *Handler) DeleteSubscription(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.subscriptionSvc.Delete(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
flashError(w, "Couldn't delete subscription: "+err.Error())
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
return
}
flashSuccess(w, "Subscription deleted.")
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
}
Minternal/repository/download.go
@@ -2,6 +2,7 @@ package repository
import (
"database/sql"
"fmt"
"vidarchive/internal/models"
)
@@ -23,7 +24,11 @@ func (r *DownloadRepository) Create(d *models.Download) error {
if err != nil {
return err
}
d.ID, _ = result.LastInsertId()
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("download last insert id: %w", err)
}
d.ID = id
return nil
}
Minternal/repository/preset.go
@@ -2,10 +2,17 @@ package repository
import (
"database/sql"
"fmt"
"vidarchive/internal/models"
)
// execer is satisfied by both *sql.DB and *sql.Tx, so the insert/update/clear
// helpers can run either directly or inside a transaction (see Save).
type execer interface {
Exec(query string, args ...interface{}) (sql.Result, error)
}
type PresetRepository struct {
db *sql.DB
}
@@ -14,8 +21,8 @@ func NewPresetRepository(db *sql.DB) *PresetRepository {
return &PresetRepository{db: db}
}
func (r *PresetRepository) Create(p *models.Preset) error {
result, err := r.db.Exec(
func insertPreset(e execer, p *models.Preset) error {
result, err := e.Exec(
`INSERT INTO presets (name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, custom_flags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Name, p.Description, boolToInt(p.IsDefault), p.FormatMode, p.Format, p.Quality, p.CustomFormat,
@@ -26,10 +33,63 @@ func (r *PresetRepository) Create(p *models.Preset) error {
if err != nil {
return err
}
p.ID, _ = result.LastInsertId()
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("preset last insert id: %w", err)
}
p.ID = id
return nil
}
func updatePreset(e execer, p *models.Preset) error {
_, err := e.Exec(
`UPDATE presets SET name=?, description=?, is_default=?, format_mode=?, format=?, quality=?, custom_format=?, extract_audio=?, audio_format=?, embed_subs=?, sub_langs=?, embed_thumbnail=?, embed_metadata=?, write_info_json=?, custom_flags=?
WHERE id=?`,
p.Name, p.Description, boolToInt(p.IsDefault), p.FormatMode, p.Format, p.Quality, p.CustomFormat,
boolToInt(p.ExtractAudio), p.AudioFormat, boolToInt(p.EmbedSubs), p.SubLangs,
boolToInt(p.EmbedThumbnail), boolToInt(p.EmbedMetadata), boolToInt(p.WriteInfoJSON),
p.CustomFlags, p.ID,
)
return err
}
func clearDefault(e execer) error {
_, err := e.Exec(`UPDATE presets SET is_default = 0`)
return err
}
// Save persists p, inserting it when it has no id yet and updating it otherwise.
// When p is the new default it first clears the previous default, doing all of it
// within a single transaction so a mid-sequence failure can't leave the library
// with no default set. Create and Update both route through here, so the
// exclusive-default invariant can't be bypassed.
func (r *PresetRepository) Save(p *models.Preset) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if p.IsDefault {
if err := clearDefault(tx); err != nil {
return err
}
}
if p.ID == 0 {
err = insertPreset(tx, p)
} else {
err = updatePreset(tx, p)
}
if err != nil {
return err
}
return tx.Commit()
}
func (r *PresetRepository) Create(p *models.Preset) error {
return r.Save(p)
}
func (r *PresetRepository) GetByID(id int64) (*models.Preset, error) {
row := r.db.QueryRow(
`SELECT id, name, description, is_default, format_mode, format, quality, custom_format, extract_audio, audio_format, embed_subs, sub_langs, embed_thumbnail, embed_metadata, write_info_json, custom_flags, created_at
@@ -68,15 +128,7 @@ func (r *PresetRepository) GetDefault() (*models.Preset, error) {
}
func (r *PresetRepository) Update(p *models.Preset) error {
_, err := r.db.Exec(
`UPDATE presets SET name=?, description=?, is_default=?, format_mode=?, format=?, quality=?, custom_format=?, extract_audio=?, audio_format=?, embed_subs=?, sub_langs=?, embed_thumbnail=?, embed_metadata=?, write_info_json=?, custom_flags=?
WHERE id=?`,
p.Name, p.Description, boolToInt(p.IsDefault), p.FormatMode, p.Format, p.Quality, p.CustomFormat,
boolToInt(p.ExtractAudio), p.AudioFormat, boolToInt(p.EmbedSubs), p.SubLangs,
boolToInt(p.EmbedThumbnail), boolToInt(p.EmbedMetadata), boolToInt(p.WriteInfoJSON),
p.CustomFlags, p.ID,
)
return err
return r.Save(p)
}
func (r *PresetRepository) Delete(id int64) error {
@@ -85,8 +137,7 @@ func (r *PresetRepository) Delete(id int64) error {
}
func (r *PresetRepository) ClearDefault() error {
_, err := r.db.Exec(`UPDATE presets SET is_default = 0`)
return err
return clearDefault(r.db)
}
func scanPreset(row interface{ Scan(...interface{}) error }) (*models.Preset, error) {
Minternal/repository/preset_test.go
@@ -117,3 +117,62 @@ func TestPresetRepositoryDefault(t *testing.T) {
t.Error("expected no default preset after clearing")
}
}
// Save must keep "default" exclusive within a single transaction: promoting a
// new default demotes the previous one, and there is never more than one.
func TestPresetRepositorySaveExclusiveDefault(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
repo := NewPresetRepository(db)
if err := repo.ClearDefault(); err != nil { // drop the migration-seeded default
t.Fatalf("clear default: %v", err)
}
a := &models.Preset{Name: "A", IsDefault: true}
if err := repo.Save(a); err != nil {
t.Fatalf("save A: %v", err)
}
if a.ID == 0 {
t.Error("expected ID to be set by Save")
}
b := &models.Preset{Name: "B", IsDefault: true}
if err := repo.Save(b); err != nil {
t.Fatalf("save B: %v", err)
}
assertSoleDefault := func(want string) {
t.Helper()
def, err := repo.GetDefault()
if err != nil {
t.Fatalf("get default: %v", err)
}
if def.Name != want {
t.Errorf("expected %q to be the default, got %q", want, def.Name)
}
all, err := repo.GetAll()
if err != nil {
t.Fatalf("get all: %v", err)
}
n := 0
for _, p := range all {
if p.IsDefault {
n++
}
}
if n != 1 {
t.Errorf("expected exactly one default, got %d", n)
}
}
// B was created as default last, so it wins.
assertSoleDefault("B")
// Updating A to default flips it back, still exclusively (a.ID is set, so Save
// routes to an update).
a.IsDefault = true
if err := repo.Save(a); err != nil {
t.Fatalf("save A update: %v", err)
}
assertSoleDefault("A")
}
Minternal/repository/subscription.go
@@ -2,6 +2,7 @@ package repository
import (
"database/sql"
"fmt"
"time"
"vidarchive/internal/models"
@@ -32,7 +33,11 @@ func (r *SubscriptionRepository) Create(s *models.Subscription) error {
if err != nil {
return err
}
s.ID, _ = result.LastInsertId()
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("subscription last insert id: %w", err)
}
s.ID = id
return nil
}
Ainternal/server/handler_test.go
@@ -0,0 +1,233 @@
package server
import (
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strconv"
"strings"
"testing"
)
// getWith issues a GET carrying the given cookies (so a flash set by a prior
// POST is delivered to the page that renders it).
func getWith(router http.Handler, path string, cookies []*http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", path, nil)
for _, c := range cookies {
req.AddCookie(c)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
// flash returns the (kind, message) of a flash cookie set on the response.
func flash(w *httptest.ResponseRecorder) (kind, message string, ok bool) {
for _, c := range w.Result().Cookies() {
if c.Name == "flash" && c.Value != "" {
if v, err := url.QueryUnescape(c.Value); err == nil {
if k, m, found := strings.Cut(v, "|"); found {
return k, m, true
}
}
}
}
return "", "", false
}
var presetIDRe = regexp.MustCompile(`/settings/presets/(\d+)"`)
// newestPresetID returns the highest preset id referenced on the settings page,
// which (ids autoincrement) is the most recently created preset.
func newestPresetID(t *testing.T, router http.Handler) string {
t.Helper()
body := getWith(router, "/settings", nil).Body.String()
max := int64(-1)
for _, m := range presetIDRe.FindAllStringSubmatch(body, -1) {
if id, err := strconv.ParseInt(m[1], 10, 64); err == nil && id > max {
max = id
}
}
if max < 0 {
t.Fatal("no preset id found on settings page")
}
return strconv.FormatInt(max, 10)
}
func validPresetForm(name string) url.Values {
return url.Values{
"name": {name},
"format_mode": {"default"},
}
}
// A created preset shows a success flash exactly once, then the message is gone.
func TestPresetCreateFlashShownOnce(t *testing.T) {
srv, _, cleanup := setupTestServer(t)
defer cleanup()
router := srv.Router()
w := postForm(router, "/settings/presets", validPresetForm("Archival"))
if w.Code != http.StatusSeeOther {
t.Fatalf("create preset: expected 303, got %d", w.Code)
}
kind, msg, ok := flash(w)
if !ok || kind != "success" || !strings.Contains(msg, "created") {
t.Fatalf("expected success flash, got kind=%q msg=%q ok=%v", kind, msg, ok)
}
// The redirected page carries the flash cookie and shows the message.
page := getWith(router, "/settings", w.Result().Cookies())
if !strings.Contains(page.Body.String(), msg) {
t.Error("settings page did not render the flash message")
}
if !strings.Contains(page.Body.String(), "Archival") {
t.Error("created preset not shown on settings page")
}
// ...and it clears the cookie, so a subsequent load is clean.
if _, _, stillSet := flash(page); stillSet {
t.Error("flash cookie should have been cleared after being shown")
}
clean := getWith(router, "/settings", page.Result().Cookies())
if strings.Contains(clean.Body.String(), msg) {
t.Error("flash message should not persist across loads")
}
}
func TestPresetUpdateAndDelete(t *testing.T) {
srv, _, cleanup := setupTestServer(t)
defer cleanup()
router := srv.Router()
if w := postForm(router, "/settings/presets", validPresetForm("Original")); w.Code != http.StatusSeeOther {
t.Fatalf("create: got %d", w.Code)
}
id := newestPresetID(t, router)
// Update renames it.
if w := postForm(router, "/settings/presets/"+id, validPresetForm("Renamed")); w.Code != http.StatusSeeOther {
t.Fatalf("update: got %d", w.Code)
}
if body := getWith(router, "/settings", nil).Body.String(); !strings.Contains(body, "Renamed") {
t.Error("updated preset name not reflected")
}
// Delete removes it.
w := postForm(router, "/settings/presets/"+id+"/delete", nil)
if w.Code != http.StatusSeeOther {
t.Fatalf("delete: got %d", w.Code)
}
if k, _, ok := flash(w); !ok || k != "success" {
t.Error("expected success flash on delete")
}
if body := getWith(router, "/settings", nil).Body.String(); strings.Contains(body, "Renamed") {
t.Error("deleted preset still present")
}
}
// The create and edit preset forms share one partial; both must render.
func TestSettingsRendersSharedPresetForm(t *testing.T) {
srv, _, cleanup := setupTestServer(t)
defer cleanup()
router := srv.Router()
if w := postForm(router, "/settings/presets", validPresetForm("Editable")); w.Code != http.StatusSeeOther {
t.Fatalf("create: got %d", w.Code)
}
body := getWith(router, "/settings", nil).Body.String()
for _, want := range []string{"Create Preset", "Update Preset", `name="format_mode"`} {
if !strings.Contains(body, want) {
t.Errorf("settings page missing %q", want)
}
}
}
func TestCreateDownloadRequiresURL(t *testing.T) {
srv, _, cleanup := setupTestServer(t)
defer cleanup()
router := srv.Router()
w := postForm(router, "/download", url.Values{"url": {""}})
if w.Code != http.StatusSeeOther {
t.Fatalf("expected 303 redirect, got %d", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/download" {
t.Errorf("expected redirect to /download, got %q", loc)
}
if k, _, ok := flash(w); !ok || k != "error" {
t.Error("expected an error flash for the missing URL")
}
}
func TestCreateDownloadQueues(t *testing.T) {
srv, _, cleanup := setupTestServer(t)
defer cleanup()
router := srv.Router()
w := postForm(router, "/download", url.Values{"url": {"https://example.com/clip"}})
if w.Code != http.StatusSeeOther {
t.Fatalf("expected 303, got %d", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/queue" {
t.Errorf("expected redirect to /queue, got %q", loc)
}
if k, _, ok := flash(w); !ok || k != "success" {
t.Error("expected a success flash")
}
if body := getWith(router, "/queue", nil).Body.String(); !strings.Contains(body, "example.com/clip") {
t.Error("queued download not shown on the queue page")
}
}
func TestUpdateSettingsFlash(t *testing.T) {
srv, _, cleanup := setupTestServer(t)
defer cleanup()
router := srv.Router()
w := postForm(router, "/settings", url.Values{
"refresh_interval": {"7"},
"auto_refresh_library": {"1"},
"auto_refresh_downloads": {"1"},
})
if w.Code != http.StatusSeeOther {
t.Fatalf("expected 303, got %d", w.Code)
}
k, msg, ok := flash(w)
if !ok || k != "success" || !strings.Contains(msg, "saved") {
t.Errorf("expected success flash, got kind=%q msg=%q ok=%v", k, msg, ok)
}
// The saved interval is reflected in the form.
if body := getWith(router, "/settings", nil).Body.String(); !strings.Contains(body, `value="7"`) {
t.Error("saved refresh interval not reflected in settings form")
}
}
// A subscription's edit form is rendered by the shared partial with its values
// populated.
func TestSubscriptionEditFormRenders(t *testing.T) {
srv, _, cleanup := setupTestServer(t)
defer cleanup()
router := srv.Router()
if w := postForm(router, "/subscriptions", url.Values{
"name": {"News"},
"url": {"https://example.com/news"},
"output_dir": {"subscriptions/news"},
"refresh_mode": {"skip"},
"schedule_kind": {"weekly"},
}); w.Code != http.StatusSeeOther {
t.Fatalf("create sub: got %d", w.Code)
}
body := getWith(router, "/subscriptions", nil).Body.String()
for _, want := range []string{
"Update Subscription", // edit form present
`value="https://example.com/news"`, // URL populated in edit form
`value="subscriptions/news"`, // folder populated
} {
if !strings.Contains(body, want) {
t.Errorf("subscriptions page missing %q", want)
}
}
}
Minternal/server/subscription_test.go
@@ -63,29 +63,57 @@ func TestCreateSubscriptionLifecycle(t *testing.T) {
}
}
// hasErrorFlash reports whether the response set an error flash cookie (the
// Post/Redirect/Get feedback mechanism for a rejected form).
func hasErrorFlash(w *httptest.ResponseRecorder) bool {
for _, c := range w.Result().Cookies() {
if c.Name == "flash" {
if v, err := url.QueryUnescape(c.Value); err == nil {
return strings.HasPrefix(v, "error|")
}
}
}
return false
}
func TestCreateSubscriptionValidation(t *testing.T) {
srv, _, cleanup := setupTestServer(t)
defer cleanup()
router := srv.Router()
// Missing output_dir is rejected.
w := postForm(router, "/subscriptions", url.Values{
"url": {"https://example.com/x"},
"schedule_kind": {"daily"},
"output_dir": {""},
})
if w.Code != http.StatusBadRequest {
t.Errorf("empty output_dir: expected 400, got %d", w.Code)
// A rejected form redirects back (303) with an error flash rather than a raw
// 400, and must not persist anything.
cases := []struct {
name string
form url.Values
}{
{"empty output_dir", url.Values{
"url": {"https://example.com/x"},
"schedule_kind": {"daily"},
"output_dir": {""},
}},
{"bad cron", url.Values{
"url": {"https://example.com/x"},
"output_dir": {"foo"},
"schedule_kind": {"cron"},
"cron_expr": {"definitely not cron"},
}},
}
for _, tc := range cases {
w := postForm(router, "/subscriptions", tc.form)
if w.Code != http.StatusSeeOther {
t.Errorf("%s: expected 303 redirect, got %d", tc.name, w.Code)
}
if !hasErrorFlash(w) {
t.Errorf("%s: expected an error flash cookie", tc.name)
}
}
// Invalid custom cron is rejected.
w = postForm(router, "/subscriptions", url.Values{
"url": {"https://example.com/x"},
"output_dir": {"foo"},
"schedule_kind": {"cron"},
"cron_expr": {"definitely not cron"},
})
if w.Code != http.StatusBadRequest {
t.Errorf("bad cron: expected 400, got %d", w.Code)
// None of the rejected forms should have created a subscription.
req := httptest.NewRequest("GET", "/subscriptions", nil)
lw := httptest.NewRecorder()
router.ServeHTTP(lw, req)
if !strings.Contains(lw.Body.String(), "No subscriptions yet") {
t.Error("rejected forms should not have persisted any subscription")
}
}
Minternal/service/download.go
@@ -185,23 +185,27 @@ func (s *DownloadService) ExecuteDownload(d *models.Download) (bool, error) {
defer s.cache.Delete(d.ID)
var preset *models.Preset
if d.PresetID.Valid {
preset, err = s.presetSvc.GetByID(d.PresetID.Int64)
if err != nil {
preset, _ = s.presetSvc.GetDefault()
log.Printf("download %d: preset %d lookup failed (%v); falling back to default", d.ID, d.PresetID.Int64, err)
preset = nil
}
} else {
preset, _ = s.presetSvc.GetDefault()
}
if preset == nil {
preset = &models.Preset{}
var derr error
if preset, derr = s.presetSvc.GetDefault(); derr != nil {
log.Printf("download %d: no default preset available (%v); using built-in defaults", d.ID, derr)
preset = &models.Preset{}
}
}
var sub *models.Subscription
if d.SubscriptionID.Valid && s.subscriptionSvc != nil {
sub, _ = s.subscriptionSvc.GetByID(d.SubscriptionID.Int64)
var serr error
if sub, serr = s.subscriptionSvc.GetByID(d.SubscriptionID.Int64); serr != nil {
log.Printf("download %d: subscription %d lookup failed: %v", d.ID, d.SubscriptionID.Int64, serr)
}
}
// Reject custom flags that clash with options VidArchive sets itself, before
@@ -220,6 +224,10 @@ func (s *DownloadService) ExecuteDownload(d *models.Download) (bool, error) {
if err := os.MkdirAll(tempDownloadDir, 0755); err != nil {
return false, fmt.Errorf("create temp download dir: %w", err)
}
// Own the temp dir's lifetime here, where it's created, so it's removed on
// every exit path — including a failed yt-dlp run or an early return that
// crashes mid-import. The import helpers below no longer clean it up.
defer os.RemoveAll(tempDownloadDir)
args := s.presetSvc.BuildArgs(preset, d.FormatOverride, d.CustomFlags)
@@ -463,10 +471,8 @@ func (s *DownloadService) importDownloadedItems(d *models.Download, tempDownload
imported++
}
// RemoveAll (not Remove): leftover item dirs from failed imports, plus any
// orphaned thumbnail/image files yt-dlp left behind, would otherwise keep the
// temp dir non-empty and leak it forever.
os.RemoveAll(tempDownloadDir)
// The temp dir (and any leftovers from failed imports) is removed by the
// caller's deferred cleanup, so partial state never leaks even on a crash.
return imported, nil
}
@@ -646,8 +652,6 @@ func (s *DownloadService) refreshAndAddNew(d *models.Download, preset *models.Pr
}
}
os.RemoveAll(tempDownloadDir)
if len(newURLs) == 0 {
return nil
}
@@ -662,6 +666,7 @@ func (s *DownloadService) downloadFresh(d *models.Download, preset *models.Prese
if err := os.MkdirAll(tempDir, 0755); err != nil {
return err
}
defer os.RemoveAll(tempDir)
args := s.presetSvc.BuildArgs(preset, d.FormatOverride, d.CustomFlags)
args, cleanup := s.appendCookies(args)
@@ -691,9 +696,14 @@ func (s *DownloadService) applyMetadata(existing, infoJSONPath, videoID string)
Description string `json:"description"`
WebpageURL string `json:"webpage_url"`
}
_ = json.Unmarshal(data, &info)
if err := json.Unmarshal(data, &info); err != nil {
log.Printf("applyMetadata: ignoring malformed %s: %v", infoJSONPath, err)
}
meta, _ := s.librarySvc.readOrCreateMetadata(existing)
meta, err := s.librarySvc.readOrCreateMetadata(existing)
if err != nil {
return fmt.Errorf("read metadata for %s: %w", existing, err)
}
if info.Title != "" {
meta.Name = info.Title
}
Minternal/service/library.go
@@ -272,7 +272,9 @@ func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem,
if infoJSONPath != "" {
data, err := os.ReadFile(infoJSONPath)
if err == nil {
_ = json.Unmarshal(data, &info)
if err := json.Unmarshal(data, &info); err != nil {
log.Printf("scanItem: ignoring malformed %s: %v", infoJSONPath, err)
}
}
}
@@ -368,7 +370,9 @@ func (s *LibraryService) scanItem(itemDir, relPath string) (*models.LibraryItem,
}
if dirty {
_ = s.writeMetadata(itemDir, metadata)
if err := s.writeMetadata(itemDir, metadata); err != nil {
log.Printf("scanItem: failed to persist derived metadata for %s: %v", itemDir, err)
}
}
return item, nil
@@ -856,7 +860,11 @@ func (s *LibraryService) FindByVideoID(baseDir, id string) (string, bool) {
if _, err := os.Stat(filepath.Join(itemDir, itemMarkerName)); err != nil {
continue
}
meta, _ := s.readOrCreateMetadata(itemDir)
meta, err := s.readOrCreateMetadata(itemDir)
if err != nil {
log.Printf("FindByVideoID: skipping %s: %v", itemDir, err)
continue
}
if meta.VideoID == id {
return itemDir, true
}
@@ -883,7 +891,11 @@ func (s *LibraryService) PruneToIDSet(baseDir string, keep map[string]bool) (int
if _, err := os.Stat(filepath.Join(itemDir, itemMarkerName)); err != nil {
continue
}
meta, _ := s.readOrCreateMetadata(itemDir)
meta, err := s.readOrCreateMetadata(itemDir)
if err != nil {
log.Printf("PruneToIDSet: skipping %s: %v", itemDir, err)
continue
}
if meta.VideoID == "" || keep[meta.VideoID] {
continue
}
Minternal/service/preset.go
@@ -16,12 +16,7 @@ func NewPresetService(repo *repository.PresetRepository) *PresetService {
}
func (s *PresetService) Create(p *models.Preset) error {
if p.IsDefault {
if err := s.repo.ClearDefault(); err != nil {
return err
}
}
return s.repo.Create(p)
return s.repo.Save(p)
}
func (s *PresetService) GetByID(id int64) (*models.Preset, error) {
@@ -37,12 +32,7 @@ func (s *PresetService) GetDefault() (*models.Preset, error) {
}
func (s *PresetService) Update(p *models.Preset) error {
if p.IsDefault {
if err := s.repo.ClearDefault(); err != nil {
return err
}
}
return s.repo.Update(p)
return s.repo.Save(p)
}
func (s *PresetService) Delete(id int64) error {
Mweb/static/style.css
@@ -197,6 +197,27 @@ button.danger:focus,
--pico-border-color: #b71c1c;
}
/* One-shot feedback banner shown after a form action (see PageData.Flash). */
.flash {
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
border-radius: var(--pico-border-radius);
border: 1px solid transparent;
border-left-width: 4px;
}
.flash-success {
background-color: rgba(46, 125, 50, 0.12);
border-color: #2e7d32;
color: var(--pico-color);
}
.flash-error {
background-color: rgba(198, 40, 40, 0.12);
border-color: #c62828;
color: var(--pico-color);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
Mweb/templates/layout.html
@@ -31,6 +31,9 @@
</header>
<main>
{{if .Flash}}
<div class="flash flash-{{.Flash.Kind}}" role="status" aria-live="polite">{{.Flash.Message}}</div>
{{end}}
{{.Content}}
</main>
Mweb/templates/settings.html
@@ -99,7 +99,7 @@
<article>
<h4>Create Preset</h4>
<form method="post" action="/settings/presets">
{{template "preset_form_create" .}}
{{template "preset_form" emptyPreset}}
<div class="form-actions">
<button type="submit" class="primary">Create Preset</button>
@@ -112,13 +112,13 @@
{{define "preset_form"}}
<div class="form-group">
<label for="name">Name *</label>
<label>Name *</label>
<input type="text" name="name" required
value="{{if .}}{{.Name}}{{end}}">
</div>
<div class="form-group">
<label for="description">Description</label>
<label>Description</label>
<input type="text" name="description"
value="{{if .}}{{.Description}}{{end}}">
</div>
@@ -154,7 +154,7 @@
<div class="conditional-content format-preset">
<div class="form-row">
<div class="form-group">
<label for="format">Format</label>
<label>Format</label>
<select name="format">
<option value="best" {{if .}}{{if eq .Format "best"}}selected{{end}}{{end}}>best</option>
<option value="worst" {{if .}}{{if eq .Format "worst"}}selected{{end}}{{end}}>worst</option>
@@ -165,7 +165,7 @@
</div>
<div class="form-group">
<label for="quality">Max Quality</label>
<label>Max Quality</label>
<select name="quality">
<option value="" {{if .}}{{if eq .Quality ""}}selected{{end}}{{end}}>No limit</option>
<option value="720" {{if .}}{{if eq .Quality "720"}}selected{{end}}{{end}}>720p</option>
@@ -179,7 +179,7 @@
<div class="conditional-content format-custom">
<div class="form-group">
<label for="custom_format">Custom Format</label>
<label>Custom Format</label>
<input type="text" name="custom_format" placeholder="e.g., bestvideo+bestaudio/best or format ID like 22"
value="{{if .}}{{.CustomFormat}}{{end}}">
<small>Overrides the format selection. Use yt-dlp format syntax.</small>
@@ -220,7 +220,7 @@
</div>
<div class="conditional-content">
<div class="form-group">
<label for="sub_langs">Subtitle Languages</label>
<label>Subtitle Languages</label>
<input type="text" name="sub_langs" placeholder="e.g., en,de,fr or all"
value="{{if .}}{{.SubLangs}}{{else}}en{{end}}">
<small>Comma-separated language codes or "all"</small>
@@ -253,144 +253,8 @@
</div>
<div class="form-group">
<label for="custom_flags">Custom Flags</label>
<label>Custom Flags</label>
<textarea name="custom_flags" rows="2" placeholder="Any additional yt-dlp flags...">{{if .}}{{.CustomFlags}}{{end}}</textarea>
{{template "ytdlp_notes" false}}
</div>
{{end}}
{{define "preset_form_create"}}
<div class="form-group">
<label for="name">Name *</label>
<input type="text" name="name" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<input type="text" name="description">
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_default" value="1">
Set as default preset
</label>
</div>
<div class="conditional-section">
<label>Format Selection</label>
<div class="radio-group format-mode">
<label>
<input type="radio" name="format_mode" value="default" class="conditional-trigger" checked>
yt-dlp default
</label>
<label>
<input type="radio" name="format_mode" value="preset" class="conditional-trigger">
Preset format
</label>
<label>
<input type="radio" name="format_mode" value="custom" class="conditional-trigger">
Custom format
</label>
</div>
<div class="conditional-content format-preset">
<div class="form-row">
<div class="form-group">
<label for="format">Format</label>
<select name="format">
<option value="best" selected>best</option>
<option value="worst">worst</option>
<option value="bestvideo+bestaudio">bestvideo+bestaudio</option>
<option value="bestvideo">bestvideo</option>
<option value="bestaudio">bestaudio</option>
</select>
</div>
<div class="form-group">
<label for="quality">Max Quality</label>
<select name="quality">
<option value="" selected>No limit</option>
<option value="720">720p</option>
<option value="1080">1080p</option>
<option value="1440">1440p</option>
<option value="2160">4K (2160p)</option>
</select>
</div>
</div>
</div>
<div class="conditional-content format-custom">
<div class="form-group">
<label for="custom_format">Custom Format</label>
<input type="text" name="custom_format" placeholder="e.g., bestvideo+bestaudio/best or format ID like 22">
<small>Overrides the format selection. Use yt-dlp format syntax.</small>
</div>
</div>
</div>
<div class="conditional-section">
<div class="form-group">
<label>
<input type="checkbox" name="extract_audio" value="1" class="conditional-trigger">
Extract audio only
</label>
</div>
<div class="conditional-content">
<div class="form-group">
<label>Audio Format</label>
<div class="radio-group">
<label><input type="radio" name="audio_format" value="mp3"> MP3</label>
<label><input type="radio" name="audio_format" value="flac"> FLAC</label>
<label><input type="radio" name="audio_format" value="wav"> WAV</label>
<label><input type="radio" name="audio_format" value="opus"> Opus</label>
<label><input type="radio" name="audio_format" value="aac"> AAC</label>
<label><input type="radio" name="audio_format" value="" checked> Default (no conversion)</label>
</div>
</div>
</div>
</div>
<div class="conditional-section">
<div class="form-group">
<label>
<input type="checkbox" name="embed_subs" value="1" class="conditional-trigger">
Embed subtitles
</label>
</div>
<div class="conditional-content">
<div class="form-group">
<label for="sub_langs">Subtitle Languages</label>
<input type="text" name="sub_langs" placeholder="e.g., en,de,fr or all" value="en">
<small>Comma-separated language codes or "all"</small>
</div>
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="embed_thumbnail" value="1" checked>
Embed thumbnail
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="embed_metadata" value="1" checked>
Embed metadata
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="write_info_json" value="1" checked>
Write info JSON
</label>
</div>
<div class="form-group">
<label for="custom_flags">Custom Flags</label>
<textarea name="custom_flags" rows="2" placeholder="Any additional yt-dlp flags..."></textarea>
{{template "ytdlp_notes" false}}
</div>
{{end}}
Mweb/templates/subscriptions.html
@@ -8,7 +8,6 @@
<h3>Your Subscriptions</h3>
<div class="subscriptions-list">
{{range .Data.Subscriptions}}
{{$sub := .}}
<article>
<div class="preset-header">
<h4>
@@ -49,73 +48,7 @@
<details>
<summary role="button" class="small"><span class="edit-text">Edit</span><span class="cancel-text">Cancel</span></summary>
<form method="post" action="/subscriptions/{{.ID}}" class="edit-form">
<div class="form-group">
<label>Name *</label>
<input type="text" name="name" required value="{{$sub.Name}}">
</div>
<div class="form-group">
<label>URL *</label>
<input type="text" name="url" required value="{{$sub.URL}}">
</div>
<div class="form-group">
<label>Library folder *</label>
<input type="text" name="output_dir" required value="{{$sub.OutputDir}}">
<small>This subscription owns this folder — don't download into it manually. May be nested, e.g. <code>subscriptions/news</code>.</small>
</div>
<div class="form-group">
<label>Refresh mode</label>
<div class="radio-group">
<label><input type="radio" name="refresh_mode" value="overwrite" {{if eq $sub.RefreshMode "overwrite"}}checked{{end}}> Overwrite (re-download & replace)</label>
<label><input type="radio" name="refresh_mode" value="skip" {{if eq $sub.RefreshMode "skip"}}checked{{end}}> Skip existing (add new only)</label>
<label><input type="radio" name="refresh_mode" value="metadata" {{if eq $sub.RefreshMode "metadata"}}checked{{end}}> Metadata refresh (update existing, add new)</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Schedule</label>
<select name="schedule_kind">
<option value="hourly" {{if eq $sub.ScheduleKind "hourly"}}selected{{end}}>Hourly</option>
<option value="daily" {{if eq $sub.ScheduleKind "daily"}}selected{{end}}>Daily</option>
<option value="weekly" {{if eq $sub.ScheduleKind "weekly"}}selected{{end}}>Weekly</option>
<option value="monthly" {{if eq $sub.ScheduleKind "monthly"}}selected{{end}}>Monthly</option>
<option value="cron" {{if eq $sub.ScheduleKind "cron"}}selected{{end}}>Custom (cron)</option>
</select>
</div>
<div class="form-group">
<label>Cron expression</label>
<input type="text" name="cron_expr" value="{{$sub.CronExpr}}" placeholder="0 3 * * *">
<small>Used when schedule is "Custom". Standard 5-field cron.</small>
</div>
</div>
<div class="form-group">
<label>Preset</label>
<select name="preset_id">
<option value="">Default preset</option>
{{range $.Data.Presets}}
<option value="{{.ID}}" {{if and $sub.PresetID.Valid (eq $sub.PresetID.Int64 .ID)}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="form-group">
<label>Format override</label>
<input type="text" name="format_override" value="{{$sub.FormatOverride}}" placeholder="e.g., bestvideo+bestaudio/best or format ID like 22">
</div>
<div class="form-group">
<label>Custom flags</label>
<textarea name="custom_flags" rows="2">{{$sub.CustomFlags}}</textarea>
{{template "ytdlp_notes" true}}
</div>
<div class="form-group">
<label>
<input type="checkbox" name="prune_removed" value="1" {{if $sub.PruneRemoved}}checked{{end}}>
Delete items removed from the source (prune)
</label>
</div>
{{template "subscription_form" (dict "Sub" . "Presets" $.Data.Presets)}}
<div class="form-actions">
<button type="submit" class="primary">Update Subscription</button>
</div>
@@ -132,73 +65,7 @@
<article>
<h3>Create Subscription</h3>
<form method="post" action="/subscriptions">
<div class="form-group">
<label>Name *</label>
<input type="text" name="name" required placeholder="A name for this subscription">
</div>
<div class="form-group">
<label>URL *</label>
<input type="text" name="url" required placeholder="https://youtube.com/watch?v=...">
</div>
<div class="form-group">
<label>Library folder *</label>
<input type="text" name="output_dir" required placeholder="e.g., videos/music (relative to library folder)">
<small>This subscription owns this folder — don't download into it manually. May be nested.</small>
</div>
<div class="form-group">
<label>Refresh mode</label>
<div class="radio-group">
<label><input type="radio" name="refresh_mode" value="overwrite" checked> Overwrite (re-download & replace)</label>
<label><input type="radio" name="refresh_mode" value="skip"> Skip existing (add new only)</label>
<label><input type="radio" name="refresh_mode" value="metadata"> Metadata refresh (update existing, add new)</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Schedule</label>
<select name="schedule_kind">
<option value="hourly">Hourly</option>
<option value="daily" selected>Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="cron">Custom (cron)</option>
</select>
</div>
<div class="form-group">
<label>Cron expression</label>
<input type="text" name="cron_expr" placeholder="0 3 * * *">
<small>Used when schedule is "Custom". Standard 5-field cron.</small>
</div>
</div>
<div class="form-group">
<label>Preset</label>
<select name="preset_id">
<option value="">Default preset</option>
{{range .Data.Presets}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
<div class="form-group">
<label>Format override</label>
<input type="text" name="format_override" placeholder="e.g., bestvideo+bestaudio/best or format ID like 22">
</div>
<div class="form-group">
<label>Custom flags</label>
<textarea name="custom_flags" rows="2" placeholder="Any additional yt-dlp flags..."></textarea>
{{template "ytdlp_notes" true}}
</div>
<div class="form-group">
<label>
<input type="checkbox" name="prune_removed" value="1">
Delete items removed from the source (prune)
</label>
</div>
{{template "subscription_form" (dict "Sub" newSubscription "Presets" .Data.Presets)}}
<div class="form-actions">
<button type="submit" class="primary">Create Subscription</button>
</div>
@@ -206,3 +73,75 @@
</article>
</div>
{{end}}
{{/* subscription_form renders the create/edit fields. Data: {Sub *Subscription, Presets []*Preset}.
Sub is always non-nil — newSubscription supplies create-time defaults. */}}
{{define "subscription_form"}}
{{$sub := .Sub}}
<div class="form-group">
<label>Name *</label>
<input type="text" name="name" required value="{{$sub.Name}}" placeholder="A name for this subscription">
</div>
<div class="form-group">
<label>URL *</label>
<input type="text" name="url" required value="{{$sub.URL}}" placeholder="https://youtube.com/watch?v=...">
</div>
<div class="form-group">
<label>Library folder *</label>
<input type="text" name="output_dir" required value="{{$sub.OutputDir}}" placeholder="e.g., videos/music (relative to library folder)">
<small>This subscription owns this folder — don't download into it manually. May be nested, e.g. <code>subscriptions/news</code>.</small>
</div>
<div class="form-group">
<label>Refresh mode</label>
<div class="radio-group">
<label><input type="radio" name="refresh_mode" value="overwrite" {{if eq $sub.RefreshMode "overwrite"}}checked{{end}}> Overwrite (re-download & replace)</label>
<label><input type="radio" name="refresh_mode" value="skip" {{if eq $sub.RefreshMode "skip"}}checked{{end}}> Skip existing (add new only)</label>
<label><input type="radio" name="refresh_mode" value="metadata" {{if eq $sub.RefreshMode "metadata"}}checked{{end}}> Metadata refresh (update existing, add new)</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Schedule</label>
<select name="schedule_kind">
<option value="hourly" {{if eq $sub.ScheduleKind "hourly"}}selected{{end}}>Hourly</option>
<option value="daily" {{if eq $sub.ScheduleKind "daily"}}selected{{end}}>Daily</option>
<option value="weekly" {{if eq $sub.ScheduleKind "weekly"}}selected{{end}}>Weekly</option>
<option value="monthly" {{if eq $sub.ScheduleKind "monthly"}}selected{{end}}>Monthly</option>
<option value="cron" {{if eq $sub.ScheduleKind "cron"}}selected{{end}}>Custom (cron)</option>
</select>
</div>
<div class="form-group">
<label>Cron expression</label>
<input type="text" name="cron_expr" value="{{$sub.CronExpr}}" placeholder="0 3 * * *">
<small>Used when schedule is "Custom". Standard 5-field cron.</small>
</div>
</div>
<div class="form-group">
<label>Preset</label>
<select name="preset_id">
<option value="">Default preset</option>
{{range .Presets}}
<option value="{{.ID}}" {{if and $sub.PresetID.Valid (eq $sub.PresetID.Int64 .ID)}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="form-group">
<label>Format override</label>
<input type="text" name="format_override" value="{{$sub.FormatOverride}}" placeholder="e.g., bestvideo+bestaudio/best or format ID like 22">
</div>
<div class="form-group">
<label>Custom flags</label>
<textarea name="custom_flags" rows="2" placeholder="Any additional yt-dlp flags...">{{$sub.CustomFlags}}</textarea>
{{template "ytdlp_notes" true}}
</div>
<div class="form-group">
<label>
<input type="checkbox" name="prune_removed" value="1" {{if $sub.PruneRemoved}}checked{{end}}>
Delete items removed from the source (prune)
</label>
</div>
{{end}}