Help us shape the future of Go! Take the 2026 Go Developer Survey

seadex-scout

command module
v1.8.49 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 19, 2026 License: GPL-2.0, GPL-3.0 Imports: 32 Imported by: 0

README

seadex-scout

Image Size Platforms Test coverage OpenSSF Best Practices OpenSSF Scorecard SBOM

A report-only watcher that compares your Sonarr/Radarr anime library against SeaDex (the community-curated index of the best anime releases) and tells you, per title, when SeaDex recommends a better release than the one on disk. It never downloads, grabs, or touches a torrent client: it tells you what to go get, and you decide.

One image and one config file give you three things:

  1. Findings on the log (always on): the daemon compares your library to SeaDex and logs a warn line when a better release exists than the one on disk. You turn those lines into Loki/Grafana alerts (see Alerting); the app ships no notifier of its own.
  2. An on-demand report: a season-by-season audit of how your whole library lines up with SeaDex, written as Markdown and JSON. See The report.
  3. An optional Torznab feed: publishes SeaDex's picks for Sonarr/Radarr to grab through their own engine. It stays off until you configure it, and it is the only automation path: seadex-scout itself still never grabs, the arrs do.

The problem

To keep an anime library aligned with SeaDex by hand, you open releases.moe, look up each show, and compare your files against the recommendation. seadexarr automates the lookup, but two gaps matter for a storage- and bandwidth-conscious library:

  • Its only notifier is Discord, so it cannot alert through Loki and Grafana.
  • Its filters cannot keep encodes and drop remuxes. For a library that prefers a good x265 encode over a 40 GB remux, that distinction is the whole point.

seadex-scout closes both gaps and nothing more.

What it does

On start and then every poll_interval, seadex-scout runs one cycle:

  1. It walks the Sonarr/Radarr anime library (with arr-side tag include/exclude) and fingerprints each item's current release: group, resolution, codec, remux-vs-encode, and dual-audio.
  2. It matches each SeaDex entry to a library item by AniList ID through the Fribb anime-lists ID bridge, with an AniList title fallback for the entries that do not map.
  3. It filters SeaDex's recommended releases by your preferences (remux policy, AnimeBytes on or off, dual-audio).
  4. It compares the surviving recommendation against what you have and emits a warn log line when SeaDex has something better.

When the Torznab feed is configured, the same cycle rebuilds it from that one SeaDex fetch, so a finding and what the arrs can grab from the feed always reflect the same refresh.

Run modes

The mode setting (or a subcommand) picks the run mode:

  • daemon (default): the poll loop above, flagging better releases as findings on the log. When a Prowlarr Torznab URL is configured, the same process also serves the Torznab feed, both features in one container.
  • report: a one-shot, read-only audit. It scans the whole library once, writes a SeaDex-alignment report, and exits. Run it as the container command (report), set mode: report in the config, or use docker exec while the daemon runs.
Scheduling
  • Built-in (default): poll_interval is a Go duration (3h default; a value under 1h is clamped up to 1h). A cycle runs on start, then every interval. It is the single cadence for both the findings loop and the Torznab feed.

  • External / resident-idle: set poll_interval: off (or disabled / 0). The daemon runs no internal timer; the container idles healthy and an external scheduler drives each cycle with the poll subcommand, which runs one cycle, updates the health marker, and exits 0 or 1. The Torznab feed is served from the last cycle's snapshot, so it is empty until the first poll runs. With Ofelia, label the service:

        labels:
          ofelia.enabled: "true"
          ofelia.job-exec.seadex-poll.schedule: "@every 3h"
          ofelia.job-exec.seadex-poll.command: "/seadex-scout poll"
    

    Any scheduler works: docker exec seadex-scout /seadex-scout poll is the whole contract.

The report

The report answers, for every anime with a SeaDex match: which release you have, and whether it is SeaDex's best, a listed alt, or neither. It is season-level: each SeaDex entry (one AniList ID = one cour, movie, or special) is scoped to its TVDB season through the Fribb mapping and compared against that season's on-disk groups. Each row gets a verdict:

  • have_best: you have a release SeaDex marks best.
  • have_alt: you have a listed alt; SeaDex marks a different release best.
  • have_unlisted: you have a release SeaDex does not list.
  • no_file: the mapped season or movie has no file on disk.
  • unverified: files are present but no release group could be identified.

A trailing not_on_seadex section then lists the library items recognized as anime (through the Fribb catalogue) that SeaDex does not list at all, so you can see which of your titles SeaDex has not curated. Every row links the Sonarr/Radarr item, the SeaDex entry, and each best release.

Each run writes a timestamped pair into report.dir (default /config/reports): report-<UTC date+time>.md grouped by verdict and report-<UTC date+time>.json beside it, plus one report item log line per anime. Successive runs never overwrite one another, and the app deletes no reports, so prune old pairs yourself. A second report started while one is still running refuses with another report is already running and exits 1.

While the daemon runs, produce a new report without stopping it:

docker exec seadex-scout /seadex-scout report

A report never writes the state cache, so it is safe to run alongside a daemon cycle. To produce reports on a schedule, use the same Ofelia job-exec pattern as above with /seadex-scout report. The output of a docker exec run goes to the exec session, not the container log stream, so Loki never sees its report item lines.

When you run report as the container's command (rather than docker exec into the running daemon), disable the image's baked healthcheck for that one-shot container (compose: healthcheck: { disable: true }; docker run: --no-healthcheck). The health marker belongs to the daemon's poll loop, so a report-only container reads unhealthy while the report is still generating, and an unhealthy-restart watchdog could kill it mid-run.

Indexer (Torznab feed)

When a Prowlarr Torznab URL is configured, the daemon serves a Torznab feed of SeaDex releases for Sonarr/Radarr, alongside the compare loop in the same process. It is the opt-in automation path: unlike the report-only findings, it lets the arrs grab. Point your arrs at it (directly or through Prowlarr) and they parse, match, and grab through their own engines, profiles, and history, exactly as for any other indexer. To set it up, see docs/torznab-indexer.md.

The feed handles its two request kinds two different ways. A search (the arr's automatic or interactive search, which carries a query) is proxied to Prowlarr's Nyaa and AnimeBytes Torznab endpoints and filtered to what SeaDex curates, so its download links are Prowlarr's own and no tracker passkey is needed here. If every upstream query fails (Prowlarr unreachable), the search answers with a Torznab <error code="900"> document rather than an empty feed, so the arr records a failed search instead of concluding there were no results. A periodic RSS check (the no-query "recent releases" fetch the arrs run on their sync interval) has no query to match against, so the feed synthesizes the SeaDex list itself: one item per curated release, its title taken from SeaDex's own file names, with a public Nyaa .torrent link or an AnimeBytes link built from your ab_passkey.

Every item, either way, carries a download-volume-factor marker: SeaDex's best release is tagged 0.75 (which the arrs read as AnimeBytes Freeleech25) and an alt 0.25 (Freeleech75). That marker is the signal you map to a Custom Format, which is what makes the arrs prefer SeaDex's pick. Each item's category is the entry's real media type, resolved from the anime-list mapping rather than guessed from the file name: a film is 2000 (Movies → Radarr), while a series, OVA, or special is 5070 (Anime → Sonarr), so a single-file special is never mistaken for a movie.

It answers whole-season searches, not per-episode ones. SeaDex tracks season packs, so the feed answers a season search with the pack and returns nothing, without contacting a tracker, for a per-episode query. Specials and movies are single releases and are always answered.

Setup requirement: the feed relies on the season search, so enable Anime Standard Format Search on the seadex-scout indexer in Sonarr (Settings → Indexers → the indexer). Without it, Sonarr sends only per-episode queries, which the feed does not answer.

Security

The feed is gated by feed_api_key: a request without the matching apikey gets 401. Its links are Prowlarr proxy URLs (for searches) and, for the AnimeBytes RSS feed, direct AnimeBytes links that embed your ab_passkey. Treat the endpoint as sensitive and keep it on your LAN; behind an internal reverse proxy is fine (that is what the per-tracker subdomain routing is for), but do not put it on the public internet. seadex-scout sends the Prowlarr API key in a request header, never in a logged URL, and never writes it to the logs.

The synthesized feed is also persisted on disk between cycles as /config/feed.json, and its AnimeBytes items embed the ab_passkey in their download links. The file is written owner-only (0600), but treat it as secret-bearing: a /config backup captures the passkey even when your config.yaml only references it through ${SEADEX_SCOUT_AB_PASSKEY}.

How matching works

SeaDex keys everything on AniList IDs; Sonarr keys on TVDB, Radarr on TMDB/IMDb. seadex-scout bridges them:

  • ID mapping. The Fribb anime-list-mini.json dataset maps anilist_id to type (TV vs movie), tvdb_id, themoviedb_id, and imdb_id. Each cycle revalidates it with a conditional GET, so an unchanged multi-MB file is a cheap 304 and is never re-downloaded. The type decides which arr and which ID field to use.
  • Overrides. To pin the entries Fribb misses, drop a /config/overrides.json beside the config: a JSON array of records keyed by anilist_id, applied ahead of Fribb. Absent is fine. Fields per record: anilist_id (required), type (movie routes to Radarr, anything else to Sonarr), tvdb_id, tmdb_movies (array of ints), imdb_ids (array of strings), and season_tvdb. These are NOT the upstream Fribb field names (imdb_id, themoviedb_id, season), which are ignored with a warning naming the key. An override replaces the whole mapping record for its anilist_id (no field-by-field merge), so when correcting an entry Fribb already has, restate every field the entry needs.
  • Title fallback. When an entry maps through neither, seadex-scout fetches its titles and format from AniList and tries a conservative normalized title-plus-year match against the library: exact match, single candidate required, and an ambiguous match is skipped rather than guessed. Mapped items never reach AniList, so steady-state AniList traffic is near zero.

Release classification and filters

Each SeaDex release and each library file is classified into one vocabulary: release group, tracker (public like Nyaa, private like AnimeBytes), resolution, codec (x265/x264), dual-audio, and kind (remux / encode / unknown). The remux-vs-encode decision reads names and notes, never a size or bitrate inference; an unclassifiable release is unknown and is never silently dropped. The comparison is group-centric: an item is aligned when a recommended release group is already present on it.

These filters shape the findings and the report only. The indexer feed applies none of them; there the arrs filter through their own quality profile and Custom Formats. All are optional:

  • filters.exclude_remux (default false): when true, releases classified remux never count as a recommendation. The default keeps them, because on SeaDex a remux is often the best release.
  • filters.require_dual_audio (default false): drop releases that are not dual-audio.
  • filters.exclude_specials (default false): when true, drop OVA/ONA/special entries from findings and the report.
  • animebytes (default false): the one tracker knob, at the top level because it is tracker access rather than a content filter. The public trackers SeaDex lists (Nyaa, AnimeTosho, RuTracker) are always considered; the private tracker AnimeBytes is included only when you turn this on, which means you have an account. On, a finding carries every source, so a release on both Nyaa and AnimeBytes shows both links. Because seadex-scout only links, an AnimeBytes link is the torrent page you open as a member: no tracker credentials are needed.
  • arr_tags.include / arr_tags.exclude (arr-side): scan only items carrying an include tag, and never items carrying an exclude tag; an exclude wins when an item has both.

Configuration

All configuration lives in one YAML file, /config/config.yaml (override the path with CONFIG_PATH). On first boot with no config, seadex-scout writes a commented starter there and exits with a warning; edit it and restart. The full annotated template is config.example.yaml.

Any string value can reference SONARR_*, RADARR_*, or SEADEX_SCOUT_* environment variables with ${VAR}, so secrets can live in an .env or a Docker secret instead of the file. API keys are never logged (only whether each is set).

sonarr:
  enabled: true
  url: "http://sonarr:8989"
  api_key: "${SONARR_API_KEY}"    # or paste it directly; required when enabled
  public_url: ""                  # browser URL for report deep-links; falls back to url
radarr:
  enabled: false
  url: "http://radarr:7878"
  api_key: ""

mode: "daemon"                    # daemon (scheduled) | report (one-shot, exit)
poll_interval: "3h"               # cadence for BOTH findings + Torznab feed; off/disabled/0 = external trigger via `poll`

animebytes: false                 # true if you have an AnimeBytes account: adds AB releases + links

filters:
  exclude_remux: false            # true drops remuxes (default keeps them)
  require_dual_audio: false
  exclude_specials: false         # true drops OVA/ONA/specials

arr_tags:
  include: []                     # only scan arr items with these tags; [] = all
  exclude: []                     # never scan arr items with these tags (an exclude wins)

report:
  dir: "/config/reports"          # timestamped report-<UTC date+time>.md + .json written here

indexer:                          # the daemon serves the feed whenever a Torznab URL is set below
  feed_api_key: ""                # the arrs send this; the feed checks it (openssl rand -hex 16)
  nyaa_torznab_url: ""            # Prowlarr Nyaa Torznab URL, e.g. http://prowlarr:9696/1/api ("" = off)
  ab_torznab_url: ""              # Prowlarr AnimeBytes Torznab URL ("" = off)
  prowlarr_api_key: ""            # Prowlarr API key; secret, never logged
  ab_passkey: ""                  # AnimeBytes passkey for the AB RSS feed download links ("" = AB RSS off; Nyaa needs none)

log:
  level: "info"                   # debug | info | warn | error
  format: "json"                  # json | text

At least one arr must be enabled with a url and an api_key; an enabled arr missing either is a configuration error. An unknown or misplaced key is also rejected at startup with an error naming it (unknown configuration key "anime_bytes"), so a typo fails fast instead of being silently ignored. public_url is the browser-facing base used only for the report's deep-links (leave it empty to reuse url), so an internal Docker hostname in url still yields working links.

The upstream endpoints (SeaDex, Fribb, AniList), their request cadences, and the internal file locations under /config (the state cache, the reports, and the overrides file) are fixed and are not config keys, so the file stays limited to what you actually tune.

Observability

Observability is slog-only: no metrics endpoint, and no HTTP surface unless you configure the indexer feed, the only thing that binds a port (fixed at :9118). An alert-only deployment stays socket-less.

  • slog to Loki. A JSON handler writes to stdout; Alloy (or any collector) ships it to Loki. A finding is one line at warn (msg="better release available") with title, al_id, arr, current_group, recommended_group, tracker, resolution, kind, classification_reason, a headline release_url, and release_urls (every obtainable source, so a release on both Nyaa and AnimeBytes carries both). Informational cases (incomplete, theoretical_best, mixed_group_manual) log at info. Each cycle closes with a completion line: cycle complete (carrying the counts, mapping coverage, and AniList usage) when healthy, or cycle degraded at warn with a reason when an upstream outage or a safety guard skipped the comparison. Report mode emits one report item line per anime.
  • Health. The distroless image's Docker HEALTHCHECK runs the seadex-scout health subcommand against a /tmp/.healthy file marker, so it needs no shell and no port; the marker reflects the last cycle's library-ingest outcome.

Alerting

seadex-scout ships no notifier of its own; its operational state is in its logs (there is no metrics endpoint). Ship the container's logs to Loki (Grafana Alloy's Docker log discovery does this with no configuration) and evaluate the rules in alerts.yaml with Loki's ruler; firing alerts deliver through your Alertmanager like any Prometheus metric alert. They cover:

Alert Fires when Severity
SeadexScoutCycleError a cycle logs an error: the Sonarr/Radarr library walk failed, or a library-shrink / mapping-refresh guard escalated after 8 consecutive degraded cycles warning
SeadexScoutScanStalled no cycle completion line (cycle complete or cycle degraded) in 7h, so the daemon poll loop is wedged warning
SeadexScoutBetterReleaseFound SeaDex recommended a better release than the one on disk (informational, not a fault) info
SeadexScoutReportWritten a report run wrote a season-level alignment report (informational) info

Thresholds and the severity labels are starting points. Adjust the container selector (or job / service, depending on your log collector) to your deployment and the stall window to your poll_interval (default 3h). In resident-idle (poll_interval: off) or report mode each cycle runs through a docker exec child (the poll / report subcommand), so its logs go to the trigger rather than the container's log stream and the log rules cannot fire; alert on your external scheduler's own job result instead. The rules assume the default JSON log handler; for log.format: text, swap the | json | level="ERROR" parser stage for a |= "level=ERROR" line filter. Route by whatever labels your Alertmanager uses.

Deployment

seadex-scout ships as a distroless, non-root, multi-arch (amd64 + arm64) image at ghcr.io/cplieger/seadex-scout. The compose.yaml at the repo root is a working example. For a hardened deployment, layer on:

    read_only: true
    cap_drop: ["ALL"]
    security_opt: ["no-new-privileges:true"]
    tmpfs: ["/tmp:size=1m,mode=1777,noexec,nosuid,nodev"]  # backs the health marker

The /config volume must be writable by the container user (set user: to match the host owner of the mounted directory); it holds config.yaml, the state cache, and the report output. By default the container exposes no network port: observability is slog-only and health is the file-marker health subcommand. It binds a port only when you configure the indexer feed, which serves on a fixed :9118; publish that port only on your LAN.

Contributing

Issues and pull requests are welcome. See CONTRIBUTING.md for the repo layout, the conventions, and how to run the checks locally.

Disclaimer

This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.

This project was built with AI-assisted tooling using Claude, GPT, and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.

License

GPL-3.0. Linking arrapi (GPL-3.0) makes seadex-scout GPL-3.0. See LICENSE and NOTICE.

Documentation

Overview

Package main is seadex-scout: a watcher that compares a Sonarr/Radarr anime library against SeaDex (releases.moe) and emits a structured slog line whenever SeaDex recommends a better release than the one on disk. It never downloads or touches a torrent client; it tells the operator what to go get.

main.go is the composition root: it installs logging, handles the distroless `health` subcommand, loads and validates the YAML config (CONFIG_PATH, default /config/config.yaml; a starter is written on first boot), builds the scout (build.go), and runs the daemon. All logic lives in internal/*.

Two run modes: the daemon (no argument, or mode: daemon) runs a compare cycle on start and every poll_interval - or sits resident-idle when poll_interval is off/disabled/0, with an external scheduler driving cycles via the `poll` subcommand - and, when a Prowlarr Torznab URL is configured, also serves the Torznab feed of SeaDex releases (both features in one process, no toggle); the one-shot report (the `report` subcommand or mode: report) writes a SeaDex-alignment report and exits. The `poll` subcommand runs one compare cycle and the `health` subcommand backs the Docker healthcheck; both run via `docker exec <container> /seadex-scout <cmd>` while the daemon idles.

Directories

Path Synopsis
internal
align
Package align resolves which on-disk release groups a SeaDex entry should be compared against, and summarizes a whole-series comparison.
Package align resolves which on-disk release groups a SeaDex entry should be compared against, and summarizes a whole-series comparison.
anilist
Package anilist is a minimal AniList GraphQL client used only as a fallback when the Fribb map plus operator overrides miss an AniList ID.
Package anilist is a minimal AniList GraphQL client used only as a fallback when the Fribb map plus operator overrides miss an AniList ID.
appinfo
Package appinfo holds seadex-scout's fixed identity constants shared across its outbound HTTP clients, so the app presents one consistent identity to every upstream (SeaDex, Fribb, AniList, Prowlarr) from a single source rather than each client redeclaring it.
Package appinfo holds seadex-scout's fixed identity constants shared across its outbound HTTP clients, so the app presents one consistent identity to every upstream (SeaDex, Fribb, AniList, Prowlarr) from a single source rather than each client redeclaring it.
audit
Package audit produces a full SeaDex-alignment report over the library: for every anime that has a matching SeaDex entry, what release you have and whether it is SeaDex's best, an alt, or unlisted.
Package audit produces a full SeaDex-alignment report over the library: for every anime that has a matching SeaDex entry, what release you have and whether it is SeaDex's best, an alt, or unlisted.
classify
Package classify houses the shared SeaDex-to-release classification glue: the single construction of a release.Release from a seadex.Torrent (in the context of its entry) that both the compare (findings) and audit (report) flows depend on.
Package classify houses the shared SeaDex-to-release classification glue: the single construction of a release.Release from a seadex.Torrent (in the context of its entry) that both the compare (findings) and audit (report) flows depend on.
compare
Package compare turns matched SeaDex entries into findings.
Package compare turns matched SeaDex entries into findings.
config
Package config loads seadex-scout configuration from a single YAML file (default /config/config.yaml).
Package config loads seadex-scout configuration from a single YAML file (default /config/config.yaml).
filter
Package filter decides which SeaDex candidate releases an operator could use.
Package filter decides which SeaDex candidate releases an operator could use.
indexer
Package indexer serves a Torznab feed of SeaDex releases for Sonarr/Radarr.
Package indexer serves a Torznab feed of SeaDex releases for Sonarr/Radarr.
library
Package library ingests the Sonarr/Radarr anime library through arrapi into a snapshot: per item its external IDs, tags, current release groups, and a representative release fingerprint.
Package library ingests the Sonarr/Radarr anime library through arrapi into a snapshot: per item its external IDs, tags, current release groups, and a representative release fingerprint.
mapping
Package mapping bridges AniList IDs (what SeaDex keys on) to the arr IDs Sonarr and Radarr key on (TVDB, TMDB, IMDb), using the Fribb anime-lists dataset plus a local overrides file the operator can pin misses in.
Package mapping bridges AniList IDs (what SeaDex keys on) to the arr IDs Sonarr and Radarr key on (TVDB, TMDB, IMDb), using the Fribb anime-lists dataset plus a local overrides file the operator can pin misses in.
match
Package match links SeaDex entries to library items.
Package match links SeaDex entries to library items.
release
Package release classifies a media release from its names, notes, and metadata into a normalized fingerprint: release group, tracker kind, resolution, video codec, dual-audio, and remux-vs-encode.
Package release classifies a media release from its names, notes, and metadata into a normalized fingerprint: release group, tracker kind, resolution, video codec, dual-audio, and remux-vs-encode.
report
Package report emits findings as structured slog events with cross-cycle dedupe.
Package report emits findings as structured slog events with cross-cycle dedupe.
scout
Package scout orchestrates one compare cycle: load state, walk the library, refresh the ID map, pull SeaDex, match entries to library items, compare, and report findings, then persist the caches.
Package scout orchestrates one compare cycle: load state, walk the library, refresh the ID map, pull SeaDex, match entries to library items, compare, and report findings, then persist the caches.
seadex
Package seadex is a read client for the SeaDex (releases.moe) PocketBase API.
Package seadex is a read client for the SeaDex (releases.moe) PocketBase API.
state
Package state persists seadex-scout's cross-cycle cache as a single JSON file written atomically: the last library snapshot (for diffing), the cached Fribb map plus its HTTP validators, the AniList fallback memo, the finding dedupe records, and a flag marking that the dedupe table has been seeded.
Package state persists seadex-scout's cross-cycle cache as a single JSON file written atomically: the last library snapshot (for diffing), the cached Fribb map plus its HTTP validators, the AniList fallback memo, the finding dedupe records, and a flag marking that the dedupe table has been seeded.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL