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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ for container hostnames, volume mounts and Remote Path Mappings.

Open `http://<magnetowid-host>:8484/` and sign in with the API key to see the
queue and history, to pause, resume or remove downloads, and to correct matches
with overrides. See [Web interface](docs/usage.md#web-interface).
with overrides. Its Setup page lists the values to enter in Sonarr and Radarr.
See [Web interface](docs/usage.md#web-interface).

## Help and contributing

Expand Down
3 changes: 2 additions & 1 deletion cmd/magnetowid/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ 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, Overrides: overrideStore, APIKey: *apiKey, Version: version, Log: log}).Register(mux)
(&web.Handler{Queue: queue, Overrides: overrideStore, Providers: providers, Categories: cats, 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
10 changes: 10 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ Sonarr/Radarr must be able to read the download directory. If they see it at a d

## Sonarr / Radarr setup

The [web interface](#web-interface)'s **Setup** page lists these values for
your install.

1. **Download client:** Settings → Download Clients → **SABnzbd**.
- Name: `magnetowid`.
- Host and port: magnetowid's host and port.
Expand Down Expand Up @@ -181,6 +184,13 @@ import a download removed from history.
add, change and remove them for series and films. A form that can't be saved
says why beside the field at fault.

**Setup** shows what to enter in Sonarr and Radarr: the download client's host,
port and category, and each site's indexer URL, API path and categories. The
addresses are the ones the page was opened with, so use another host if the
apps reach magnetowid [differently](#docker-networking-and-shared-downloads).
It also shows the version, the download folder, and each site's state: working,
or unreachable with the error and the time of the next attempt.

Signing in lasts 30 days. Changing `MAGNETOWID_API_KEY` signs every browser
out. Search for titles in Sonarr/Radarr.

Expand Down
4 changes: 4 additions & 0 deletions internal/downloader/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ func (j Job) TimeLeft(now time.Time) time.Duration {
return time.Duration(float64(now.Sub(j.Started)) * (1 - j.Fraction) / gained)
}

// Unreachable reports whether the job's last attempt met its provider's
// outage, which Error then describes.
func (j Job) Unreachable() bool { return strings.HasPrefix(j.Error, errUnreachable.Error()+": ") }

// HistoryRetention is how long finished jobs are kept.
const HistoryRetention = 30 * 24 * time.Hour

Expand Down
4 changes: 4 additions & 0 deletions internal/downloader/queue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,10 @@ func TestOutagePausesProvider(t *testing.T) {
if (j.ID == a || j.ID == b) && (j.Status != StatusQueued || j.Attempts != 0) {
t.Errorf("job %s: status = %s, attempts = %d", j.Name, j.Status, j.Attempts)
}
// Only a met the outage.
if (j.ID == a || j.ID == b) && j.Unreachable() != (j.ID == a) {
t.Errorf("job %s: unreachable = %v, error = %q", j.Name, j.Unreachable(), j.Error)
}
}
if until, ok := q.Outages()["fake"]; !ok || time.Until(until) < 50*time.Minute {
t.Errorf("outages = %v, want fake held back for about an hour", q.Outages())
Expand Down
18 changes: 16 additions & 2 deletions internal/newznab/newznab.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ const (
catTVHD = 5040
)

// Categories returns the categories an indexer offers a kind's releases in,
// for Sonarr's or Radarr's indexer settings.
func Categories(kind provider.Kind) []int {
if kind == provider.Movie {
return []int{catMovies, catMoviesHD}
}
return []int{catTV, catTVHD}
}

// Handler serves the Newznab API. Mount it at "/{provider}/api".
type Handler struct {
Providers *provider.Registry
Expand Down Expand Up @@ -505,8 +514,13 @@ func (h *Handler) nzbLink(r *http.Request, p provider.Provider, id string, secs
return h.baseURL(r, p) + "?" + v.Encode()
}

// Honor reverse-proxy headers when constructing the public URL.
func (h *Handler) baseURL(r *http.Request, p provider.Provider) string {
return BaseURL(r) + "/" + p.Name() + "/api"
}

// BaseURL is the address r reached magnetowid at, such as
// "http://nas:8484". It honors reverse-proxy headers.
func BaseURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil || strings.EqualFold(firstValue(r.Header.Get("X-Forwarded-Proto")), "https") {
scheme = "https"
Expand All @@ -515,7 +529,7 @@ func (h *Handler) baseURL(r *http.Request, p provider.Provider) string {
if fh := firstValue(r.Header.Get("X-Forwarded-Host")); fh != "" {
host = fh
}
return scheme + "://" + host + "/" + p.Name() + "/api"
return scheme + "://" + host
}

func firstValue(v string) string {
Expand Down
18 changes: 18 additions & 0 deletions internal/newznab/newznab_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,24 @@ func TestLinksBehindProxy(t *testing.T) {
}
}

func TestBaseURL(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://nas:8484/ui/setup", nil)
if got := BaseURL(r); got != "http://nas:8484" {
t.Errorf("BaseURL = %q", got)
}
r.Header.Set("X-Forwarded-Proto", "https, http")
r.Header.Set("X-Forwarded-Host", "magnetowid.example.com, internal:8484")
if got := BaseURL(r); got != "https://magnetowid.example.com" {
t.Errorf("BaseURL behind a proxy = %q", got)
}
}

func TestCategories(t *testing.T) {
if tv, films := Categories(provider.Episode), Categories(provider.Movie); !slices.Equal(tv, []int{5000, 5040}) || !slices.Equal(films, []int{2000, 2040}) {
t.Errorf("categories = %v and %v", tv, films)
}
}

func TestSearchByTVDBID(t *testing.T) {
fp := &fakeTVDBProvider{title: "Days of Honor"}
fp.items = []provider.Item{
Expand Down
156 changes: 156 additions & 0 deletions internal/web/setup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package web

import (
"fmt"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"

"github.com/combor/magnetowid/internal/downloader"
"github.com/combor/magnetowid/internal/newznab"
"github.com/combor/magnetowid/internal/provider"
)

// setupView is everything the setup page shows: what to enter in Sonarr and
// Radarr, and how this magnetowid is doing.
type setupView struct {
chrome
// The download client's address, as the browser reached this page.
Host, Port string
SSL bool
Categories []setupValue // the download client's, by app if known
Indexers []setupValue // each site's URL
Newznab []setupValue // the indexers' categories, by app
Sites []siteStatus
Dir string // the download folder
Offered string // the download categories, as configured
}

// setupValue is a value to enter, and where if that isn't everywhere.
type setupValue struct {
Text string
For string
}

// siteStatus is how downloads from a site are going.
type siteStatus struct {
Name string
Status string
Tone string // badge colour: neutral, ok or danger
Facts []string // the status's details, then what the site supports
Error string // why the site is unreachable
}

// newSetupView takes base as newznab.BaseURL returns it.
func newSetupView(c chrome, base string, providers *provider.Registry, categories []string, dir string,
jobs []downloader.Job, outages map[string]time.Time, now time.Time) setupView {
v := setupView{chrome: c, Host: base, Dir: dir, Offered: strings.Join(categories, ", ")}
if u, err := url.Parse(base); err == nil && u.Hostname() != "" {
v.Host, v.Port, v.SSL = u.Hostname(), u.Port(), u.Scheme == "https"
if v.Port == "" {
v.Port = "80"
if v.SSL {
v.Port = "443"
}
}
}
// Only the default categories say which app they are for.
if slices.Contains(categories, "tv") && slices.Contains(categories, "movies") {
v.Categories = []setupValue{{"tv", "in Sonarr"}, {"movies", "in Radarr"}}
} else {
for _, c := range categories {
v.Categories = append(v.Categories, setupValue{Text: c})
}
}
v.Newznab = []setupValue{
{numbers(newznab.Categories(provider.Episode)), "in Sonarr"},
{numbers(newznab.Categories(provider.Movie)), "in Radarr"},
}
for _, name := range providers.Names() {
p, _ := providers.Get(name)
v.Indexers = append(v.Indexers, setupValue{base + "/" + name, name})
v.Sites = append(v.Sites, newSiteStatus(p, jobs, outages[name], c.Paused, now))
}
return v
}

func (v setupView) Title() string { return "Setup — magnetowid" }

// ByApp reports whether the download categories name their apps.
func (v setupView) ByApp() bool { return len(v.Categories) > 0 && v.Categories[0].For != "" }

func numbers(ns []int) string {
s := make([]string, len(ns))
for i, n := range ns {
s[i] = strconv.Itoa(n)
}
return strings.Join(s, ", ")
}

// newSiteStatus takes when the site's outage lets its jobs run again, zero if
// it has none. Only downloads tell whether a site is reachable.
func newSiteStatus(p provider.Provider, jobs []downloader.Job, outage time.Time, paused bool, now time.Time) siteStatus {
v := siteStatus{Name: p.Name(), Status: "No downloads yet", Tone: "neutral"}
var waiting int
var last time.Time
var unreachable string
for _, j := range jobs {
if j.Ref.Provider != p.Name() {
continue
}
switch j.Status {
case downloader.StatusQueued, downloader.StatusDownloading:
if j.Paused {
continue
}
waiting++
if unreachable == "" && j.Unreachable() {
unreachable = j.Error
}
case downloader.StatusCompleted:
if j.Finished.After(last) {
last = j.Finished
}
}
}
switch {
// An outage outlasts its jobs' removal, but only until its next attempt.
case !outage.IsZero() && (outage.After(now) || waiting > 0):
v.Status, v.Tone, v.Error = "Unreachable", "danger", unreachable
switch {
case paused:
v.Facts = append(v.Facts, "tries again when the queue resumes")
case outage.After(now):
v.Facts = append(v.Facts, "trying again in "+until(outage.Sub(now)))
default:
v.Facts = append(v.Facts, "trying again now")
}
switch waiting {
case 0:
case 1:
v.Facts = append(v.Facts, "1 download waiting")
default:
v.Facts = append(v.Facts, fmt.Sprintf("%d downloads waiting", waiting))
}
case !last.IsZero():
v.Status, v.Tone = "Working", "ok"
v.Facts = append(v.Facts, "last download "+ago(now.Sub(last)))
}
if _, ok := p.(provider.TVDBSearcher); ok {
v.Facts = append(v.Facts, "TVDB ID search")
}
if _, ok := p.(provider.RecentLister); ok {
v.Facts = append(v.Facts, "RSS")
}
if _, ok := p.(provider.Overridable); ok {
v.Facts = append(v.Facts, "overrides")
}
return v
}

func (h *Handler) setup(w http.ResponseWriter, r *http.Request) {
h.show(w, r, http.StatusOK, "setup", "")
}
Loading
Loading