package handler import ( "database/sql" "log" "net/http" "strconv" "strings" "time" "vidarchive/internal/models" ) func (h *Handler) Subscriptions(w http.ResponseWriter, r *http.Request) { subscriptions, err := h.subscriptionSvc.GetAll() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } presets, err := h.presetSvc.GetAll() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } h.renderWithRequest(w, r, "subscriptions", PageData{ Title: "Subscriptions", ActiveTab: "subscriptions", Data: struct { Subscriptions []*models.Subscription Presets []*models.Preset }{ Subscriptions: subscriptions, Presets: presets, }, }) } // subscriptionFromForm builds and validates a Subscription from form values, // shared by create and update. It resolves/validates the schedule into a canonical // cron expression and requires an output directory (the subscription owns it). func (h *Handler) subscriptionFromForm(r *http.Request) (*models.Subscription, error) { name := strings.TrimSpace(r.FormValue("name")) url := strings.TrimSpace(r.FormValue("url")) if name == "" { return nil, errBadRequest("a name is required") } if url == "" { return nil, errBadRequest("URL is required") } outputDir := strings.TrimSpace(r.FormValue("output_dir")) if outputDir == "" { return nil, errBadRequest("an output directory is required — the subscription owns this folder") } refreshMode := r.FormValue("refresh_mode") switch refreshMode { case "overwrite", "skip", "metadata": default: refreshMode = "overwrite" } scheduleKind := r.FormValue("schedule_kind") cronExpr, err := h.subscriptionSvc.CronExprFor(scheduleKind, strings.TrimSpace(r.FormValue("cron_expr"))) if err != nil { return nil, errBadRequest(err.Error()) } sub := &models.Subscription{ Name: name, URL: url, Enabled: true, RefreshMode: refreshMode, ScheduleKind: scheduleKind, CronExpr: cronExpr, FormatOverride: r.FormValue("format_override"), CustomFlags: r.FormValue("custom_flags"), OutputDir: outputDir, PruneRemoved: r.FormValue("prune_removed") == "1", } if pid := r.FormValue("preset_id"); pid != "" { if id, err := strconv.ParseInt(pid, 10, 64); err == nil { sub.PresetID = sql.NullInt64{Int64: id, Valid: true} } } return sub, nil } func (h *Handler) CreateSubscription(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } sub, err := h.subscriptionFromForm(r) if err != nil { redirectWithError(w, r, "/subscriptions", err.Error(), nil) return } if next, err := h.subscriptionSvc.ComputeNextRun(sub, time.Now()); err == nil { sub.NextRunAt = sql.NullTime{Time: next, Valid: true} } if err := h.subscriptionSvc.Create(sub); err != nil { redirectWithError(w, r, "/subscriptions", "Couldn't create this subscription.", err) return } redirectWithSuccess(w, r, "/subscriptions", "Subscription created.") } func (h *Handler) UpdateSubscription(w http.ResponseWriter, r *http.Request) { id, ok := parseID(w, r) if !ok { return } if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } existing, err := h.subscriptionSvc.GetByID(id) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } sub, err := h.subscriptionFromForm(r) if err != nil { redirectWithError(w, r, "/subscriptions", err.Error(), nil) return } sub.ID = id sub.Enabled = existing.Enabled // Recompute the next run from the (possibly changed) schedule. if next, err := h.subscriptionSvc.ComputeNextRun(sub, time.Now()); err == nil { sub.NextRunAt = sql.NullTime{Time: next, Valid: true} } if err := h.subscriptionSvc.Update(sub); err != nil { redirectWithError(w, r, "/subscriptions", "Couldn't update this subscription.", err) return } redirectWithSuccess(w, r, "/subscriptions", "Subscription updated.") } func (h *Handler) ToggleSubscription(w http.ResponseWriter, r *http.Request) { id, ok := parseID(w, r) if !ok { return } sub, err := h.subscriptionSvc.GetByID(id) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } if err := h.subscriptionSvc.SetEnabled(id, !sub.Enabled); err != nil { redirectWithError(w, r, "/subscriptions", "Couldn't change this subscription.", err) return } message := "Subscription resumed." if sub.Enabled { message = "Subscription paused." } redirectWithSuccess(w, r, "/subscriptions", message) } func (h *Handler) RunSubscription(w http.ResponseWriter, r *http.Request) { id, ok := parseID(w, r) if !ok { return } sub, err := h.subscriptionSvc.GetByID(id) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } // Refuse a second run while one is still in flight, the same guard the // scheduler applies. Two runs of one subscription share an output directory, // so in overwrite mode they race: one deletes the item the other just wrote. active, err := h.downloadSvc.HasActiveForSubscription(id) if err != nil { redirectWithError(w, r, "/subscriptions", "Couldn't start this subscription run.", err) return } if active { redirectWithError(w, r, "/subscriptions", "This subscription already has a run in progress.", nil) return } download, err := h.downloadSvc.CreateForSubscription(sub) if err != nil { redirectWithError(w, r, "/subscriptions", "Couldn't start this subscription run.", err) return } h.workerPool.Submit(download) // Record the manual run so the subscriptions page shows it. The schedule's // next run time is deliberately left alone. if err := h.subscriptionSvc.MarkManualRun(id, time.Now(), "queued"); err != nil { log.Printf("subscription %d: failed to record manual run: %v", id, err) } redirectWithSuccess(w, r, "/queue", "Subscription run queued.") } func (h *Handler) DeleteSubscription(w http.ResponseWriter, r *http.Request) { id, ok := parseID(w, r) if !ok { return } if err := h.subscriptionSvc.Delete(id); err != nil { redirectWithError(w, r, "/subscriptions", "Couldn't delete this subscription.", err) return } redirectWithSuccess(w, r, "/subscriptions", "Subscription deleted.") } // errBadRequest is a small sentinel-style error carrying a user-facing message. type errBadRequest string func (e errBadRequest) Error() string { return string(e) }