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/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) + } + } +} 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