From 7d305e6ae02824de7fd2ce0b75c72f492a264a5c Mon Sep 17 00:00:00 2001 From: Piotr Komborski <163394+combor@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:04:01 +0100 Subject: [PATCH 1/2] Name the field an override is refused for InvalidError gains a Field: titles, id, seasons, episodes, or a film's title or year, and none if the override as a whole is at fault. Reason gives the message without the field's name. The web interface's forms use both to show a refusal beside the input it is about. The API's messages are unchanged. --- internal/overrides/check.go | 30 +++++++++++++------------- internal/overrides/overrides.go | 14 +++++++++--- internal/overrides/overrides_test.go | 32 ++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/internal/overrides/check.go b/internal/overrides/check.go index 18a460f..57b0321 100644 --- a/internal/overrides/check.go +++ b/internal/overrides/check.go @@ -13,7 +13,7 @@ import ( // sorted, and site IDs taken from URLs. func checkSeries(tvdbID int, o provider.SeriesOverride, site provider.Overridable) (provider.SeriesOverride, error) { if tvdbID <= 0 { - return provider.SeriesOverride{}, invalid("TVDB ID %d is not positive", tvdbID) + return provider.SeriesOverride{}, invalid("", "TVDB ID %d is not positive", tvdbID) } var out provider.SeriesOverride var err error @@ -22,18 +22,18 @@ func checkSeries(tvdbID int, o provider.SeriesOverride, site provider.Overridabl } if o.ID != "" { if out.ID, err = site.ParseID(o.ID); err != nil { - return provider.SeriesOverride{}, invalid("id: %v", err) + return provider.SeriesOverride{}, invalid("id", "id: %v", err) } } seen := make(map[int]bool) for _, r := range o.Seasons { switch { case r.Season < 1: - return provider.SeriesOverride{}, invalid("seasons: season %d is not a numbered TVDB season", r.Season) + return provider.SeriesOverride{}, invalid("seasons", "seasons: season %d is not a numbered TVDB season", r.Season) case r.SiteSeason < 0: - return provider.SeriesOverride{}, invalid("seasons: site_season %d is negative", r.SiteSeason) + return provider.SeriesOverride{}, invalid("seasons", "seasons: site_season %d is negative", r.SiteSeason) case seen[r.Season]: - return provider.SeriesOverride{}, invalid("seasons: season %d has two rules", r.Season) + return provider.SeriesOverride{}, invalid("seasons", "seasons: season %d has two rules", r.Season) } seen[r.Season] = true } @@ -49,21 +49,21 @@ func checkSeries(tvdbID int, o provider.SeriesOverride, site provider.Overridabl for _, n := range numbers { // Season 0 holds specials. if n.Season < 0 || n.Episode < 1 { - return provider.SeriesOverride{}, invalid("episodes: %s is not a numbered TVDB episode", n) + return provider.SeriesOverride{}, invalid("episodes", "episodes: %s is not a numbered TVDB episode", n) } id, err := site.ParseID(o.Episodes[n]) if err != nil { - return provider.SeriesOverride{}, invalid("episodes: %s: %v", n, err) + return provider.SeriesOverride{}, invalid("episodes", "episodes: %s: %v", n, err) } if other, dup := pinned[id]; dup { - return provider.SeriesOverride{}, invalid("episodes: %s and %s both name %s", other, n, id) + return provider.SeriesOverride{}, invalid("episodes", "episodes: %s and %s both name %s", other, n, id) } pinned[id] = n out.Episodes[n] = id } } if len(out.Titles) == 0 && out.ID == "" && len(out.Seasons) == 0 && len(out.Episodes) == 0 { - return provider.SeriesOverride{}, invalid("an override needs titles, an id, seasons or episodes; DELETE removes one") + return provider.SeriesOverride{}, invalid("", "an override needs titles, an id, seasons or episodes; DELETE removes one") } return out, nil } @@ -73,11 +73,11 @@ func checkFilm(title string, year int, o provider.FilmOverride, site provider.Ov title = strings.TrimSpace(title) switch { case provider.NormalizeTitle(title) == "": - return provider.FilmOverride{}, invalid("film title %q has no letters or digits", title) + return provider.FilmOverride{}, invalid("title", "film title %q has no letters or digits", title) case year <= 0: - return provider.FilmOverride{}, invalid("year %d is not positive", year) + return provider.FilmOverride{}, invalid("year", "year %d is not positive", year) case (o.Title != "" || o.Year != 0) && provider.FilmKey(o.Title, o.Year) != provider.FilmKey(title, year): - return provider.FilmOverride{}, invalid("the override is for %q (%d), not %q (%d)", o.Title, o.Year, title, year) + return provider.FilmOverride{}, invalid("", "the override is for %q (%d), not %q (%d)", o.Title, o.Year, title, year) } out := provider.FilmOverride{Title: title, Year: year} var err error @@ -86,11 +86,11 @@ func checkFilm(title string, year int, o provider.FilmOverride, site provider.Ov } if o.ID != "" { if out.ID, err = site.ParseID(o.ID); err != nil { - return provider.FilmOverride{}, invalid("id: %v", err) + return provider.FilmOverride{}, invalid("id", "id: %v", err) } } if len(out.Titles) == 0 && out.ID == "" { - return provider.FilmOverride{}, invalid("an override needs titles or an id; DELETE removes one") + return provider.FilmOverride{}, invalid("", "an override needs titles or an id; DELETE removes one") } return out, nil } @@ -102,7 +102,7 @@ func checkTitles(titles []string) ([]string, error) { t = strings.TrimSpace(t) n := provider.NormalizeTitle(t) if n == "" { - return nil, invalid("titles: %q has no letters or digits", t) + return nil, invalid("titles", "titles: %q has no letters or digits", t) } if !seen[n] { seen[n] = true diff --git a/internal/overrides/overrides.go b/internal/overrides/overrides.go index ead51c7..8c7a593 100644 --- a/internal/overrides/overrides.go +++ b/internal/overrides/overrides.go @@ -23,12 +23,20 @@ var bucket = []byte("overrides") var ErrUnknownSite = errors.New("no such site takes overrides") // InvalidError reports an override the store refuses. -type InvalidError struct{ msg string } +type InvalidError struct { + // Field is the part at fault: titles, id, seasons, episodes, or a film's + // title or year. It is empty if the override as a whole is. + Field string + msg string +} func (e *InvalidError) Error() string { return e.msg } -func invalid(format string, args ...any) error { - return &InvalidError{fmt.Sprintf(format, args...)} +// Reason is the error without the field's name, for showing beside the field. +func (e *InvalidError) Reason() string { return strings.TrimPrefix(e.msg, e.Field+": ") } + +func invalid(field, format string, args ...any) error { + return &InvalidError{field, fmt.Sprintf(format, args...)} } // Store is safe for concurrent use. diff --git a/internal/overrides/overrides_test.go b/internal/overrides/overrides_test.go index f16eea4..df9d6b4 100644 --- a/internal/overrides/overrides_test.go +++ b/internal/overrides/overrides_test.go @@ -211,3 +211,35 @@ func TestCheckFilm(t *testing.T) { } } } + +func TestInvalidErrorField(t *testing.T) { + series := func(o provider.SeriesOverride) error { + _, err := checkSeries(1, o, &site{}) + return err + } + film := func(title string, year int) error { + _, err := checkFilm(title, year, provider.FilmOverride{ID: "abc"}, &site{}) + return err + } + for _, tc := range []struct { + err error + field, reason string + }{ + {series(provider.SeriesOverride{}), "", "an override needs titles, an id, seasons or episodes; DELETE removes one"}, + {series(provider.SeriesOverride{Titles: []string{"-"}}), "titles", `"-" has no letters or digits`}, + {series(provider.SeriesOverride{ID: "1"}), "id", "not an ID"}, + {series(provider.SeriesOverride{Seasons: []provider.SeasonRule{{Season: 1}, {Season: 1}}}), "seasons", "season 1 has two rules"}, + {series(provider.SeriesOverride{Episodes: map[provider.EpisodeNumber]string{{Season: 1, Episode: 1}: "1"}}), "episodes", "S01E01: not an ID"}, + {film("-", 1984), "title", `film title "-" has no letters or digits`}, + {film("Cube", 0), "year", "year 0 is not positive"}, + } { + var inv *InvalidError + if !errors.As(tc.err, &inv) { + t.Errorf("%v is not an InvalidError", tc.err) + continue + } + if inv.Field != tc.field || inv.Reason() != tc.reason { + t.Errorf("%v: field %q, reason %q; want %q, %q", tc.err, inv.Field, inv.Reason(), tc.field, tc.reason) + } + } +} From e29b5bfcc9bfd80747ea3aea4c00e80eede032cc Mon Sep 17 00:00:00 2001 From: Piotr Komborski <163394+combor@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:04:02 +0100 Subject: [PATCH 2/2] Manage overrides in the web interface A new Overrides page lists each site's overrides, with forms to add, change and remove them for series and films. A series' form has its titles to search, the site's ID or page address, season rules and pinned episodes; a film's has Radarr's title and year, titles and the ID. Titles, rules and pins are rows of inputs. Their Add and Remove buttons submit the form, which comes back changed but unsaved, so no script is needed. A hidden first submit button keeps Enter saving rather than adding a row. A form that can't be saved comes back with status 422 and each message beside its field, and focus goes to the first one. The form checks what the store can't see: numbers that aren't numbers, half-filled rows, an episode pinned twice. The store's refusals follow. Adding an override for a series or film that has one is refused with a link to it, where the API's PUT would replace it. htmx focuses the first autofocus input after every swap, so a form's page refreshes only its header, from the new /ui/topbar, and leaves what is typed alone. Saving returns to the list at the saved row, which is highlighted. --- README.md | 4 +- cmd/magnetowid/main.go | 2 +- docs/usage.md | 17 +- internal/web/overrides.go | 788 ++++++++++++++++++++++++++ internal/web/overrides_test.go | 456 +++++++++++++++ internal/web/static/style.css | 230 ++++++++ internal/web/templates/app.html | 45 +- internal/web/templates/film.html | 41 ++ internal/web/templates/override.html | 97 ++++ internal/web/templates/overrides.html | 55 ++ internal/web/templates/series.html | 94 +++ internal/web/view.go | 45 +- internal/web/web.go | 59 +- internal/web/web_test.go | 22 +- 14 files changed, 1901 insertions(+), 54 deletions(-) create mode 100644 internal/web/overrides.go create mode 100644 internal/web/overrides_test.go create mode 100644 internal/web/templates/film.html create mode 100644 internal/web/templates/override.html create mode 100644 internal/web/templates/overrides.html create mode 100644 internal/web/templates/series.html diff --git a/README.md b/README.md index be96d41..116621c 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,8 @@ for container hostnames, volume mounts and Remote Path Mappings. ## Web interface Open `http://:8484/` and sign in with the API key to see the -queue and history, and to pause, resume or remove downloads. See -[Web interface](docs/usage.md#web-interface). +queue and history, to pause, resume or remove downloads, and to correct matches +with overrides. See [Web interface](docs/usage.md#web-interface). ## Help and contributing diff --git a/cmd/magnetowid/main.go b/cmd/magnetowid/main.go index 4683b2a..facc578 100644 --- a/cmd/magnetowid/main.go +++ b/cmd/magnetowid/main.go @@ -115,7 +115,7 @@ func run(log *slog.Logger, level *slog.LevelVar) error { mux.Handle("/{provider}/api", &newznab.Handler{Providers: providers, APIKey: *apiKey, Probe: prober, Log: log}) mux.Handle("/api", &sabnzbd.Handler{Queue: queue, APIKey: *apiKey, Categories: cats, Log: log}) (&overrides.Handler{Store: overrideStore, APIKey: *apiKey, Log: log}).Register(mux) - (&web.Handler{Queue: queue, APIKey: *apiKey, Version: version, Log: log}).Register(mux) + (&web.Handler{Queue: queue, Overrides: overrideStore, APIKey: *apiKey, Version: version, Log: log}).Register(mux) mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { io.WriteString(w, "OK\n") }) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/docs/usage.md b/docs/usage.md index 783fc6b..d6cb3a4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -177,16 +177,22 @@ every five seconds. Downloads stay for 30 days unless Sonarr or Radarr removes them first. Removing one can also delete its folder; Sonarr and Radarr cannot import a download removed from history. +**Overrides** lists each site's [overrides](#correcting-matches), with forms to +add, change and remove them for series and films. A form that can't be saved +says why beside the field at fault. + Signing in lasts 30 days. Changing `MAGNETOWID_API_KEY` signs every browser out. Search for titles in Sonarr/Radarr. ## Correcting matches If magnetowid misses a series or film, or pairs the wrong episodes, add an -override. The overrides API takes the same API key, in an `X-Api-Key` header -or an `apikey` parameter. Changes apply to the next search, and RSS feeds -rebuild with them at the next sync. Overrides are saved in -`.magnetowid-jobs.db`. +override on the [web interface](#web-interface)'s **Overrides** page or through +the overrides API. Changes apply to the next search, and RSS feeds rebuild with +them at the next sync. Overrides are saved in `.magnetowid-jobs.db`. + +The API takes the same API key, in an `X-Api-Key` header or an `apikey` +parameter. | Request | Purpose | |---|---| @@ -208,6 +214,9 @@ A series override has one or more of: | `seasons` | Rules placing TVDB seasons in the site's numbering: TVDB's episode *n* is the site's episode *n* + `offset` in season `site_season`. `site_season` 0 accepts any season, if only one has that number. | | `episodes` | Single TVDB episodes, such as `S01E05` or the special `S00E01`, each with the site's episode ID. These win over `seasons`, and no other episode matches a pinned one. | +The Overrides page calls these *Titles to search*, *ID or page address*, +*Season rules* and *Pinned episodes*. + A film override has `titles` to search instead of Radarr's, an `id`, or both. The site's film with that `id`, found by searching the titles, is used even if its title or year differs from Radarr's, and no other film matches it. diff --git a/internal/web/overrides.go b/internal/web/overrides.go new file mode 100644 index 0000000..1f009e6 --- /dev/null +++ b/internal/web/overrides.go @@ -0,0 +1,788 @@ +package web + +import ( + "cmp" + "errors" + "fmt" + "hash/fnv" + "html/template" + "maps" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/combor/magnetowid/internal/overrides" + "github.com/combor/magnetowid/internal/provider" +) + +// overridesView is everything the overrides page shows. +type overridesView struct { + chrome + Sites []siteView +} + +func (v overridesView) Title() string { return "Overrides — magnetowid" } + +// siteView is a site that takes overrides, with any it has. +type siteView struct { + Name string + Rows []overrideRow // series, then films +} + +type overrideRow struct { + ID string // of the row's element + Film bool + Title string + Year string // a film's + Facts []string + URL string // the override's form +} + +// Label is the title and year, for controls. +func (r overrideRow) Label() string { return strings.TrimSpace(r.Title + " " + r.Year) } + +func newOverridesView(store *overrides.Store, c chrome) overridesView { + v := overridesView{chrome: c} + for _, site := range store.Sites() { + v.Sites = append(v.Sites, newSiteView(site, store.Site(site))) + } + return v +} + +// newSiteView accepts a nil o. +func newSiteView(site string, o *provider.Overrides) siteView { + v := siteView{Name: site} + if o == nil { + return v + } + for id, s := range o.Series { + row := overrideRow{ID: seriesRowID(site, id), Title: seriesName(id, s), URL: seriesURL(site, id)} + if len(s.Titles) > 0 { + row.Facts = append(row.Facts, "TVDB "+strconv.Itoa(id)) + } + if len(s.Titles) > 1 { + row.Facts = append(row.Facts, fmt.Sprintf("%d titles", len(s.Titles))) + } + if s.ID != "" { + row.Facts = append(row.Facts, "ID "+s.ID) + } + if len(s.Seasons) > 3 { + row.Facts = append(row.Facts, fmt.Sprintf("%d season rules", len(s.Seasons))) + } else { + for _, r := range s.Seasons { + row.Facts = append(row.Facts, ruleText(r)) + } + } + switch n := len(s.Episodes); n { + case 0: + case 1: + row.Facts = append(row.Facts, "1 pinned episode") + default: + row.Facts = append(row.Facts, fmt.Sprintf("%d pinned episodes", n)) + } + v.Rows = append(v.Rows, row) + } + slices.SortFunc(v.Rows, func(a, b overrideRow) int { + return cmp.Or(cmp.Compare(strings.ToLower(a.Title), strings.ToLower(b.Title)), cmp.Compare(a.ID, b.ID)) + }) + for _, key := range slices.Sorted(maps.Keys(o.Films)) { + f := o.Films[key] + row := overrideRow{ID: filmRowID(site, f.Title, f.Year), Film: true, Title: f.Title, Year: strconv.Itoa(f.Year), + URL: filmURL(site, f.Title, f.Year)} + switch n := len(f.Titles); n { + case 0: + case 1: + row.Facts = append(row.Facts, "searched as “"+f.Titles[0]+"”") + default: + row.Facts = append(row.Facts, fmt.Sprintf("searched under %d titles", n)) + } + if f.ID != "" { + row.Facts = append(row.Facts, "ID "+f.ID) + } + v.Rows = append(v.Rows, row) + } + return v +} + +// seriesName is the first title the override searches: overrides don't keep +// the series' own. +func seriesName(tvdbID int, o provider.SeriesOverride) string { + if len(o.Titles) > 0 { + return o.Titles[0] + } + return "TVDB " + strconv.Itoa(tvdbID) +} + +func ruleText(r provider.SeasonRule) string { + site := "any season" + if r.SiteSeason > 0 { + site = "S" + strconv.Itoa(r.SiteSeason) + } + s := fmt.Sprintf("S%d → %s", r.Season, site) + if r.Offset != 0 { + s += fmt.Sprintf(", episodes %+d", r.Offset) + } + return s +} + +func seriesURL(site string, tvdbID int) string { + return "/ui/overrides/" + url.PathEscape(site) + "/series/" + strconv.Itoa(tvdbID) +} + +func filmURL(site, title string, year int) string { + return "/ui/overrides/" + url.PathEscape(site) + "/films/" + strconv.Itoa(year) + "/" + url.PathEscape(title) +} + +func seriesRowID(site string, tvdbID int) string { return site + "-series-" + strconv.Itoa(tvdbID) } + +// Film titles can have any letters; element IDs here are also sent in +// headers, which can't. +func filmRowID(site, title string, year int) string { + h := fnv.New32a() + h.Write([]byte(provider.FilmKey(title, year))) + return fmt.Sprintf("%s-film-%08x", site, h.Sum32()) +} + +// overrideForm is what the series and film forms share. Their repeated +// inputs are title; season, site_season and offset; and ep_season, +// ep_episode and ep_id. Rows left blank are ignored. +type overrideForm struct { + chrome + Site string + New bool + Name string // the override's, once saved + Action string // where the form posts + Titles []titleRow + ID string + Errors map[string]string // by the override's field; "" is the whole form's + Exists string // the form of the override a new one would replace + Focus string // the ID of the input to focus + rows int // rows with errors of their own +} + +// formView is a series' or a film's form. +type formView interface { + base() *overrideForm + prepare() +} + +func (f *overrideForm) base() *overrideForm { return f } + +type titleRow struct { + N int // from 1 + Value string + Focus bool +} + +// The inputs that show an override's fields, for focusing the first at fault. +var fieldInputs = map[string]string{ + "tvdbid": "tvdbid", "title": "name", "year": "year", + "titles": "title-1", "id": "id", "seasons": "season-1", "episodes": "ep-1", +} + +// invalid reports whether the form can't be saved as it is. +func (f *overrideForm) invalid() bool { return len(f.Errors) > 0 || f.rows > 0 } + +// Summary tells that the form was not saved. +func (f *overrideForm) Summary() string { + switch { + case f.Errors[""] != "": + return f.Errors[""] + case f.invalid(): + return "Fix the fields marked below, then save again." + } + return "" +} + +func (f *overrideForm) read(r *http.Request) { + for _, t := range r.PostForm["title"] { + f.Titles = append(f.Titles, titleRow{Value: t}) + } + f.ID = strings.TrimSpace(r.PostFormValue("id")) +} + +func (f *overrideForm) fill(titles []string, id string) { + for _, t := range titles { + f.Titles = append(f.Titles, titleRow{Value: t}) + } + f.ID = id +} + +func (f *overrideForm) titles() []string { + var out []string + for _, t := range f.Titles { + if v := strings.TrimSpace(t.Value); v != "" { + out = append(out, v) + } + } + return out +} + +// fail marks field with why the form can't be saved. +func (f *overrideForm) fail(field, why string) { + if f.Errors == nil { + f.Errors = make(map[string]string) + } + if _, dup := f.Errors[field]; !dup { + f.Errors[field] = why + } + f.focus(fieldInputs[field]) +} + +// failRow counts a row marked with its own error. +func (f *overrideForm) failRow(input string) { + f.rows++ + f.focus(input) +} + +// focus keeps the first input named: the form's first problem. +func (f *overrideForm) focus(input string) { + if f.Focus == "" { + f.Focus = input + } +} + +func (f *overrideForm) prepare() { + if len(f.Titles) == 0 { + f.Titles = []titleRow{{}} + } + for i := range f.Titles { + f.Titles[i].N = i + 1 + f.Titles[i].Focus = f.Focus == "title-"+strconv.Itoa(i+1) + } +} + +// sentence makes a page's sentence of an error message. +func sentence(s string) string { + r, n := utf8.DecodeRuneInString(s) + s = string(unicode.ToUpper(r)) + s[n:] + if !strings.HasSuffix(s, ".") { + s += "." + } + return s +} + +// editRows adds a blank row to rows, or removes row i if there is one. +func editRows[T any](rows []T, i int) []T { + switch { + case i < 0: + var blank T + return append(rows, blank) + case i < len(rows): + return slices.Delete(rows, i, i+1) + } + return rows +} + +// rowEdit reports the list whose Add button submitted the form, with i below +// 0, or whose row i's Remove button did. ok is false if neither did. +func rowEdit(r *http.Request) (list string, i int, ok bool) { + if list = r.PostFormValue("add"); list != "" { + return list, -1, true + } + list, n, _ := strings.Cut(r.PostFormValue("remove"), ".") + i, err := strconv.Atoi(n) + if err != nil || i < 1 { + return "", 0, false + } + return list, i - 1, true +} + +// editTitles reports whether it applied a row edit to the titles. +func (f *overrideForm) editTitles(list string, i int) bool { + if list != "titles" { + return false + } + f.Titles = editRows(f.Titles, i) + f.Focus = rowFocus("title", list, len(f.Titles), i) + return true +} + +// rowFocus is the input to focus after a row edit: the new row's first, or +// the list's Add button. +func rowFocus(prefix, list string, rows, i int) string { + if i < 0 { + return prefix + "-" + strconv.Itoa(rows) + } + return "add-" + list +} + +func number(s string) (int, error) { return strconv.Atoi(strings.TrimSpace(s)) } + +type seriesFormView struct { + overrideForm + TVDBID string + Seasons []seasonRow + Episodes []episodeRow +} + +type seasonRow struct { + N int + Season, SiteSeason, Offset string // as typed + Error string + Focus bool +} + +type episodeRow struct { + N int + Season, Episode, ID string // as typed + Error string + Focus bool +} + +func (v *seriesFormView) Title() string { + if v.New { + return "New series override — magnetowid" + } + return v.Name + " — magnetowid" +} + +func (v *seriesFormView) prepare() { + v.overrideForm.prepare() + if len(v.Seasons) == 0 { + v.Seasons = []seasonRow{{}} + } + if len(v.Episodes) == 0 { + v.Episodes = []episodeRow{{}} + } + for i := range v.Seasons { + v.Seasons[i].N = i + 1 + v.Seasons[i].Focus = v.Focus == "season-"+strconv.Itoa(i+1) + } + for i := range v.Episodes { + v.Episodes[i].N = i + 1 + v.Episodes[i].Focus = v.Focus == "ep-"+strconv.Itoa(i+1) + } +} + +func (v *seriesFormView) fill(o provider.SeriesOverride) { + v.overrideForm.fill(o.Titles, o.ID) + for _, r := range o.Seasons { + v.Seasons = append(v.Seasons, seasonRow{Season: strconv.Itoa(r.Season), SiteSeason: strconv.Itoa(r.SiteSeason), Offset: strconv.Itoa(r.Offset)}) + } + numbers := slices.SortedFunc(maps.Keys(o.Episodes), func(a, b provider.EpisodeNumber) int { + return cmp.Or(cmp.Compare(a.Season, b.Season), cmp.Compare(a.Episode, b.Episode)) + }) + for _, n := range numbers { + v.Episodes = append(v.Episodes, episodeRow{Season: strconv.Itoa(n.Season), Episode: strconv.Itoa(n.Episode), ID: o.Episodes[n]}) + } +} + +func (v *seriesFormView) read(r *http.Request) { + v.overrideForm.read(r) + at := func(name string, i int) string { + if vs := r.PostForm[name]; i < len(vs) { + return strings.TrimSpace(vs[i]) + } + return "" + } + for i := range r.PostForm["season"] { + v.Seasons = append(v.Seasons, seasonRow{Season: at("season", i), SiteSeason: at("site_season", i), Offset: at("offset", i)}) + } + for i := range r.PostForm["ep_season"] { + v.Episodes = append(v.Episodes, episodeRow{Season: at("ep_season", i), Episode: at("ep_episode", i), ID: at("ep_id", i)}) + } +} + +// edit reports whether a row's button submitted the form, and applies it. +func (v *seriesFormView) edit(r *http.Request) bool { + list, i, ok := rowEdit(r) + if !ok { + return false + } + switch { + case v.editTitles(list, i): + case list == "seasons": + v.Seasons = editRows(v.Seasons, i) + v.Focus = rowFocus("season", list, len(v.Seasons), i) + case list == "episodes": + v.Episodes = editRows(v.Episodes, i) + v.Focus = rowFocus("ep", list, len(v.Episodes), i) + } + return true +} + +// rule returns the row's rule, or why it isn't one. +func (r seasonRow) rule() (provider.SeasonRule, string) { + var rule provider.SeasonRule + var err error + if rule.Season, err = number(r.Season); err != nil || rule.Season < 1 { + return rule, "Enter the TVDB season, from 1. Specials can only be pinned." + } + if rule.SiteSeason, err = number(r.SiteSeason); err != nil || rule.SiteSeason < 0 { + return rule, "Enter the site’s season, or 0 for any season." + } + if r.Offset != "" { + if rule.Offset, err = number(r.Offset); err != nil { + return rule, "The shift must be a whole number, such as 13 or -2." + } + } + return rule, "" +} + +// pin returns the row's episode and its site ID, or why it has none. +func (r episodeRow) pin() (provider.EpisodeNumber, string) { + var n provider.EpisodeNumber + var err error + if n.Season, err = number(r.Season); err != nil || n.Season < 0 { + return n, "Enter the episode’s TVDB season, or 0 for a special." + } + if n.Episode, err = number(r.Episode); err != nil || n.Episode < 1 { + return n, "Enter the episode’s TVDB number, from 1." + } + if r.ID == "" { + return n, "Enter the episode’s ID or page address on the site." + } + return n, "" +} + +// override returns the override the form describes, marking the rows that +// are not understood. +func (v *seriesFormView) override() provider.SeriesOverride { + o := provider.SeriesOverride{Titles: v.titles(), ID: v.ID} + for i := range v.Seasons { + row := &v.Seasons[i] + if row.Season == "" && row.SiteSeason == "" && row.Offset == "" { + continue + } + rule, why := row.rule() + if why != "" { + row.Error = why + v.failRow("season-" + strconv.Itoa(i+1)) + continue + } + o.Seasons = append(o.Seasons, rule) + } + for i := range v.Episodes { + row := &v.Episodes[i] + if row.Season == "" && row.Episode == "" && row.ID == "" { + continue + } + n, why := row.pin() + if _, dup := o.Episodes[n]; why == "" && dup { + why = n.String() + " is pinned twice." + } + if why != "" { + row.Error = why + v.failRow("ep-" + strconv.Itoa(i+1)) + continue + } + if o.Episodes == nil { + o.Episodes = make(map[provider.EpisodeNumber]string) + } + o.Episodes[n] = row.ID + } + if !v.invalid() && len(o.Titles) == 0 && o.ID == "" && len(o.Seasons) == 0 && len(o.Episodes) == 0 { + v.fail("", "Add a title, an ID, a season rule or a pinned episode.") + v.focus("title-1") + } + return o +} + +type filmFormView struct { + overrideForm + Film string // Radarr's title + Year string +} + +func (v *filmFormView) Title() string { + if v.New { + return "New film override — magnetowid" + } + return v.Name + " — magnetowid" +} + +// edit reports whether a row's button submitted the form, and applies it. +func (v *filmFormView) edit(r *http.Request) bool { + list, i, ok := rowEdit(r) + if ok { + v.editTitles(list, i) + } + return ok +} + +func (h *Handler) listOverrides(w http.ResponseWriter, r *http.Request) { + h.show(w, r, http.StatusOK, "overrides", "") +} + +// topbar refreshes the header of the pages that don't refresh whole. +func (h *Handler) topbar(w http.ResponseWriter, _ *http.Request) { + h.render(w, http.StatusOK, app, "topbar", h.formChrome()) +} + +func (h *Handler) formChrome() chrome { + c := newChrome(h.Queue.Jobs(), h.Queue.Paused(), h.Version, "overrides") + c.form = true + return c +} + +// toList sends the browser to the overrides page, at a row if one is named. +func (h *Handler) toList(w http.ResponseWriter, r *http.Request, row string) { + u := url.URL{Path: pagePath("overrides"), Fragment: row} + // htmx would swap a redirected page into the form. + if htmx(r) { + w.Header().Set("HX-Redirect", u.String()) + return + } + http.Redirect(w, r, u.String(), http.StatusSeeOther) +} + +// site returns the request's site. If it takes no overrides, site sends the +// browser to the list and returns false. +func (h *Handler) site(w http.ResponseWriter, r *http.Request) (string, bool) { + site := r.PathValue("site") + if !slices.Contains(h.Overrides.Sites(), site) { + h.toList(w, r, "") + return "", false + } + return site, true +} + +// readForm reports whether the request's form could be read. +func (h *Handler) readForm(w http.ResponseWriter, r *http.Request) bool { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if err := r.ParseForm(); err != nil { + http.Error(w, "can't read the form", http.StatusBadRequest) + return false + } + return true +} + +// form renders an override's form. htmx requests get only the form. +func (h *Handler) form(w http.ResponseWriter, r *http.Request, status int, t *template.Template, v formView) { + v.prepare() + name := "layout" + if htmx(r) { + name = "form" + } + w.Header().Add("Vary", "HX-Request") + h.render(w, status, t, name, v) +} + +// saved answers a save with the list at the saved row, or with the form and +// why the store refused it. +func (h *Handler) saved(w http.ResponseWriter, r *http.Request, t *template.Template, v formView, row string, err error) { + var inv *overrides.InvalidError + switch { + case err == nil: + h.toList(w, r, row) + case errors.As(err, &inv): + v.base().fail(inv.Field, sentence(inv.Reason())) + h.form(w, r, http.StatusUnprocessableEntity, t, v) + case errors.Is(err, overrides.ErrUnknownSite): + h.toList(w, r, "") + default: + h.Log.Error("can't save an override", "err", err) + v.base().fail("", "Couldn’t save the override. The log has the details.") + h.form(w, r, http.StatusInternalServerError, t, v) + } +} + +// seriesID returns the TVDB ID in the request's path, 0 if it has none. +func seriesID(r *http.Request) (id int, ok bool) { + s := r.PathValue("tvdbid") + if s == "" { + return 0, true + } + id, err := strconv.Atoi(s) + return id, err == nil && id > 0 +} + +func (h *Handler) newSeriesForm(site string, tvdbID int) *seriesFormView { + v := &seriesFormView{overrideForm: overrideForm{chrome: h.formChrome(), Site: site}} + if tvdbID == 0 { + v.New = true + v.Action = "/ui/overrides/" + url.PathEscape(site) + "/series" + return v + } + o, _ := h.Overrides.Site(site).SeriesFor(tvdbID) + v.Name = seriesName(tvdbID, o) + v.Action = seriesURL(site, tvdbID) + v.TVDBID = strconv.Itoa(tvdbID) + return v +} + +func (h *Handler) seriesForm(w http.ResponseWriter, r *http.Request) { + site, ok := h.site(w, r) + if !ok { + return + } + tvdbID, ok := seriesID(r) + o, found := h.Overrides.Site(site).SeriesFor(tvdbID) + if !ok || tvdbID != 0 && !found { + h.toList(w, r, "") + return + } + v := h.newSeriesForm(site, tvdbID) + if v.New { + v.Focus = "tvdbid" + } else { + v.fill(o) + } + h.form(w, r, http.StatusOK, seriesPage, v) +} + +func (h *Handler) saveSeries(w http.ResponseWriter, r *http.Request) { + site, ok := h.site(w, r) + if !ok || !h.readForm(w, r) { + return + } + tvdbID, ok := seriesID(r) + if !ok { + h.toList(w, r, "") + return + } + v := h.newSeriesForm(site, tvdbID) + v.read(r) + if v.New { + v.TVDBID = strings.TrimSpace(r.PostFormValue("tvdbid")) + } + if v.edit(r) { + h.form(w, r, http.StatusOK, seriesPage, v) + return + } + if v.New { + var err error + if tvdbID, err = number(v.TVDBID); err != nil || tvdbID <= 0 { + v.fail("tvdbid", "Enter the series’ TVDB ID, a number.") + } else if _, exists := h.Overrides.Site(site).SeriesFor(tvdbID); exists { + v.fail("tvdbid", "This series already has an override.") + v.Exists = seriesURL(site, tvdbID) + } + } + o := v.override() + if v.invalid() { + h.form(w, r, http.StatusUnprocessableEntity, seriesPage, v) + return + } + _, err := h.Overrides.PutSeries(site, tvdbID, o) + if err == nil { + h.Log.Info("saved an override", "site", site, "tvdbid", tvdbID) + } + h.saved(w, r, seriesPage, v, seriesRowID(site, tvdbID), err) +} + +func (h *Handler) deleteSeries(w http.ResponseWriter, r *http.Request) { + site, ok := h.site(w, r) + if !ok { + return + } + tvdbID, _ := seriesID(r) + found, err := h.Overrides.DeleteSeries(site, tvdbID) + if found && err == nil { + h.Log.Info("removed an override", "site", site, "tvdbid", tvdbID) + } + h.done(w, r, "overrides", "remove the override", "#remove-"+seriesRowID(site, tvdbID)+"-notice", err) +} + +// film returns the film in the request's path; year is 0 if it has none. +func film(r *http.Request) (title string, year int, ok bool) { + s := r.PathValue("year") + if s == "" { + return "", 0, true + } + year, err := strconv.Atoi(s) + return r.PathValue("title"), year, err == nil && year > 0 +} + +func (h *Handler) newFilmForm(site, title string, year int) *filmFormView { + v := &filmFormView{overrideForm: overrideForm{chrome: h.formChrome(), Site: site}} + if year == 0 { + v.New = true + v.Action = "/ui/overrides/" + url.PathEscape(site) + "/films" + return v + } + // The saved title can differ from the path's in case and punctuation. + if o, found := h.Overrides.Site(site).Film(title, year); found { + title = o.Title + } + v.Film, v.Year = title, strconv.Itoa(year) + v.Name = title + " " + v.Year + v.Action = filmURL(site, title, year) + return v +} + +func (h *Handler) filmForm(w http.ResponseWriter, r *http.Request) { + site, ok := h.site(w, r) + if !ok { + return + } + title, year, ok := film(r) + o, found := h.Overrides.Site(site).Film(title, year) + if !ok || year != 0 && !found { + h.toList(w, r, "") + return + } + v := h.newFilmForm(site, title, year) + if v.New { + v.Focus = "name" + } else { + v.fill(o.Titles, o.ID) + } + h.form(w, r, http.StatusOK, filmPage, v) +} + +func (h *Handler) saveFilm(w http.ResponseWriter, r *http.Request) { + site, ok := h.site(w, r) + if !ok || !h.readForm(w, r) { + return + } + title, year, ok := film(r) + if !ok { + h.toList(w, r, "") + return + } + v := h.newFilmForm(site, title, year) + v.read(r) + if v.New { + v.Film, v.Year = strings.TrimSpace(r.PostFormValue("name")), strings.TrimSpace(r.PostFormValue("year")) + } + if v.edit(r) { + h.form(w, r, http.StatusOK, filmPage, v) + return + } + if v.New { + title = v.Film + var err error + if title == "" { + v.fail("title", "Enter the film’s title in Radarr.") + } + if year, err = number(v.Year); err != nil || year <= 0 { + v.fail("year", "Enter the film’s year in Radarr.") + } else if _, exists := h.Overrides.Site(site).Film(title, year); exists { + v.fail("title", "This film already has an override.") + v.Exists = filmURL(site, title, year) + } + } + o := provider.FilmOverride{Titles: v.titles(), ID: v.ID} + if !v.invalid() && len(o.Titles) == 0 && o.ID == "" { + v.fail("", "Add a title to search or an ID.") + v.focus("title-1") + } + if v.invalid() { + h.form(w, r, http.StatusUnprocessableEntity, filmPage, v) + return + } + saved, err := h.Overrides.PutFilm(site, title, year, o) + if err == nil { + h.Log.Info("saved an override", "site", site, "title", saved.Title, "year", year) + } + h.saved(w, r, filmPage, v, filmRowID(site, title, year), err) +} + +func (h *Handler) deleteFilm(w http.ResponseWriter, r *http.Request) { + site, ok := h.site(w, r) + if !ok { + return + } + title, year, _ := film(r) + found, err := h.Overrides.DeleteFilm(site, title, year) + if found && err == nil { + h.Log.Info("removed an override", "site", site, "title", title, "year", year) + } + h.done(w, r, "overrides", "remove the override", "#remove-"+filmRowID(site, title, year)+"-notice", err) +} diff --git a/internal/web/overrides_test.go b/internal/web/overrides_test.go new file mode 100644 index 0000000..4ec4c10 --- /dev/null +++ b/internal/web/overrides_test.go @@ -0,0 +1,456 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + + "github.com/combor/magnetowid/internal/provider" +) + +// signedIn returns functions that get and post as a signed-in browser. +func signedIn(t *testing.T, srv *httptest.Server) (func(path string, header ...string) response, func(path string, form url.Values, header ...string) response) { + t.Helper() + c := signIn(t, srv) + cookie := c.Name + "=" + c.Value + return func(path string, header ...string) response { + t.Helper() + return get(t, srv, path, c, header...) + }, func(path string, form url.Values, header ...string) response { + t.Helper() + return post(t, srv, path, form, append([]string{"Cookie", cookie}, header...)...) + } +} + +func lacks(body string, wants ...string) []string { + var missing []string + for _, want := range wants { + if !strings.Contains(body, want) { + missing = append(missing, want) + } + } + return missing +} + +func TestOverridesPage(t *testing.T) { + srv, _, _ := newUI(t) + page, send := signedIn(t, srv) + + r := page("/ui/overrides") + if missing := lacks(r.body, + "Overrides — magnetowid", + `

Overrides

`, + `Overrides`, + `hx-get="/ui/overrides" hx-trigger="every 5s"`, + `

fake

`, + "No overrides for fake.", + `href="/ui/overrides/fake/series/new"`, + `href="/ui/overrides/fake/films/new"`, + ); r.status != http.StatusOK || missing != nil { + t.Errorf("empty overrides page = %d, lacks %q:\n%s", r.status, missing, r.body) + } + + for path, form := range map[string]url.Values{ + "/ui/overrides/fake/series": {"tvdbid": {"74419"}, "ep_season": {"0"}, "ep_episode": {"1"}, "ep_id": {"abc"}}, + "/ui/overrides/fake/films": {"name": {"Tom & Jerry"}, "year": {"1992"}, "title": {"Tom i Jerry"}}, + } { + if r := send(path, form); r.status != http.StatusSeeOther { + t.Fatalf("POST %s = %d %s", path, r.status, r.body) + } + } + r = page("/ui/overrides") + if missing := lacks(r.body, + `2`, + `
  • `, + `TVDB 74419`, + "1 pinned episode", + "<b>Tom & Jerry</b> 1992", + "searched as “Tom i Jerry”", + `aria-label="Remove TVDB 74419"`, + `hx-post="/ui/overrides/fake/series/74419/delete"`, + `id="remove-fake-series-74419-notice"`, + ); missing != nil { + t.Errorf("overrides page lacks %q:\n%s", missing, r.body) + } + // htmx focuses the first autofocus element after every swap. + if strings.Contains(r.body, "autofocus") || strings.Contains(r.body, "No overrides for") { + t.Errorf("overrides page:\n%s", r.body) + } + + r = page("/ui/overrides", "HX-Request", "true") + if r.status != http.StatusOK || !strings.HasPrefix(r.body, "Overrides — magnetowid") || strings.Contains(r.body, "New series override — magnetowid", + `

    New series override

    `, + `
    `, + `id="tvdbid" name="tvdbid" value=""`, + "required autofocus", + `id="title-1" name="title" value=""`, + `id="season-1" name="season"`, + `id="ep-1" name="ep_season"`, + // Only the header refreshes: refreshing the form would undo typing. + `
    `, + `hx-get="/ui/topbar"`, + ); r.status != http.StatusOK || missing != nil { + t.Errorf("new series form = %d, lacks %q:\n%s", r.status, missing, r.body) + } + // One row needs no Remove button, and a new override nothing to remove. + if strings.Contains(r.body, `name="remove"`) || strings.Contains(r.body, "remove-override") || strings.Contains(r.body, "visually-hidden\">Overrides") { + t.Errorf("new series form:\n%s", r.body) + } + + // Row buttons change the form without saving it. + r = send(create, url.Values{"tvdbid": {"81970"}, "title": {"Ranczo"}, "add": {"titles"}}, "HX-Request", "true") + if missing := lacks(r.body, `value="81970"`, `id="title-1" name="title" value="Ranczo"`, + "id=\"title-2\" name=\"title\" value=\"\" aria-label=\"Title 2\" autocomplete=\"off\" spellcheck=\"false\" autofocus", + `name="remove" value="titles.2"`); r.status != http.StatusOK || !strings.HasPrefix(r.body, "`, "Not an ID.", `value="https://elsewhere.example/abc" autocomplete="off" spellcheck="false" autofocus aria-invalid="true"`}}, + {"a title without letters", url.Values{"tvdbid": {"1"}, "title": {"Ranczo", "–"}}, + []string{`id="titles-error"`, "has no letters or digits."}}, + {"two rules for a season", url.Values{"tvdbid": {"1"}, "season": {"1", "1"}, "site_season": {"1", "2"}, "offset": {"", ""}}, + []string{`id="seasons-error"`, "Season 1 has two rules."}}, + {"a bad pinned ID", url.Values{"tvdbid": {"1"}, "ep_season": {"1"}, "ep_episode": {"1"}, "ep_id": {"12"}}, + []string{`id="episodes-error"`, "S01E01: not an ID."}}, + {"two episodes pinned to one", url.Values{"tvdbid": {"1"}, "ep_season": {"1", "1"}, "ep_episode": {"1", "2"}, "ep_id": {"abc", "https://site.example/abc"}}, + []string{"S01E01 and S01E02 both name abc."}}, + } { + r := send(create, tc.form) + if missing := lacks(r.body, append(tc.want, "Ranczo`, + "TVDB 81970ID abcS2 → S2, episodes +13S3 → any season, episodes -11 pinned episode"); missing != nil { + t.Errorf("the saved override's row lacks %q:\n%s", missing, r.body) + } + + // A new override doesn't replace one. + r = send(create, url.Values{"tvdbid": {"81970"}, "title": {"Other"}}) + if missing := lacks(r.body, "This series already has an override.", `Edit it`, `value="Other"`); r.status != http.StatusUnprocessableEntity || missing != nil { + t.Errorf("a second override = %d, lacks %q:\n%s", r.status, missing, r.body) + } + + const edit = "/ui/overrides/fake/series/81970" + r = page(edit) + if missing := lacks(r.body, + "Ranczo — magnetowid", + `

    Ranczo

    `, + `

    81970

    `, + `action="/ui/overrides/fake/series/81970" hx-post="/ui/overrides/fake/series/81970"`, + `name="title" value="Ranczo"`, + `id="id" name="id" value="abc"`, + `id="season-1" name="season" type="number" min="1" value="2"`, + `name="offset" type="number" value="13"`, + `id="season-2" name="season" type="number" min="1" value="3"`, + `name="remove" value="seasons.2"`, + `id="ep-1" name="ep_season" type="number" min="0" value="0"`, + `name="ep_id" value="xyz"`, + `popovertarget="remove-override"`, + ``, + ); r.status != http.StatusOK || missing != nil { + t.Errorf("series form = %d, lacks %q:\n%s", r.status, missing, r.body) + } + if strings.Contains(r.body, "autofocus") || strings.Contains(r.body, `name="tvdbid"`) { + t.Errorf("series form:\n%s", r.body) + } + if r := send(edit, url.Values{"title": {"Ranczo", "Ranczo Wilkowyje"}}); r.status != http.StatusSeeOther || r.header.Get("Location") != "/ui/overrides#fake-series-81970" { + t.Errorf("saving an edit = %d to %q: %s", r.status, r.header.Get("Location"), r.body) + } + if r := page("/ui/overrides"); !strings.Contains(r.body, "TVDB 819702 titles") { + t.Errorf("the edited override's row:\n%s", r.body) + } + if r := send(edit, url.Values{}); r.status != http.StatusUnprocessableEntity || !strings.Contains(r.body, `

    81970

    `) { + t.Errorf("emptying an override = %d %s", r.status, r.body) + } + + if r := post(t, srv, create, saved); r.status != http.StatusSeeOther || r.header.Get("Location") != "/ui/login" { + t.Errorf("signed-out save = %d to %q", r.status, r.header.Get("Location")) + } + if r := send(edit, saved, "Sec-Fetch-Site", "cross-site"); r.status != http.StatusForbidden { + t.Errorf("cross-site save = %d", r.status) + } + if r := send("/ui/overrides/nope/series", saved); r.status != http.StatusSeeOther || r.header.Get("Location") != "/ui/overrides" { + t.Errorf("saving for an unknown site = %d to %q", r.status, r.header.Get("Location")) + } + + // A failed save keeps the form. + db.Close() + r = send(edit, url.Values{"title": {"Ranczo"}}, "HX-Request", "true") + if missing := lacks(r.body, "Couldn’t save the override. The log has the details.", `value="Ranczo"`); r.status != http.StatusInternalServerError || + missing != nil || !strings.HasPrefix(r.body, "New film override — magnetowid", + `

    New film override

    `, + `action="/ui/overrides/fake/films" hx-post="/ui/overrides/fake/films"`, + `id="name" name="name" value="" autocomplete="off" spellcheck="false" required autofocus`, + `id="year" name="year" type="number" min="1" value="" required`, + `id="title-1" name="title"`, + `id="id" name="id"`, + ); r.status != http.StatusOK || missing != nil { + t.Errorf("new film form = %d, lacks %q:\n%s", r.status, missing, r.body) + } + if strings.Contains(r.body, `name="season"`) { + t.Errorf("new film form:\n%s", r.body) + } + + r = send(create, url.Values{"name": {"Cube"}, "year": {"1997"}, "title": {"Sześcian"}, "add": {"titles"}}, "HX-Request", "true") + if missing := lacks(r.body, `name="name" value="Cube"`, `value="1997"`, `value="Sześcian"`, `id="title-2"`); r.status != http.StatusOK || + !strings.HasPrefix(r.body, "Not an ID."}}, + } { + r := send(create, tc.form) + if missing := lacks(r.body, append(tc.want, "`, `Face/Off 1997`, + "ID abc", `aria-label="Remove Face/Off 1997"`, `hx-post="`+edit+`/delete"`); missing != nil { + t.Errorf("the saved film's row lacks %q:\n%s", missing, r.body) + } + + // Radarr's titles match without case, punctuation or a leading "the". + r = send(create, url.Values{"name": {"The Face Off"}, "year": {"1997"}, "id": {"abd"}}) + if missing := lacks(r.body, "This film already has an override.", `Edit it`); r.status != http.StatusUnprocessableEntity || missing != nil { + t.Errorf("a second override = %d, lacks %q:\n%s", r.status, missing, r.body) + } + + for _, path := range []string{edit, "/ui/overrides/fake/films/1997/The%20Face%20Off"} { + r = page(path) + if missing := lacks(r.body, + "Face/Off 1997 — magnetowid", + `

    Face/Off 1997

    `, + `action="`+edit+`" hx-post="`+edit+`"`, + `id="id" name="id" value="abc"`, + ``, + ); r.status != http.StatusOK || missing != nil || strings.Contains(r.body, `name="name"`) || strings.Contains(r.body, "autofocus") { + t.Errorf("film form at %s = %d, lacks %q:\n%s", path, r.status, missing, r.body) + } + } + if r := send(edit, url.Values{"title": {"Bez twarzy"}}, "HX-Request", "true"); r.header.Get("HX-Redirect") != "/ui/overrides#"+row { + t.Errorf("saving an edit = %d, HX-Redirect %q: %s", r.status, r.header.Get("HX-Redirect"), r.body) + } + if r := page("/ui/overrides"); !strings.Contains(r.body, "searched as “Bez twarzy”") { + t.Errorf("the edited film's row:\n%s", r.body) + } +} + +func TestRemoveOverride(t *testing.T) { + srv, _, db := newUI(t) + page, send := signedIn(t, srv) + for path, form := range map[string]url.Values{ + "/ui/overrides/fake/series": {"tvdbid": {"1"}, "title": {"Klan"}}, + "/ui/overrides/fake/films": {"name": {"Face/Off"}, "year": {"1997"}, "id": {"abc"}}, + } { + if r := send(path, form); r.status != http.StatusSeeOther { + t.Fatalf("POST %s = %d %s", path, r.status, r.body) + } + } + if r := send("/ui/overrides/fake/series", url.Values{"tvdbid": {"2"}, "title": {"Ranczo"}}); r.status != http.StatusSeeOther { + t.Fatalf("saving = %d %s", r.status, r.body) + } + + if r := post(t, srv, "/ui/overrides/fake/series/1/delete", nil); r.status != http.StatusSeeOther || r.header.Get("Location") != "/ui/login" { + t.Errorf("signed-out removal = %d to %q", r.status, r.header.Get("Location")) + } + if r := send("/ui/overrides/fake/series/1/delete", nil, "Sec-Fetch-Site", "cross-site"); r.status != http.StatusForbidden { + t.Errorf("cross-site removal = %d", r.status) + } + if r := page("/ui/overrides"); !strings.Contains(r.body, "Klan") { + t.Fatalf("refused removals removed the override:\n%s", r.body) + } + + // htmx requests get the list without the row, plain forms a redirect to it. + r := send("/ui/overrides/fake/series/1/delete", nil, "HX-Request", "true") + if r.status != http.StatusOK || !strings.HasPrefix(r.body, "Overrides — magnetowid") || strings.Contains(r.body, "Klan") || + !strings.Contains(r.body, "Face/Off") { + t.Errorf("htmx removal = %d %s", r.status, r.body) + } + if r := send("/ui/overrides/fake/films/1997/Face%2FOff/delete", nil); r.status != http.StatusSeeOther || r.header.Get("Location") != "/ui/overrides" { + t.Errorf("removal = %d to %q", r.status, r.header.Get("Location")) + } + if r := page("/ui/overrides"); strings.Contains(r.body, "Face/Off") || !strings.Contains(r.body, "Ranczo") { + t.Errorf("after removals:\n%s", r.body) + } + // An override already removed is not an error. + if r := send("/ui/overrides/fake/series/1/delete", nil, "HX-Request", "true"); r.status != http.StatusOK { + t.Errorf("removing a removed override = %d %s", r.status, r.body) + } + + // A failed removal's notice goes into its confirmation. + db.Close() + r = send("/ui/overrides/fake/series/2/delete", nil, "HX-Request", "true") + if r.status != http.StatusInternalServerError || r.header.Get("HX-Retarget") != "#remove-fake-series-2-notice" || + !strings.Contains(r.body, "Couldn’t remove the override.") { + t.Errorf("failed htmx removal = %d %v %s", r.status, r.header, r.body) + } + r = send("/ui/overrides/fake/series/2/delete", nil) + if r.status != http.StatusInternalServerError || !strings.Contains(r.body, " .icon { margin-top: 2px; } + +.banner-danger { + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: var(--danger-soft); + color: var(--danger); +} /* Now downloading */ @@ -829,6 +844,199 @@ code { color: var(--text-muted); } +/* Overrides */ + +.site .section-head h2 { + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.section-tools { + display: flex; + gap: 8px; + margin: -4px 0 -4px auto; +} + +.row-title a { + color: inherit; + text-decoration: none; +} + +.row-title a:hover { + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} + +/* A saved override's row, clear of the header. */ +.row:target { + scroll-margin-top: 96px; + animation: saved 2.6s ease-out; +} + +.hint { + padding: 18px 20px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 14px; + text-align: center; +} + +/* Override forms */ + +.form-card { padding: 28px 28px 24px; } + +.form-head { + display: grid; + gap: 6px; + padding-bottom: 24px; +} + +.form-head h1 { + font-size: 24px; + font-weight: 700; + line-height: 1.3; + letter-spacing: -0.025em; + overflow-wrap: anywhere; +} + +.form-head .lead { + max-width: 62ch; + margin-top: 0; +} + +.fields { display: grid; } +.fields > .banner { margin-bottom: 24px; } + +.fieldset { + display: grid; + grid-template-columns: minmax(0, 5fr) minmax(0, 8fr); + gap: 12px 40px; + padding: 24px 0; + border-top: 1px solid var(--border); +} + +.fieldset-about h2 { + font-size: 15px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.fieldset-about p { + margin-top: 4px; + color: var(--text-muted); + font-size: 13.5px; +} + +.fieldset-body { + display: grid; + align-content: start; + justify-items: start; + gap: 10px; + min-width: 0; +} + +.fieldset-body > .input, +.fieldset-body > .line, +.lines { + justify-self: stretch; +} + +.fields .input { + height: 40px; + padding: 0 12px; + font-size: 14.5px; +} + +.fields .input::placeholder { color: color-mix(in srgb, var(--text-muted) 70%, transparent); } + +.input-id { max-width: 200px; } + +/* What an override is for, which editing can't change. */ +.fixed { + display: flex; + align-items: center; + gap: 8px; + min-height: 40px; + font-weight: 650; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.lines { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.line { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px; +} + +.line > .input { + flex: 1 1 0; + min-width: 0; +} + +.cell { + position: relative; + display: grid; + flex: 0 0 104px; + gap: 4px; + color: var(--text-muted); + font-size: 12px; + font-weight: 650; +} + +.cell-wide { + flex: 1 1 160px; + min-width: 0; +} + +.to { + display: grid; + place-items: center; + height: 40px; + color: var(--text-muted); +} + +.line .icon-btn { + width: 40px; + height: 40px; +} + +.line .field-error { flex-basis: 100%; } +.field-error a { color: inherit; font-weight: 650; white-space: nowrap; } + +.form-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding-top: 24px; + border-top: 1px solid var(--border); +} + +.form-actions .btn-quiet-danger { margin-left: auto; } + +/* Only the first row names its inputs where the rows line up. */ +@media (min-width: 561px) { + .line + .line .cell > span { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } +} + /* Removal confirmation */ .confirm { @@ -1101,6 +1309,11 @@ code { @keyframes toast-hide { to { visibility: hidden; } } +@keyframes saved { + 0%, 35% { border-color: var(--ok); box-shadow: 0 0 0 4px var(--ok-soft); } + 100% { box-shadow: 0 0 0 4px transparent; } +} + @keyframes sheen { 0% { background-position: -150% 0, 0 0; } 60%, 100% { background-position: 250% 0, 0 0; } @@ -1171,5 +1384,22 @@ code { white-space: nowrap; } + .form-card { padding: 22px 18px 18px; } + .form-head { padding-bottom: 20px; } + .form-head h1 { font-size: 21px; } + + .fieldset { + grid-template-columns: minmax(0, 1fr); + padding: 20px 0; + } + + /* An episode's ID goes under its numbers. */ + .cell { flex: 1 1 72px; } + .cell-wide { order: 1; flex-basis: 100%; } + .line .field-error { order: 2; } + .to { display: none; } + + .form-actions .btn { flex: 1 1 auto; } + .auth-card { padding: 26px 22px 22px; } } diff --git a/internal/web/templates/app.html b/internal/web/templates/app.html index 9e5ec74..2fa8347 100644 --- a/internal/web/templates/app.html +++ b/internal/web/templates/app.html @@ -1,4 +1,4 @@ -{{/* Shared by the queue and history pages. */}} +{{/* Shared by the pages behind sign-in. */}} {{/* Refreshes keep a failed removal's notice while its confirmation is open. */}} {{define "head"}} @@ -7,7 +7,7 @@ {{- end}} {{define "content" -}} -
    +
    {{template "app" .}}
    {{with .Notice}}{{template "notice" .}}{{end}}
    @@ -22,7 +22,27 @@ {{- end}} {{define "app" -}} -
    +{{template "topbar" .}} + +{{/* Actions queue with refreshes so responses land in order. Nothing is cancelled: the bundled htmx releases the queue + when a cancelled request ends, while its replacement still runs. */}} +
    + {{with .Heading}}

    {{.}}

    {{end}} + + {{template "page" .}} +
    +{{- end}} + +{{/* A page that doesn't refresh whole refreshes its header. */}} +{{define "topbar" -}} +
    - -{{/* Actions queue with refreshes so responses land in order. Nothing is cancelled: the bundled htmx releases the queue - when a cancelled request ends, while its replacement still runs. */}} -
    -

    {{if eq .Page "history"}}Download history{{else}}Download queue{{end}}

    -
    - {{template "page" .}} -
    {{- end}} {{define "name" -}} @@ -88,4 +94,9 @@

    {{if eq .Page "history"}}Download history{{else}}Dow {{define "icon-play"}}{{end}} {{define "icon-trash"}}{{end}} {{define "icon-check"}}{{end}} +{{define "icon-plus"}}{{end}} +{{define "icon-x"}}{{end}} +{{define "icon-edit"}}{{end}} +{{define "icon-tv"}}{{end}} +{{define "icon-film"}}{{end}} {{define "icon-alert"}}{{end}} diff --git a/internal/web/templates/film.html b/internal/web/templates/film.html new file mode 100644 index 0000000..c4d1e18 --- /dev/null +++ b/internal/web/templates/film.html @@ -0,0 +1,41 @@ +{{define "kind"}}film{{end}} + +{{define "titles-help"}}The site’s titles for the film, searched instead of Radarr’s.{{end}} + +{{define "id-help"}}The site’s film, used even if its title or year differs from Radarr’s. Paste its page’s address or its ID.{{end}} + +{{define "form" -}} + + {{template "form-start" .}} +
    +
    +

    Film

    +

    Its title and year in Radarr.

    +
    +
    + {{- if .New}} +
    + + +
    + {{- with index .Errors "title"}} +

    {{template "field-error" .}}{{with $.Exists}} Edit it{{end}}

    + {{- end}} + {{- with index .Errors "year"}} +

    {{template "field-error" .}}

    + {{- end}} + {{- else}} +

    {{.Film}} {{.Year}}

    + {{- end}} +
    +
    + + {{template "titles" .}} + + {{template "site-id" .}} + + {{template "form-actions" .}} + +{{- end}} diff --git a/internal/web/templates/override.html b/internal/web/templates/override.html new file mode 100644 index 0000000..de467c9 --- /dev/null +++ b/internal/web/templates/override.html @@ -0,0 +1,97 @@ +{{/* Shared by the series and film forms, which define the form and the help for these fields. */}} + +{{define "form-head" -}} +
    +

    {{.Site}} · {{template "kind"}} override

    +

    {{if .New}}New {{template "kind"}} override{{else}}{{.Name}}{{end}}

    +

    It replaces automatic matching on {{.Site}} for what it covers. Fill in only what needs correcting.

    +
    +{{- end}} + +{{/* Enter saves: a form's first submit button is its default. */}} +{{define "form-start" -}} + +{{- with .Summary}} + +{{- end}} +{{- end}} + +{{define "field-error" -}} +{{template "icon-alert"}}{{.}} +{{- end}} + +{{define "titles" -}} +
    +
    +

    Titles to search

    +

    {{template "titles-help"}}

    +
    +
    +
      + {{- range .Titles}} +
    • + + {{- if gt (len $.Titles) 1}} + + {{- end}} +
    • + {{- end}} +
    + {{- with index .Errors "titles"}} +

    {{template "field-error" .}}

    + {{- end}} + +
    +
    +{{- end}} + +{{define "site-id" -}} +
    +
    +

    +

    {{template "id-help"}}

    +
    +
    + + {{- with index .Errors "id"}} +

    {{template "field-error" .}}

    + {{- end}} +
    +
    +{{- end}} + +{{define "form-actions" -}} +
    + + Cancel + {{- if not .New}} + + {{- end}} +
    +{{- end}} + +{{/* Outside the form it removes, which can't hold another. */}} +{{define "remove-override" -}} +{{- if not .New}} + +{{- end}} +{{- end}} + +{{/* Saving swaps only the form, so the rest of the page keeps its place. */}} +{{define "page" -}} +
    + {{template "form-head" .}} +
    {{template "form" .}}
    +
    +{{template "remove-override" .}} +{{- end}} diff --git a/internal/web/templates/overrides.html b/internal/web/templates/overrides.html new file mode 100644 index 0000000..677f3d9 --- /dev/null +++ b/internal/web/templates/overrides.html @@ -0,0 +1,55 @@ +{{define "page" -}} + {{- range .Sites}} +
    +
    +

    {{.Name}}

    + {{- with .Rows}} + {{len .}} + {{- end}} + +
    + {{- if .Rows}} +
      + {{- range .Rows}} +
    • + +

      {{.Title}}{{with .Year}} {{.}}{{end}}

      +

      + {{if .Film}}Film{{else}}Series{{end}} + {{range .Facts}}{{.}}{{end}} +

      +
      + {{template "icon-edit"}} + {{template "remove-button" .}} + +
      +
    • + {{- end}} +
    + {{- else}} +

    No overrides for {{.Name}}. Add one if a series or film is missing, or its episodes are paired wrongly.

    + {{- end}} +
    + {{- else}} +
    + {{template "mark"}} +

    No site takes overrides

    +

    Overrides correct how a site’s series and films are matched.

    +
    + {{- end}} + {{- if .Sites}} +

    Overrides replace automatic matching for what they cover, from the next search.

    + {{- end}} +{{- end}} diff --git a/internal/web/templates/series.html b/internal/web/templates/series.html new file mode 100644 index 0000000..03fc7eb --- /dev/null +++ b/internal/web/templates/series.html @@ -0,0 +1,94 @@ +{{define "kind"}}series{{end}} + +{{define "titles-help"}}The site’s titles for the series, searched instead of the ones magnetowid finds.{{end}} + +{{define "id-help"}}The site’s series, if several share a title. Paste its page’s address or its ID.{{end}} + +{{define "form" -}} +
    + {{template "form-start" .}} +
    +
    +

    {{if .New}}{{else}}TVDB ID{{end}}

    +

    The number in the series’ TVDB link in Sonarr.

    +
    +
    + {{- if .New}} + + {{- with index .Errors "tvdbid"}} +

    {{template "field-error" .}}{{with $.Exists}} Edit it{{end}}

    + {{- end}} + {{- else}} +

    {{.TVDBID}}

    + {{- end}} +
    +
    + + {{template "titles" .}} + + {{template "site-id" .}} + +
    +
    +

    Season rules

    +

    Where a TVDB season’s episodes are on the site: TVDB’s episode n is the site’s episode n plus the shift. Site season 0 accepts any season, if only one has that episode.

    +
    +
    +
      + {{- range $row := .Seasons}} +
    1. + + + + + {{- if gt (len $.Seasons) 1}} + + {{- end}} + {{- with .Error}} +

      {{template "field-error" .}}

      + {{- end}} +
    2. + {{- end}} +
    + {{- with index .Errors "seasons"}} +

    {{template "field-error" .}}

    + {{- end}} + +
    +
    + +
    +
    +

    Pinned episodes

    +

    Single TVDB episodes, each with the site’s episode. Pins win over season rules, and season 0 holds specials.

    +
    +
    +
      + {{- range $row := .Episodes}} +
    1. + + + + + {{- if gt (len $.Episodes) 1}} + + {{- end}} + {{- with .Error}} +

      {{template "field-error" .}}

      + {{- end}} +
    2. + {{- end}} +
    + {{- with index .Errors "episodes"}} +

    {{template "field-error" .}}

    + {{- end}} + +
    +
    + + {{template "form-actions" .}} +
    +{{- end}} diff --git a/internal/web/view.go b/internal/web/view.go index cce94ff..1d7d090 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -14,12 +14,13 @@ import ( // chrome is the queue summary every page shows. type chrome struct { Version string - Page string // queue or history + Page string // queue, history or overrides Paused bool Queued int // unfinished jobs Finished int Notice string // why an action failed state string + form bool // an override's form: only its header refreshes } func newChrome(jobs []downloader.Job, paused bool, version, page string) chrome { @@ -63,20 +64,48 @@ func (c chrome) StateLabel() string { return "Idle" } -// Refresh is the URL the page polls. +// Heading names a page that shows no heading of its own. +func (c chrome) Heading() string { + switch { + case c.form: + return "" + case c.Page == "history": + return "Download history" + case c.Page == "overrides": + return "Overrides" + } + return "Download queue" +} + +// Refresh is the URL the page polls, empty if only its header refreshes: +// refreshing a form would undo what is typed in it. func (c chrome) Refresh() string { - if c.Page == "history" { - return "/ui/history" + switch { + case c.form: + return "" + case c.Page == "queue": + return "/ui/queue" } - return "/ui/queue" + return pagePath(c.Page) } // Every is the page's poll interval. func (c chrome) Every() string { - if c.Page == "history" { - return "5s" + if c.Page == "queue" { + return "1s" + } + return "5s" +} + +// pagePath is where a tab's page is. +func pagePath(page string) string { + switch page { + case "history": + return "/ui/history" + case "overrides": + return "/ui/overrides" } - return "1s" + return "/ui/" } // queueView is everything the queue page shows, so tests can render any diff --git a/internal/web/web.go b/internal/web/web.go index bc7d9c8..77a3923 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -1,5 +1,5 @@ -// Package web serves the browser interface: sign-in, and live queue and -// history pages with download controls. +// Package web serves the browser interface: sign-in, live queue and history +// pages with download controls, and forms for overrides. // // static/htmx-4.0.0.min.js is dist/htmx.min.js from the htmx.org 4.0.0 npm // package, under the Zero-Clause BSD license. @@ -21,21 +21,25 @@ import ( "time" "github.com/combor/magnetowid/internal/downloader" + "github.com/combor/magnetowid/internal/overrides" ) //go:embed templates static var files embed.FS var ( - layout = template.Must(template.ParseFS(files, "templates/layout.html")) - app = parse(layout, "templates/app.html") - loginPage = parse(layout, "templates/login.html") - queuePage = parse(app, "templates/queue.html") - historyPage = parse(app, "templates/history.html") + layout = template.Must(template.ParseFS(files, "templates/layout.html")) + app = parse(layout, "templates/app.html") + loginPage = parse(layout, "templates/login.html") + queuePage = parse(app, "templates/queue.html") + historyPage = parse(app, "templates/history.html") + overridesPage = parse(app, "templates/overrides.html") + seriesPage = parse(app, "templates/override.html", "templates/series.html") + filmPage = parse(app, "templates/override.html", "templates/film.html") ) -func parse(base *template.Template, name string) *template.Template { - return template.Must(template.Must(base.Clone()).ParseFS(files, name)) +func parse(base *template.Template, names ...string) *template.Template { + return template.Must(template.Must(base.Clone()).ParseFS(files, names...)) } const ( @@ -45,10 +49,11 @@ const ( // Handler serves the interface under /ui/. Signing in takes the API key. type Handler struct { - Queue *downloader.Queue - APIKey string - Version string - Log *slog.Logger + Queue *downloader.Queue + Overrides *overrides.Store + APIKey string + Version string + Log *slog.Logger } // Register adds the interface's routes to mux, which must not have a @@ -67,6 +72,19 @@ func (h *Handler) Register(mux *http.ServeMux) { post("/ui/queue/{id}/resume", h.pauseJob(false)) post("/ui/queue/{id}/delete", h.cancelJob) post("/ui/history/{id}/delete", h.deleteJob) + get("/ui/topbar", h.topbar) + get("/ui/overrides", h.listOverrides) + get("/ui/overrides/{site}/series/new", h.seriesForm) + get("/ui/overrides/{site}/series/{tvdbid}", h.seriesForm) + post("/ui/overrides/{site}/series", h.saveSeries) + post("/ui/overrides/{site}/series/{tvdbid}", h.saveSeries) + post("/ui/overrides/{site}/series/{tvdbid}/delete", h.deleteSeries) + get("/ui/overrides/{site}/films/new", h.filmForm) + // Slashes in titles are escaped: Face%2FOff. + get("/ui/overrides/{site}/films/{year}/{title}", h.filmForm) + post("/ui/overrides/{site}/films", h.saveFilm) + post("/ui/overrides/{site}/films/{year}/{title}", h.saveFilm) + post("/ui/overrides/{site}/films/{year}/{title}/delete", h.deleteFilm) mux.Handle("GET /ui/login", secure(http.HandlerFunc(h.loginForm))) mux.Handle("POST /ui/login", secure(csrf.Handler(http.HandlerFunc(h.login)))) mux.Handle("POST /ui/logout", secure(csrf.Handler(http.HandlerFunc(h.logout)))) @@ -209,11 +227,16 @@ func (h *Handler) show(w http.ResponseWriter, r *http.Request, status int, page, jobs, paused, now := h.Queue.Jobs(), h.Queue.Paused(), time.Now() var t *template.Template var v any - if page == "history" { + switch page { + case "history": hv := newHistoryView(jobs, paused, h.Version, now) hv.Notice = notice t, v = historyPage, hv - } else { + case "overrides": + ov := newOverridesView(h.Overrides, newChrome(jobs, paused, h.Version, page)) + ov.Notice = notice + t, v = overridesPage, ov + default: qv := newQueueView(jobs, paused, h.Queue.Outages(), h.Version, now) qv.Notice = notice t, v = queuePage, qv @@ -282,11 +305,7 @@ func (h *Handler) done(w http.ResponseWriter, r *http.Request, page, what, notic h.show(w, r, http.StatusOK, page, "") return } - path := "/ui/" - if page == "history" { - path = "/ui/history" - } - http.Redirect(w, r, path, http.StatusSeeOther) + http.Redirect(w, r, pagePath(page), http.StatusSeeOther) } // Render fully before writing so a template error cannot send half a page. diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 2442b49..e2377ab 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -3,6 +3,7 @@ package web import ( "bytes" "context" + "errors" "fmt" "io" "log/slog" @@ -19,6 +20,7 @@ import ( "github.com/combor/magnetowid/internal/downloader" "github.com/combor/magnetowid/internal/nzb" + "github.com/combor/magnetowid/internal/overrides" "github.com/combor/magnetowid/internal/provider" "github.com/combor/magnetowid/internal/store" bolt "go.etcd.io/bbolt" @@ -39,6 +41,17 @@ func (fakeProvider) Resolve(_ context.Context, id string) (provider.Stream, erro return provider.Stream{}, nil } +// IDs are lowercase letters, and pages https://site.example/. +func (fakeProvider) ParseID(ref string) (string, error) { + ref = strings.TrimPrefix(ref, "https://site.example/") + if ref == "" || strings.Trim(ref, "abcdefghijklmnopqrstuvwxyz") != "" { + return "", errors.New("not an ID") + } + return ref, nil +} + +func (fakeProvider) SetOverrides(*provider.Overrides) {} + type fakeEngine struct{} func (fakeEngine) Download(_ context.Context, _ provider.Stream, out string, progress func(time.Duration, int64)) error { @@ -54,7 +67,12 @@ func newUI(t *testing.T) (*httptest.Server, *downloader.Queue, *bolt.DB) { t.Fatal(err) } t.Cleanup(func() { db.Close() }) - q, err := downloader.New(filepath.Dir(db.Path()), db, provider.NewRegistry(fakeProvider{}), fakeEngine{}, slog.New(slog.DiscardHandler)) + providers := provider.NewRegistry(fakeProvider{}) + q, err := downloader.New(filepath.Dir(db.Path()), db, providers, fakeEngine{}, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatal(err) + } + o, err := overrides.Open(db, providers) if err != nil { t.Fatal(err) } @@ -64,7 +82,7 @@ func newUI(t *testing.T) (*httptest.Server, *downloader.Queue, *bolt.DB) { mux.Handle("/api", http.NotFoundHandler()) mux.HandleFunc("GET /overrides", http.NotFound) mux.HandleFunc("GET /health", http.NotFound) - (&Handler{Queue: q, APIKey: "key", Version: "1.2.3", Log: slog.New(slog.DiscardHandler)}).Register(mux) + (&Handler{Queue: q, Overrides: o, APIKey: "key", Version: "1.2.3", Log: slog.New(slog.DiscardHandler)}).Register(mux) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv, q, db