Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ for container hostnames, volume mounts and Remote Path Mappings.
## Web interface

Open `http://<magnetowid-host>: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

Expand Down
2 changes: 1 addition & 1 deletion cmd/magnetowid/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 13 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand All @@ -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.
Expand Down
30 changes: 15 additions & 15 deletions internal/overrides/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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
Expand Down
14 changes: 11 additions & 3 deletions internal/overrides/overrides.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions internal/overrides/overrides_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Loading
Loading