No description
  • C# 98.6%
  • Shell 0.9%
  • PowerShell 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Fill84 e4faa30032
All checks were successful
build / build (push) Successful in 1m6s
feat(shell): let the settings page add and remove indexers and clients
Closes the last functional gap in the settings page: with zero
indexers or clients configured there was no way to add one, so the
plugin could not be made useful from its own page at all.

- SettingsView gains Add/Remove buttons wired to four new REST routes
  (AddIndexer, AddClient, RemoveIndexer/{index}, RemoveClient/{index}),
  following the same method-in-the-path pattern SaveIndexer/SaveClient
  already use. Remove renders as a DestructiveButton so its intent
  carries a PluginConfirmation; Add carries none.
- SettingsSaveHandler gains matching Add/Remove handlers. Removing an
  entry deletes it from the merged settings and lets
  SettingsGateway.SaveAsync's existing orphan sweep delete its stored
  secret - proven with a dedicated test rather than assumed. A newly
  added entry gets a unique default name ("New Indexer 1", "New
  Indexer 2", ...) so two blank-named entries never collide on the
  same secret key.
- Settings now carries a LastSavedAtUtc timestamp, stamped from the
  injected IClock on every successful save/add/remove and rendered on
  the page ("Last saved yyyy-MM-dd HH:mm UTC" / "Not saved yet.") so a
  successful save is visible even though the client discards the
  response body and only re-fetches the view.

371 pre-existing tests plus 36 new ones, 0 warnings.
2026-07-31 02:28:38 +02:00
.forgejo/workflows feat(shell): pin the server's discovery contract and ship a plugin-facing README 2026-07-30 07:24:40 +02:00
docs/superpowers
scripts build: reference NoMercy.Plugins.Mvc so the plugin can serve REST 2026-07-30 07:34:17 +02:00
src feat(shell): let the settings page add and remove indexers and clients 2026-07-31 02:28:38 +02:00
tests feat(shell): let the settings page add and remove indexers and clients 2026-07-31 02:28:38 +02:00
.gitattributes
.gitignore chore: ignore the local _stage directory 2026-07-31 01:32:08 +02:00
global.json
LICENSE
nomercy-torrent-plugin.sln
nuget.config
README.md docs: the settings page saves again - media-server#26 landed 2026-07-31 01:13:34 +02:00

NoMercy Torrent Downloader

A plugin for the NoMercy MediaServer that keeps a TV library complete without supervision.

It reads the server's library to work out which episodes are missing, searches configured indexers for them, picks one release per episode against a quality/language/group profile, pushes it to a torrent client, tracks the download, and hands the finished file to the server's import pipeline. Downloads can also be added and removed by hand, by magnet link or .torrent file.

Status: in development — it loads, it does not download yet. The domain core, the indexer layer and the plugin shell are built and tested. What is missing is the engine between them: the download database, the torrent-client clients, and the loop that ties searching to grabbing. See What works today for the honest line, and Roadmap for the order.

Do not install this expecting downloads. It will load, appear in the dashboard, register its four jobs, read your library, and let you configure its schedules, indexers and download clients from its settings page. What is missing is anything that acts on that configuration to actually fetch an episode.

What works today

NoMercy.Plugin.TorrentDownloader.Core — the decision engine, with no dependency on the media server at all. It builds and tests standalone. Plus NoMercy.Plugin.TorrentDownloader, the shell that plugs it into the server.

370 tests, no warnings.

Release parsing season/episode, season packs, resolution, source, codec, release group, PROPER/REPACK, language tags
Show matching decides whether a release title belongs to a given show
Release profiles quality ladder with a cutoff, language profile including dual-audio, preferred and blocked groups, term rules, size and seeder bounds
Filtering hard accept/reject, every rejection carrying a reason a user can act on
Scoring soft ranking where a quality step outranks every other signal combined
Indexers IIndexer contract, RSS and scene-feed reading, Torznab for Jackett and Prowlarr
Rate limiting per-indexer minimum interval, concurrency cap, exponential backoff on a rate limit, circuit breaker that parks a failing indexer
Aggregation fan-out across indexers, one failing indexer never takes the search down, dedupe by infohash and title preferring the copy that can actually be downloaded
Plugin shell loads into the server, four independently scheduled jobs the owner can see and time separately, reads the library through the host's read-only contract
Settings storage configuration and secrets in their two separate stores — a password can never reach the plaintext config, because the type handed to it has nowhere to put one
Settings page renders cron schedules, folders, indexers and download clients, never echoing a stored secret back
Saving rejects a blank cron or non-absolute URL, leaves a stored secret alone when the field is submitted blank, carries a secret across a rename, and never lets one form's submission overwrite a section it did not address
Network grants user-configured indexer and client hosts requested from the owner at runtime, since a manifest cannot know them

Roadmap

Release parsing, matching, profiles, filtering, scoring done
Indexers: RSS/scene feeds, Torznab, per-indexer pacing, aggregation done
Plugin shell: loads, four scheduled jobs, reads the library, settings + secrets storage done
REST surface: the settings page saves done
Download database (SQLite) next
Torrent clients: qBittorrent, then Transmission and Deluge
The loop: wanted → search → decide → grab → track → import
Quality upgrades that replace the old file, and daily-show matching

Design

Two projects, and the split is the point:

  • Core — pure domain logic. No reference to any NoMercy assembly, no I/O outside the indexer clients, no reading of the system clock. It compiles and its tests run without cloning the media server, which keeps the test loop fast and the logic honest.
  • The plugin shell — the only part that touches the host. It implements the plugin interfaces and supplies Core with its ports. It is the only project that references NoMercy.Plugins.Abstractions, which is what keeps Core testable without a server.

The full design is in docs/superpowers/specs/, and the implementation plans in docs/superpowers/plans/. They are written to be read: the spec records the decisions and the reasons, including the ones that went the other way.

Tested against real captures, deliberately

tests/fixtures/ holds real responses captured from live indexers. Parsers are tested against those rather than hand-written samples.

That is a correction, not a preference. The first stage was built against invented release titles, and nearly every defect review found came from a case the invented data politely avoided — a show actually called Greek being read as a Greek-language release, a name with a diacritic tokenising into fragments, an indexer that appends [eztv.re] to every title. Real captures do not flatter a parser.

Building

Requires the .NET 10 SDK (matches the server's target framework).

The plugin shell builds against NoMercy.Plugins.Abstractions, which is not published to nuget.org, so fetch it first. The script clones the server (sparse, shallow) and packs the contract into a local feed that the committed nuget.config already points at:

./scripts/fetch-abstractions.sh      # or: pwsh scripts/fetch-abstractions.ps1
dotnet build
dotnet test

To build against a specific contract commit rather than the tip of dev — which is what a release build does, so the artifact can be rebuilt later:

SERVER_REF=<sha> ./scripts/fetch-abstractions.sh

Core needs none of this: it has no external dependency and no reference to any NoMercy assembly, so dotnet build src/NoMercy.Plugin.TorrentDownloader.Core and its 257 tests work on a clean clone with no server present.

Upstream platform work

The plugin shell needed twelve platform capabilities the media server did not have. They were filed as issues #15#25 and all shipped by v0.1.446 — consent granting, the Phase 2 backend surface, a network allowlist that can express user-configured hosts, a read-only library contract, capability-gated library writes, events, a secret store, navigation, UI vocabulary, cron, and the UI SDK.

Two of those decisions are worth recording, because they shaped this plugin:

  • The library is read through a narrow read-only contract, not the server's EF MediaContext. Sharing the EF model would have made it a public ABI, and every migration a plugin break.
  • Secrets go through the server's secret store and its data protector. Never an environment variable, never plaintext on disk.

Core deliberately depends on none of it, which is why it was built first and why its tests run without cloning the server.

Prior art

The matching rules are ported from a working Python prototype, including the ones that only exist because something broke: the show-name scope rule that stops Lucky matching Lucky Hank, the episode-slot check that stops the wrong episode being marked done, and the invariant that a failed push never marks an episode grabbed.

Licence

MIT.