From d676ec428486fae11411ecdf1fc9734e3aa674df Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 11:05:03 +0200 Subject: [PATCH 01/39] docs: brainstorm spec for public torrent-site catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a design document for a curated, built-in catalog of 12 public no-account torrent sites (TPB, 1337x, Nyaa, TorrentGalaxy, …). Ships the parser machinery already on disk — the gap is UX. The spec covers catalog layout, vendoring from Prowlarr-indexers, parser extensions driven by real per-site fixture tests, a dedicated /indexers/catalog page, and an optional onboarding step. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...4-20-public-torrent-site-catalog-design.md | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-20-public-torrent-site-catalog-design.md diff --git a/docs/superpowers/specs/2026-04-20-public-torrent-site-catalog-design.md b/docs/superpowers/specs/2026-04-20-public-torrent-site-catalog-design.md new file mode 100644 index 0000000..67079e3 --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-public-torrent-site-catalog-design.md @@ -0,0 +1,261 @@ +# Public torrent site catalog + +**Date:** 2026-04-20 +**Status:** Design, pre-implementation +**Scope:** Ship a curated, built-in catalog of public (no-account) torrent sites so users can add them with a single click instead of copy-pasting YAML from a GitHub repo. + +## Problem + +Today, adding a public torrent site like The Pirate Bay or 1337x to Trove requires: + +1. Locate a Cardigann YAML definition on GitHub (typically `Prowlarr/Prowlarr-indexers`). +2. Copy the raw YAML. +3. On `/indexers`, click **Add**, select type **Cardigann**, paste the YAML into a textarea. +4. Fill in name + base URL manually. +5. Save and test. + +This is friction that hides an entire class of usable sites behind expert knowledge. The underlying Cardigann parser already supports search-only public sites — the gap is pure UX. + +## Goal + +Add a curated "catalog" of 12 verified public torrent sites, installable in one click from a dedicated in-app page (and as an optional step in the onboarding wizard). Installation produces an ordinary `type=cardigann` indexer row — no new storage model, no parallel driver. + +## Non-goals + +- **No login / account-based sites**. The catalog is for truly public, search-only sites. Private trackers (account + passkey + cookies) remain in the existing Cardigann / UNIT3D / RarTracker flows. +- **No automated site-health monitoring** beyond what the existing Test button provides. +- **No auto-failover across mirrors** at runtime. The user picks one mirror at install time. +- **No logo hosting**. v1 ships without per-site artwork. +- **No dynamic fetching of definitions from upstream at runtime**. Deterministic builds matter more than freshness. + +## Shape of the solution + +### Catalog content + +Twelve sites, all present in `Prowlarr/Prowlarr-indexers`, all search-only, all no-account: + +| Slug | Display name | Focus | +|------|---|---| +| `thepiratebay` | The Pirate Bay | general | +| `1337x` | 1337x | general | +| `torrentgalaxy` | TorrentGalaxy | general | +| `limetorrents` | LimeTorrents | general | +| `magnetdl` | MagnetDL | general | +| `torlock` | Torlock | general | +| `bitsearch` | BitSearch | aggregator | +| `solidtorrents` | SolidTorrents | aggregator | +| `nyaa` | Nyaa | anime / asian | +| `eztv` | EZTV | TV | +| `yts` | YTS | movies (small-size encodes) | +| `animetosho` | AnimeTosho | anime | + +The list is expected to evolve; the architecture makes adding an entry a one-YAML-plus-one-registry-line operation. + +### Storage layout + +``` +backend/src/trove/indexers/ +├── catalog/ +│ ├── registry.yaml # curated metadata for the 12 entries +│ ├── thepiratebay.yml # vendored from Prowlarr-indexers +│ ├── 1337x.yml +│ ├── ... (10 more) +│ └── yts.yml +└── cardigann.py # existing parser, extended as needed +``` + +`registry.yaml` is the authoritative index. One entry per site: + +```yaml +- slug: thepiratebay + display_name: The Pirate Bay + description: General-purpose public torrent tracker, no account required. + categories: [movies, tv, music, software, games, books] + yaml_file: thepiratebay.yml + mirrors: + - https://thepiratebay.org + - https://tpb.party + - https://piratebay.live + default_mirror: https://thepiratebay.org + protocol: torrent + logo: null +``` + +Fields: + +- `slug` — stable identifier. Matches filename stem, URL path segment, frontend route. +- `display_name` — shown in UI. +- `description` — one-sentence sell. +- `categories` — list of `Category` enum values, used for filtering / search-routing hints. +- `yaml_file` — filename within `catalog/` (relative). +- `mirrors` — list of base URLs the site is known to respond on. Must include `default_mirror`. This is **our curated list**, not a passthrough of the vendored YAML's `links:` block — upstream often lists dead or unreliable mirrors, and we control what the UI exposes to the user. +- `default_mirror` — pre-selected in the UI dropdown. Must be an element of `mirrors`. +- `protocol` — `torrent` for all current entries, but kept explicit for future-proofing. +- `logo` — always `null` in v1. Reserved for future use (path relative to a static directory). + +No database-level representation. The catalog is source code, not data. A user who installs an entry ends up with a plain `IndexerRow` of type `cardigann`, functionally indistinguishable from a hand-added one. + +### Vendoring and updates + +Definitions are **manually vendored** — the canonical source is `Prowlarr/Prowlarr-indexers`, but the files live in our repo. Updates are explicit: + +- `scripts/update-catalog.py` downloads the upstream tarball, compares SHA-256 of each vendored file against upstream, prints a per-file status: `unchanged` / `upstream changed` / `not found upstream`. +- The maintainer reviews the diff, copies updated YAMLs, runs the test suite (notably the per-site fixture tests in §Testing), and commits. +- Cadence is manual — monthly or on user bug report. + +Rejected alternatives: + +- **Git submodule**: pulls a ~500-file repo for our 12 files, and submodules generate friction for contributors. +- **Build-time fetch**: breaks deterministic builds (same SHA can produce different images across time). + +### Backend + +**New service: `backend/src/trove/services/catalog.py`** + +Module-level cache of the parsed registry plus on-demand YAML reading: + +```python +@dataclass(frozen=True, slots=True) +class CatalogEntry: + slug: str + display_name: str + description: str + categories: list[Category] + yaml_file: str + mirrors: list[str] + default_mirror: str + protocol: Protocol + +def load_catalog() -> dict[str, CatalogEntry]: ... # cached on first call +def list_entries() -> list[CatalogEntry]: ... +def get_entry(slug: str) -> CatalogEntry: ... # raises KeyError +def read_yaml(slug: str) -> str: ... # reads file from disk +``` + +The cache invalidates only when the module reloads (i.e. never in production, always in tests that clear `sys.modules`). + +**New endpoints in `backend/src/trove/api/indexers.py`** + +``` +GET /api/indexers/catalog → list[CatalogEntryOut] +POST /api/indexers/catalog/{slug} → IndexerOut (201) + body: { base_url: str, name: str | null } +``` + +The API layer exposes a separate `CatalogEntryOut` Pydantic model that mirrors `CatalogEntry`'s fields and adds `already_installed: bool`. `already_installed` is computed at request time by querying `IndexerRow.catalog_slug == slug` (see marker column below) — a single row is enough, we don't count duplicates. + +**Tracking which rows came from the catalog** + +To compute `already_installed`, we need to know which existing Cardigann indexers originated from the catalog. Options considered: + +- Matching by `name == display_name` is fragile (user may have renamed). +- Matching by `base_url` within the entry's `mirrors` list is more robust but still ambiguous if two entries share a mirror (none currently do). +- **Chosen:** add a nullable `catalog_slug: str | None` column to `IndexerRow`. Set on catalog-installed rows, `null` for hand-added rows. Exact-match lookup is O(1) and survives renames. + +This requires an Alembic migration adding the column; existing rows get `null`. + +**`POST /api/indexers/catalog/{slug}` implementation** + +1. `entry = catalog.get_entry(slug)` → 404 if unknown. +2. Validate `body.base_url in entry.mirrors` → 422 otherwise. Prevents using the catalog endpoint as a generic indexer-creator with arbitrary URLs. +3. `yaml_text = catalog.read_yaml(slug)`; parse via `load_definition_yaml`. Errors here are our bug, not the user's — 500 with a diagnostic message. +4. Resolve `name`: prefer `body.name`, otherwise `entry.display_name`. If taken, append `-2`, `-3`, … until free (silent dedup, not a 409). +5. Construct `IndexerRow` with `type="cardigann"`, `protocol=entry.protocol`, `base_url=body.base_url`, `definition_yaml=yaml_text`, `catalog_slug=slug`, empty credentials, enabled=True, default priority. +6. Insert, commit, return `IndexerOut`. + +### Frontend + +**New page: `web/src/routes/indexers/catalog/+page.svelte`** + +Grid of tiles, one per catalog entry. Each tile shows: + +- Generic icon (Lucide `Database`) in v1 — no per-site logos. +- Display name. +- Description. +- Category badges. +- Mirror ` + {#each entry.mirrors as mirror (mirror)} + + {/each} + + + + {#if entry.already_installed} + + {:else} + + {/if} + + {#if perEntryError[entry.slug]} +
{perEntryError[entry.slug]}
+ {/if} + + {/each} + + {/if} + +``` + +- [ ] **Step 2: Typecheck** + +Run: `cd web && pnpm check` +Expected: no errors. + +- [ ] **Step 3: Start backend + frontend, smoke-test by hand** + +Run (two terminals): +- Backend: `cd backend && uv run uvicorn trove.main:app --reload` +- Frontend: `cd web && pnpm dev` + +Browse to `http://localhost:5173/indexers/catalog`. Log in if prompted. Verify all 12 tiles render with mirror dropdowns and an enabled **Add** button. Click **Add** on one site. Verify the button flips to **Installed**. Visit `/indexers` — the new row appears with the correct name and URL. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/routes/indexers/catalog/+page.svelte +git commit -m "feat(web): /indexers/catalog tile grid for catalog entries" +``` + +--- + +## Task 19: Add "Browse catalog" button on `/indexers` + +**Files:** +- Modify: `web/src/routes/indexers/+page.svelte:243-249` + +- [ ] **Step 1: Add the button** + +Edit `web/src/routes/indexers/+page.svelte` — replace the single header-action block: + +```svelte + +``` + +with a two-button cluster: + +```svelte +
+ + Browse catalog + + +
+``` + +(`Database` is already imported from `lucide-svelte` in this file; no new import required.) + +- [ ] **Step 2: Visual smoke-test** + +With both servers still running, refresh `/indexers`. Verify the new **Browse catalog** button appears next to **Add indexer** and navigates to `/indexers/catalog`. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/routes/indexers/+page.svelte +git commit -m "feat(web): browse catalog button on /indexers" +``` + +--- + +## Task 20: Add onboarding step for catalog + +**Files:** +- Modify: `web/src/routes/onboarding/+page.svelte` + +- [ ] **Step 1: Add the new step to the Step union** + +Edit line 28 — change: + +```typescript + type Step = "welcome" | "client" | "indexer" | "ai" | "tmdb" | "done"; +``` + +to: + +```typescript + type Step = "welcome" | "client" | "indexer" | "catalog" | "ai" | "tmdb" | "done"; +``` + +- [ ] **Step 2: Add state + helpers for the step** + +In the ` + +
+
+ + Indexers + +
+

Catalog

+

+ One-click install for public, no-account torrent sites. +

+
+
+ + {#if loading} +
+ Loading… +
+ {:else if errorMsg} +
+ {errorMsg} +
+ {:else} +
+ {#each entries as entry (entry.slug)} +
+
+
+ +
+
+
{entry.display_name}
+
+ {#each entry.categories as cat (cat)} + + {cat} + + {/each} +
+
+
+

{entry.description}

+ + + + {#if entry.already_installed} + + {:else} + + {/if} + + {#if perEntryError[entry.slug]} +
{perEntryError[entry.slug]}
+ {/if} +
+ {/each} +
+ {/if} +
From f43ac44b6881db51c1256863802b06099f1cb77d Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 13:18:44 +0200 Subject: [PATCH 26/39] feat(web): browse catalog button on /indexers --- web/src/routes/indexers/+page.svelte | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/web/src/routes/indexers/+page.svelte b/web/src/routes/indexers/+page.svelte index dadb8df..c8293e6 100644 --- a/web/src/routes/indexers/+page.svelte +++ b/web/src/routes/indexers/+page.svelte @@ -240,12 +240,20 @@ Newznab/Torznab APIs and Cardigann-style YAML trackers.

- +
+ + Browse catalog + + +
{#if loading} From c81fff19429e405a68ce52a2865ca7bfe6b5520c Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 13:24:11 +0200 Subject: [PATCH 27/39] refactor(web): use reactive reassignment + targeted update on catalog install --- web/src/routes/indexers/catalog/+page.svelte | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/web/src/routes/indexers/catalog/+page.svelte b/web/src/routes/indexers/catalog/+page.svelte index 8de39d3..ba4adfa 100644 --- a/web/src/routes/indexers/catalog/+page.svelte +++ b/web/src/routes/indexers/catalog/+page.svelte @@ -30,16 +30,21 @@ async function install(entry: CatalogEntryOut) { installingSlug = entry.slug; - perEntryError[entry.slug] = ""; + perEntryError = { ...perEntryError, [entry.slug]: "" }; try { await api.indexers.catalog.install(entry.slug, { base_url: selectedMirror[entry.slug], name: null }); - await load(); + entries = entries.map((e) => + e.slug === entry.slug ? { ...e, already_installed: true } : e + ); } catch (e) { const err = e as { detail?: string }; - perEntryError[entry.slug] = err.detail ?? "Install failed."; + perEntryError = { + ...perEntryError, + [entry.slug]: err.detail ?? "Install failed." + }; } finally { installingSlug = null; } From 23213a98b03caf22364f610a98a7d2356187d07c Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 13:28:47 +0200 Subject: [PATCH 28/39] feat(onboarding): public-sites step between indexer and AI Co-Authored-By: Claude Sonnet 4.6 --- web/src/routes/onboarding/+page.svelte | 142 ++++++++++++++++++++++++- 1 file changed, 138 insertions(+), 4 deletions(-) diff --git a/web/src/routes/onboarding/+page.svelte b/web/src/routes/onboarding/+page.svelte index 1dc3f5f..b386d7b 100644 --- a/web/src/routes/onboarding/+page.svelte +++ b/web/src/routes/onboarding/+page.svelte @@ -7,7 +7,8 @@ type IndexerType, type Protocol, type DownloadClientOut, - type IndexerOut + type IndexerOut, + type CatalogEntryOut } from "$lib/api"; import { CLIENT_TYPES } from "$lib/clientTypes"; import { @@ -25,7 +26,7 @@ Trash2 } from "lucide-svelte"; - type Step = "welcome" | "client" | "indexer" | "ai" | "tmdb" | "done"; + type Step = "welcome" | "client" | "indexer" | "catalog" | "ai" | "tmdb" | "done"; let step = $state("welcome"); let clients = $state([]); @@ -78,6 +79,61 @@ let indexerError = $state(null); let indexerJustSaved = $state(null); + // Catalog step state + let catalogEntries = $state([]); + let catalogLoading = $state(false); + let catalogSelected = $state>(new Set()); + let catalogInstalling = $state(false); + let catalogError = $state(null); + let catalogLoaded = $state(false); + + $effect(() => { + if (step === "catalog") loadCatalog(); + }); + + async function loadCatalog() { + if (catalogLoaded || catalogLoading) return; + catalogLoading = true; + try { + catalogEntries = await api.indexers.catalog.list(); + catalogLoaded = true; + } catch (e) { + const err = e as { detail?: string }; + catalogError = err.detail ?? "Failed to load catalog."; + } finally { + catalogLoading = false; + } + } + + function toggleCatalog(slug: string) { + const next = new Set(catalogSelected); + if (next.has(slug)) next.delete(slug); + else next.add(slug); + catalogSelected = next; + } + + async function installSelectedCatalog() { + catalogInstalling = true; + catalogError = null; + for (const entry of catalogEntries) { + if (!catalogSelected.has(entry.slug) || entry.already_installed) continue; + try { + await api.indexers.catalog.install(entry.slug, { + base_url: entry.default_mirror, + name: null + }); + } catch (e) { + const err = e as { detail?: string }; + catalogError = `${entry.display_name}: ${err.detail ?? "install failed"}`; + catalogInstalling = false; + return; + } + } + catalogInstalling = false; + indexers = await api.indexers.list(); + step = "ai"; + } + // AI state let aiTesting = $state(false); let aiResult = $state<{ ok: boolean; msg: string } | null>(null); @@ -242,6 +298,7 @@ { key: "welcome", label: "Welcome", icon: Sparkles }, { key: "client", label: "Download client", icon: Download }, { key: "indexer", label: "Indexer", icon: Database }, + { key: "catalog", label: "Public sites", icon: Database }, { key: "ai", label: "AI", icon: Sparkles }, { key: "tmdb", label: "Discover", icon: Sparkles }, { key: "done", label: "Done", icon: PartyPopper } @@ -584,7 +641,7 @@ @@ -663,7 +720,7 @@ @@ -680,6 +737,83 @@ {/if} + {#if step === "catalog"} +
+
+
+

+ Public torrent sites (optional) +

+

+ Pick any public sites you want Trove to search. You can always add more later. +

+
+
+ + {#if catalogLoading} +
Loading catalog…
+ {:else} +
+ {#each catalogEntries as entry (entry.slug)} + + {/each} +
+ {/if} + + {#if catalogError} +
+ {catalogError} +
+ {/if} + +
+ + +
+
+ {/if} + {#if step === "ai"}

From 8c8d534ba507801a9b5607cf6a5913ef1ea45751 Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 16:30:00 +0200 Subject: [PATCH 29/39] test: parametrized catalog fixture extraction harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds backend/tests/test_catalog_fixtures.py, parametrized over any backend/tests/fixtures/catalog/-search.html files present. Zero parametrizations for now — no fixture was captured. TPB was attempted via tpb.party but the vendored YAML uses the apibay.org JSON API, not HTML parsing, so captured HTML cannot be exercised by the row extractor. Similarly, thepiratebay.org itself returns a JS-rendered shell page (<5 KB). The harness is still wired correctly and will activate as soon as an HTML-based site fixture is dropped in the fixtures directory. Also adds TODO.md with a reminder to capture fixtures for the remaining 11 catalog slugs. Co-Authored-By: Claude Sonnet 4.6 --- TODO.md | 9 +++++ backend/tests/fixtures/catalog/README.md | 16 ++++++++ backend/tests/test_catalog_fixtures.py | 50 ++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 TODO.md create mode 100644 backend/tests/fixtures/catalog/README.md create mode 100644 backend/tests/test_catalog_fixtures.py diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..44edd36 --- /dev/null +++ b/TODO.md @@ -0,0 +1,9 @@ +# TODO + +## Catalog fixtures + +- [ ] Capture `-search.html` fixtures for the 11 remaining catalog slugs. + Pattern: `backend/tests/fixtures/catalog/-search.html`. + Extraction test lives in `backend/tests/test_catalog_fixtures.py` and + is parametrized over whichever fixtures are present. When captured, + the test runs `_extract_release` against each row and asserts ≥1 result. diff --git a/backend/tests/fixtures/catalog/README.md b/backend/tests/fixtures/catalog/README.md new file mode 100644 index 0000000..98ff0ec --- /dev/null +++ b/backend/tests/fixtures/catalog/README.md @@ -0,0 +1,16 @@ +# Catalog fixture HTML + +One `-search.html` file per site, containing a real search-results page captured from the site's response to a benign query (e.g. "ubuntu", "debian", "linux"). + +## How to capture + +```bash +# Example: TPB +curl -sL 'https://thepiratebay.org/search/ubuntu/0/99/0' \ + -H 'User-Agent: Mozilla/5.0' \ + > thepiratebay-search.html +``` + +Pick a query whose results are unambiguous and stable (distro ISOs, commonly-seeded old scene releases). The fixture is committed — keep it small (<500 KB) by trimming or using a narrow query. + +Re-capture whenever `scripts/update-catalog.py diff` shows the upstream YAML has changed *and* the corresponding fixture test starts failing. diff --git a/backend/tests/test_catalog_fixtures.py b/backend/tests/test_catalog_fixtures.py new file mode 100644 index 0000000..e007f85 --- /dev/null +++ b/backend/tests/test_catalog_fixtures.py @@ -0,0 +1,50 @@ +"""Parse-each-fixture smoke test. + +For every slug in the catalog that has a corresponding +`tests/fixtures/catalog/-search.html` file, this test: + - loads the vendored YAML + - runs the Cardigann row extractor against the fixture + - asserts at least one release with a non-empty title + +Missing fixtures are silently skipped — run `pytest -vv` to see which +slugs are covered. Capturing real HTML fixtures is a one-time manual +task per site; tests pass until a fixture is captured AND its extraction +breaks. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from bs4 import BeautifulSoup + +from trove.indexers.cardigann import CardigannIndexer, load_definition_yaml +from trove.services import catalog + +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "catalog" + + +def _covered_slugs() -> list[str]: + catalog.reset_cache_for_tests() + return [ + e.slug for e in catalog.list_entries() if (FIXTURE_DIR / f"{e.slug}-search.html").exists() + ] + + +@pytest.mark.parametrize("slug", _covered_slugs()) +def test_fixture_extracts_at_least_one_release(slug: str) -> None: + entry = catalog.get_entry(slug) + definition = load_definition_yaml(catalog.read_yaml(slug)) + definition.name = slug + driver = CardigannIndexer(definition, base_url=entry.default_mirror) + + html = (FIXTURE_DIR / f"{slug}-search.html").read_text(encoding="utf-8") + soup = BeautifulSoup(html, "lxml") + rows = soup.select(definition.rows_selector) + assert rows, f"{slug}: rows_selector {definition.rows_selector!r} matched no elements" + + extracted = [driver._extract_release(r) for r in rows] + extracted = [r for r in extracted if r is not None] + assert extracted, f"{slug}: 0 releases extracted from {len(rows)} row(s)" + assert extracted[0].title, f"{slug}: first release has empty title" From 4bc31bace11777e82a9c07b77298c00fabee4b7e Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 16:30:25 +0200 Subject: [PATCH 30/39] docs: document public-site catalog in indexers guide Adds a "Catalog (public sites, one click)" section to 03-indexers.md immediately before "Priority and ordering", covering how to install from the catalog, what the onboarding wizard does, and how vendored YAMLs are updated. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/trove/docs/03-indexers.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/backend/src/trove/docs/03-indexers.md b/backend/src/trove/docs/03-indexers.md index 78ed649..c1c977a 100644 --- a/backend/src/trove/docs/03-indexers.md +++ b/backend/src/trove/docs/03-indexers.md @@ -100,6 +100,22 @@ For trackers that don't have a native API, Cardigann uses YAML definition files - Custom headers beyond what httpx sends by default - JavaScript-heavy sites that need Playwright/Puppeteer +## Catalog (public sites, one click) + +For public, no-account torrent sites, Trove ships with a curated catalog of pre-configured definitions including The Pirate Bay, 1337x, TorrentGalaxy, LimeTorrents, Nyaa, EZTV, YTS, and others. Five slugs map to substitute definitions (ExtraTorrent, KickAssTorrents, TorrentDownload, TorrentProject2, Tokyo Toshokan) because the original sites have no upstream Cardigann YAML — each entry honestly declares its actual source in the description. + +**How to install:** + +1. On `/indexers`, click **Browse catalog** (next to **Add indexer**). +2. Pick a mirror from the dropdown on the site's tile — catalog entries list multiple known mirrors for sites that have them. +3. Click **Add**. The tile flips to **Installed** and the site appears on `/indexers` as a normal Cardigann indexer. + +The onboarding wizard also surfaces these sites as an optional step — pick any you want in one go. + +**Behind the scenes**: a catalog-installed entry is an ordinary `type=cardigann` indexer with a vendored YAML definition. The Test, Edit, and Delete buttons work the same way they do for hand-added indexers. If you ever need to override the URL or rename the entry, use **Edit** — nothing is special about catalog rows. + +**Updating definitions**: the shipped YAMLs track `Prowlarr/Indexers`. When a site changes its HTML, searches will start returning 0 results. Upstream usually has a fix within days. Trove refreshes the vendored files on each release; to sync sooner, a maintainer can run `scripts/update-catalog.py diff` to see what's changed upstream, then `sync` to pull. + ## Priority and ordering Each indexer has a **priority** field (default 50). Lower numbers run first in the fan-out, but since Trove queries all enabled indexers in parallel, priority mainly matters for tie-breaking when the same release appears from multiple sources — the higher-priority indexer's copy is kept. From dff95b96e0e12bf0965ac929ee1c562aeb182a88 Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 16:34:02 +0200 Subject: [PATCH 31/39] docs: temper aspirational claim about catalog refresh automation --- backend/src/trove/docs/03-indexers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/trove/docs/03-indexers.md b/backend/src/trove/docs/03-indexers.md index c1c977a..4f59d96 100644 --- a/backend/src/trove/docs/03-indexers.md +++ b/backend/src/trove/docs/03-indexers.md @@ -114,7 +114,7 @@ The onboarding wizard also surfaces these sites as an optional step — pick any **Behind the scenes**: a catalog-installed entry is an ordinary `type=cardigann` indexer with a vendored YAML definition. The Test, Edit, and Delete buttons work the same way they do for hand-added indexers. If you ever need to override the URL or rename the entry, use **Edit** — nothing is special about catalog rows. -**Updating definitions**: the shipped YAMLs track `Prowlarr/Indexers`. When a site changes its HTML, searches will start returning 0 results. Upstream usually has a fix within days. Trove refreshes the vendored files on each release; to sync sooner, a maintainer can run `scripts/update-catalog.py diff` to see what's changed upstream, then `sync` to pull. +**Updating definitions**: the shipped YAMLs track `Prowlarr/Indexers`. When a site changes its HTML, searches will start returning 0 results. Upstream usually has a fix within days. A maintainer can run `scripts/update-catalog.py diff` to see what's changed upstream, then `sync` to pull — this is manual today, typically done alongside a release. ## Priority and ordering From 3c9d514ddc7eaaf4b04c40de4fc3399a59a12934 Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 16:52:26 +0200 Subject: [PATCH 32/39] feat(cardigann): parse settings.default into config_defaults Co-Authored-By: Claude Sonnet 4.6 --- backend/src/trove/indexers/cardigann.py | 177 ++++++++++++++++++++-- backend/tests/test_cardigann_templates.py | 89 +++++++++++ 2 files changed, 250 insertions(+), 16 deletions(-) create mode 100644 backend/tests/test_cardigann_templates.py diff --git a/backend/src/trove/indexers/cardigann.py b/backend/src/trove/indexers/cardigann.py index 389dc8d..066413d 100644 --- a/backend/src/trove/indexers/cardigann.py +++ b/backend/src/trove/indexers/cardigann.py @@ -66,6 +66,7 @@ class CardigannDefinition: fields: dict[str, FieldSpec] category_mapping: dict[str, Category] = field(default_factory=dict) protocol: Protocol = Protocol.TORRENT + config_defaults: dict[str, str] = field(default_factory=dict) # from settings: block def _coerce_field(spec_data: dict[str, Any] | str) -> FieldSpec: @@ -113,6 +114,22 @@ def load_definition(data: dict[str, Any]) -> CardigannDefinition: protocol_str = (data.get("type") or "").lower() protocol = Protocol.USENET if "usenet" in protocol_str else Protocol.TORRENT + config_defaults: dict[str, str] = {} + for item in data.get("settings") or []: + if not isinstance(item, dict): + continue + name = item.get("name") + if not isinstance(name, str): + continue + default = item.get("default") + if default is None: + continue + # Normalize bools to Go-template casing + if isinstance(default, bool): + config_defaults[name] = "True" if default else "False" + else: + config_defaults[name] = str(default) + return CardigannDefinition( site=str(data.get("site", "")), name=str(data.get("name") or data.get("site", "")), @@ -123,6 +140,7 @@ def load_definition(data: dict[str, Any]) -> CardigannDefinition: fields=fields_map, category_mapping=category_mapping, protocol=protocol, + config_defaults=config_defaults, ) @@ -133,6 +151,104 @@ def load_definition_yaml(text: str) -> CardigannDefinition: return load_definition(data) +# --------------------------------------------------------------------------- +# Minimal Go-template subset evaluator for Prowlarr YAMLs. +# +# Supported: +# {{ .Keywords }} -> query terms or "" +# {{ .Query.IMDBID }}, {{ .Query.TMDBID }} -> always "" +# {{ .Config.X }} -> config[X] or "" +# {{ if .Keywords }}A{{ else }}B{{ end }} -> A if keywords non-empty, else B +# {{ if and .Keywords ... }}A{{ else }}B{{ end }}-> same (conservative: keywords-present check) +# {{ if or .Query.IMDBID .Keywords }}...{{ end }}-> keywords-present check +# {{ range .Categories }}...{{ end }} -> "" (categories not piped through) +# {{ join .Categories "," }} -> "" +# +# NOT supported: arbitrary nested ifs, function calls inside template +# expressions, range loops over arbitrary collections. +# Unrecognized templates pass through unchanged (never crash). +# --------------------------------------------------------------------------- + +_IF_ELSE_END_RE = re.compile( + r"\{\{\s*if\s+(.+?)\s*\}\}(.*?)\{\{\s*else\s*\}\}(.*?)\{\{\s*end\s*\}\}", + re.DOTALL, +) +_IF_END_RE = re.compile( + r"\{\{\s*if\s+(.+?)\s*\}\}(.*?)\{\{\s*end\s*\}\}", + re.DOTALL, +) +_RANGE_END_RE = re.compile( + r"\{\{\s*range\s+.+?\s*\}\}.*?\{\{\s*end\s*\}\}", + re.DOTALL, +) +_JOIN_RE = re.compile( + r'\{\{\s*join\s+\.Categories\s+"[^"]*"\s*\}\}', +) +_KEYWORDS_RE = re.compile(r"\{\{\s*\.Keywords\s*\}\}") +_QUERY_IMDB_RE = re.compile(r"\{\{\s*\.Query\.(IMDBID|TMDBID|TVDBID)\s*\}\}") +_CONFIG_RE = re.compile(r"\{\{\s*\.Config\.([\w\-]+)\s*\}\}") + + +def expand_template( + text: str, + *, + keywords: str = "", + config: dict[str, str] | None = None, +) -> str: + """Expand a Cardigann/Go-template string. + + See the module comment above for the supported subset. Anything unrecognized + passes through unchanged so upstream failures are visible rather than + silently producing wrong URLs. + """ + if "{{" not in text: + return text + cfg = config or {} + keywords_present = bool(keywords) + + # Drop range blocks entirely (categories-iter is the only real use). + text = _RANGE_END_RE.sub("", text) + # join .Categories -> empty + text = _JOIN_RE.sub("", text) + + # Repeatedly resolve if/else/end from the innermost occurrence outward. + # Prowlarr chains them, so loop until stable. + for _ in range(10): + before = text + + def _ifelse(m: re.Match[str]) -> str: # noqa: E306 + return m.group(2) if keywords_present else m.group(3) + + text = _IF_ELSE_END_RE.sub(_ifelse, text) + if text == before: + break + + # Bare {{ if ... }}X{{ end }} without else -> X if keywords else "" + for _ in range(10): + before = text + + def _if(m: re.Match[str]) -> str: # noqa: E306 + return m.group(2) if keywords_present else "" + + text = _IF_END_RE.sub(_if, text) + if text == before: + break + + # Variable substitutions. + text = _KEYWORDS_RE.sub(lambda _: keywords, text) + text = _QUERY_IMDB_RE.sub("", text) + + def _cfg(m: re.Match[str]) -> str: + return cfg.get(m.group(1), "") + + text = _CONFIG_RE.sub(_cfg, text) + + # .Result.* is not a request-time substitution — leave intact for + # field-extraction-time expansion (a separate call site). + + return text + + def _map_category(cat_id: int) -> Category | None: if 2000 <= cat_id < 3000: return Category.MOVIES @@ -183,16 +299,19 @@ async def test_connection(self) -> IndexerHealth: return IndexerHealth(ok=True) async def search(self, query: SearchQuery) -> list[Release]: + cfg = self.definition.config_defaults + kw = query.terms or "" params: dict[str, Any] = {} for key, template in (self.definition.search_params or {}).items(): if isinstance(template, str): - params[key] = template.replace("{{.Query.Keywords}}", query.terms) + params[key] = expand_template(template, keywords=kw, config=cfg) else: params[key] = template if "q" not in params and "search" not in params and "query" not in params: - params["q"] = query.terms + params["q"] = kw - url = self.base_url + self.definition.search_path + path = expand_template(self.definition.search_path, keywords=kw, config=cfg) + url = self.base_url + path try: resp = await self._client.get(url, params=params) except httpx.HTTPError as e: @@ -201,28 +320,37 @@ async def search(self, query: SearchQuery) -> list[Release]: raise IndexerError(f"{self.name}: HTTP {resp.status_code}") soup = BeautifulSoup(resp.text, "lxml") - rows = soup.select(self.definition.rows_selector) + rows_selector = expand_template(self.definition.rows_selector, keywords=kw, config=cfg) + rows = soup.select(rows_selector) releases: list[Release] = [] for row in rows[: query.limit]: - release = self._extract_release(row) + release = self._extract_release(row, keywords=kw, config=cfg) if release is not None: releases.append(release) return releases - def _extract_release(self, row: Tag) -> Release | None: - title = self._extract_field(row, "title") + def _extract_release( + self, + row: Tag, + *, + keywords: str = "", + config: dict[str, str] | None = None, + ) -> Release | None: + title = self._extract_field(row, "title", keywords=keywords, config=config) if not title: return None - download_url = self._extract_field(row, "download") or self._extract_field(row, "details") + download_url = self._extract_field( + row, "download", keywords=keywords, config=config + ) or self._extract_field(row, "details", keywords=keywords, config=config) if download_url and not download_url.startswith(("http://", "https://", "magnet:")): download_url = self.base_url + ( download_url if download_url.startswith("/") else f"/{download_url}" ) - size = _parse_size(self._extract_field(row, "size")) - infohash = self._extract_field(row, "infohash") or None - category = self._extract_field(row, "category") + size = _parse_size(self._extract_field(row, "size", keywords=keywords, config=config)) + infohash = self._extract_field(row, "infohash", keywords=keywords, config=config) or None + category = self._extract_field(row, "category", keywords=keywords, config=config) return Release( title=title, @@ -234,7 +362,14 @@ def _extract_release(self, row: Tag) -> Release | None: source=self.name, ) - def _extract_field(self, row: Tag, key: str) -> str | None: + def _extract_field( + self, + row: Tag, + key: str, + *, + keywords: str = "", + config: dict[str, str] | None = None, + ) -> str | None: spec = self.definition.fields.get(key) if spec is None: return None @@ -242,14 +377,24 @@ def _extract_field(self, row: Tag, key: str) -> str | None: return spec.text target: Tag | None = row - if spec.selector: - target = row.select_one(spec.selector) + selector = ( + expand_template(spec.selector, keywords=keywords, config=(config or {})) + if spec.selector + else None + ) + if selector: + target = row.select_one(selector) if target is None: return None value: str | None - if spec.attribute: - raw = target.get(spec.attribute) + attribute = ( + expand_template(spec.attribute, keywords=keywords, config=(config or {})) + if spec.attribute + else None + ) + if attribute: + raw = target.get(attribute) value = (raw[0] if raw else None) if isinstance(raw, list) else raw else: value = target.get_text(" ", strip=True) diff --git a/backend/tests/test_cardigann_templates.py b/backend/tests/test_cardigann_templates.py new file mode 100644 index 0000000..1b35200 --- /dev/null +++ b/backend/tests/test_cardigann_templates.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import yaml as _yaml + +from trove.indexers.cardigann import expand_template, load_definition + + +def test_load_definition_parses_settings_defaults() -> None: + raw = _yaml.safe_load(""" +id: test +name: test +links: [https://test.local] +type: public +settings: + - name: sort + type: select + default: time + - name: disablesort + type: checkbox + default: False + - name: no_default_item + type: text +caps: + categorymappings: [] +search: + paths: + - path: / + rows: + selector: tr + fields: {} +""") + d = load_definition(raw) + assert d.config_defaults["sort"] == "time" + assert d.config_defaults["disablesort"] == "False" + assert "no_default_item" not in d.config_defaults + + +def test_expand_keywords_substitution() -> None: + assert expand_template("search/{{ .Keywords }}/1/", keywords="ubuntu") == "search/ubuntu/1/" + + +def test_expand_if_keywords_else() -> None: + tmpl = "{{ if .Keywords }}search/{{ .Keywords }}{{ else }}latest{{ end }}" + assert expand_template(tmpl, keywords="ubuntu") == "search/ubuntu" + assert expand_template(tmpl, keywords="") == "latest" + + +def test_expand_config_substitution() -> None: + cfg = {"sort": "time", "apiurl": "example.com"} + assert expand_template("{{ .Config.sort }}", config=cfg) == "time" + assert expand_template("https://{{ .Config.apiurl }}/x", config=cfg) == "https://example.com/x" + assert expand_template("{{ .Config.missing }}", config=cfg) == "" + + +def test_expand_query_imdb_is_empty() -> None: + assert expand_template("id:{{ .Query.IMDBID }}", keywords="x") == "id:" + + +def test_expand_range_categories_is_empty() -> None: + tmpl = "path/{{ range .Categories }}:cat:{{.}}{{ end }}" + assert expand_template(tmpl, keywords="x") == "path/" + + +def test_expand_join_categories_is_empty() -> None: + assert expand_template('cats={{ join .Categories "," }}', keywords="x") == "cats=" + + +def test_expand_complex_1337x_path() -> None: + # Sample pulled from 1337x.yml + cfg = {"sort": "time", "type": "desc", "disablesort": "False"} + tmpl = ( + "{{ if and (.Keywords) (eq .Config.disablesort .False) }}sort-{{ else }}{{ end }}" + "{{ if .Keywords }}search/{{ .Keywords }}{{ else }}cat/Movies{{ end }}" + "{{ if and (.Keywords) (eq .Config.disablesort .False) }}/{{ .Config.sort }}/{{ .Config.type }}{{ else }}{{ end }}" + "/1/" + ) + result = expand_template(tmpl, keywords="ubuntu", config=cfg) + # Expected: "sort-search/ubuntu/time/desc/1/" + assert result == "sort-search/ubuntu/time/desc/1/" + + +def test_expand_no_template_passes_through() -> None: + assert expand_template("/") == "/" + assert expand_template("search/static/path") == "search/static/path" + + +def test_expand_returns_unchanged_on_unknown_directive() -> None: + # Unknown pipeline -> untouched + assert "{{ weird_directive" in expand_template("{{ weird_directive }}", keywords="x") From 58b69648ac0b02e92e3c8ed8274442562246e14e Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 16:52:57 +0200 Subject: [PATCH 33/39] feat(cardigann): minimal Go template expander + wire into search path, params, selectors Co-Authored-By: Claude Sonnet 4.6 --- backend/tests/test_cardigann_templates.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/tests/test_cardigann_templates.py b/backend/tests/test_cardigann_templates.py index 1b35200..75fa62a 100644 --- a/backend/tests/test_cardigann_templates.py +++ b/backend/tests/test_cardigann_templates.py @@ -87,3 +87,16 @@ def test_expand_no_template_passes_through() -> None: def test_expand_returns_unchanged_on_unknown_directive() -> None: # Unknown pipeline -> untouched assert "{{ weird_directive" in expand_template("{{ weird_directive }}", keywords="x") + + +def test_1337x_url_construction_uses_expanded_template() -> None: + from trove.indexers.cardigann import CardigannIndexer, load_definition_yaml + from trove.services import catalog + + catalog.reset_cache_for_tests() + d = load_definition_yaml(catalog.read_yaml("1337x")) + drv = CardigannIndexer(d, base_url="https://1337x.to") + path = expand_template(d.search_path, keywords="ubuntu", config=d.config_defaults) + assert "{{" not in path, f"path still has templates: {path}" + assert "ubuntu" in path + assert path.startswith("/") or "search" in path or "sort-" in path From 9e4854b6058db958d6a9b1bde95e362f9e7c85d5 Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 16:53:58 +0200 Subject: [PATCH 34/39] refactor: drop TPB + YTS from catalog (JSON APIs, need native drivers) Co-Authored-By: Claude Sonnet 4.6 --- .../src/trove/indexers/catalog/registry.yaml | 24 -- .../trove/indexers/catalog/thepiratebay.yml | 216 ------------------ backend/src/trove/indexers/catalog/yts.yml | 140 ------------ backend/tests/api/test_catalog_api.py | 36 +-- backend/tests/test_catalog.py | 4 +- 5 files changed, 20 insertions(+), 400 deletions(-) delete mode 100644 backend/src/trove/indexers/catalog/thepiratebay.yml delete mode 100644 backend/src/trove/indexers/catalog/yts.yml diff --git a/backend/src/trove/indexers/catalog/registry.yaml b/backend/src/trove/indexers/catalog/registry.yaml index c84f22a..55a9aa0 100644 --- a/backend/src/trove/indexers/catalog/registry.yaml +++ b/backend/src/trove/indexers/catalog/registry.yaml @@ -6,19 +6,6 @@ # so scripts/update-catalog.py can diff against Prowlarr-indexers. entries: - - slug: thepiratebay - display_name: The Pirate Bay - description: General-purpose public torrent tracker, no account required. - categories: [movies, tv, music, software, games, books, other] - yaml_file: thepiratebay.yml - upstream_path: definitions/v11/thepiratebay.yml - mirrors: - - https://thepiratebay.org - - https://tpb.party - - https://piratebay.live - default_mirror: https://thepiratebay.org - protocol: torrent - - slug: 1337x display_name: 1337x description: Large public general-purpose tracker with strong TV/movie scene coverage. @@ -124,17 +111,6 @@ entries: default_mirror: https://eztv.re protocol: torrent - - slug: yts - display_name: YTS - description: Public tracker specializing in small-size movie encodes. - categories: [movies] - yaml_file: yts.yml - upstream_path: definitions/v11/yts.yml - mirrors: - - https://yts.mx - default_mirror: https://yts.mx - protocol: torrent - - slug: animetosho display_name: Tokyo Toshokan description: Public BitTorrent library for Japanese media including anime (replaces animetosho which has no upstream Cardigann definition). diff --git a/backend/src/trove/indexers/catalog/thepiratebay.yml b/backend/src/trove/indexers/catalog/thepiratebay.yml deleted file mode 100644 index d9ea8d4..0000000 --- a/backend/src/trove/indexers/catalog/thepiratebay.yml +++ /dev/null @@ -1,216 +0,0 @@ ---- -id: thepiratebay -name: The Pirate Bay -description: "The Pirate Bay (TPB) is the galaxy’s most resilient Public BitTorrent site" -language: en-US -type: public -encoding: UTF-8 -links: - - https://thepiratebay.org/ - - https://thepiratebay.unblockninja.com/ - - https://thepiratebay.ninjaproxy1.com/ - - https://tpb.proxyninja.org/ - - https://thepiratebay.proxyninja.net/ - - https://thepiratebay.torrentbay.st/ - - https://tpb.skynetcloud.site/ - - https://piratehaven.xyz/ - - https://mirrorbay.top/ - - https://thepiratebay0.org/ - - https://thepiratebay10.xyz/ - - https://pirateproxylive.org/ - - https://thehiddenbay.com/ - - https://thepiratebay.zone/ - - https://tpb.party/ - - https://piratebayproxy.live/ - - https://piratebay.live/ - - https://piratebay.party/ - - https://thepiratebay.party/ - - https://thepiratebaye.org/ - - https://thepiratebay.cloud/ - - https://tpb-proxy.xyz/ - - https://tpb.re/ - - https://tpirbay.site/ - - https://tpirbay.top/ - - https://tpirbay.xyz/ -legacylinks: - - https://pirate-proxy.page/ - - https://5mins.shop/ - - https://tpb.surf/ - - https://tpb.monster/ - - https://thepiratebay.host/ - - https://piratetoday.xyz/ - - https://tpb.wtf/ - - https://piratebayo3klnzokct3wt5yyxb2vpebbuyjl7m623iaxmqhsd52coid.onion.ly/ - - https://piratebayo3klnzokct3wt5yyxb2vpebbuyjl7m623iaxmqhsd52coid.tor2web.to/ - - https://piratebayo3klnzokct3wt5yyxb2vpebbuyjl7m623iaxmqhsd52coid.tor2web.link/ - - https://tpb25.ukpass.co/ - - https://tpb29.ukpass.co/ - - https://piratenow.xyz/ - - https://pirate-proxy.ink/ - - https://proxifiedpiratebay.org/ - - https://unlockedpiratebay.com/ - - https://tpb.one/ - - https://piratebayorg.net/ - - https://tpbproxy.click/ - - https://pirateproxy.live/ - - https://ukpiratebay.org/ - - https://piratebay.by/ - - https://pirate-proxy.date/ - - https://thepirateproxy.net/ - - https://thepiratebay.abcproxy.org/ - - https://tpb.proxyninja.net/ - - https://tpb31.ukpass.co/ - - https://thepiratebay10.org/ - - https://pirate-proxy.africa/ - - https://5mins.eu/ - - https://piratebay.army/ - - https://tpb-visit.me/ - - https://pirate-proxy.ong/ - -caps: - categorymappings: - # Audio - - {id: 100, cat: Audio, desc: "Audio"} - - {id: 101, cat: Audio, desc: "Music"} - - {id: 102, cat: Audio/Audiobook, desc: "Audio Books"} - - {id: 103, cat: Audio, desc: "Sound Clips"} - - {id: 104, cat: Audio/Lossless, desc: "FLAC"} - - {id: 199, cat: Audio/Other, desc: "Audio Other"} - # Video - - {id: 200, cat: Movies, desc: "Video"} - - {id: 201, cat: Movies, desc: "Movies"} - - {id: 202, cat: Movies, desc: "Movies DVDR"} - - {id: 203, cat: Audio/Video, desc: "Music Videos"} - - {id: 204, cat: Movies/Other, desc: "Movie Clips"} - - {id: 205, cat: TV, desc: "TV Shows"} - - {id: 206, cat: TV/Other, desc: "Handheld"} - - {id: 207, cat: Movies/HD, desc: "HD - Movies"} - - {id: 208, cat: TV/HD, desc: "HD - TV shows"} - - {id: 209, cat: Movies/3D, desc: "3D"} - - {id: 210, cat: Movies/SD, desc: "CAM/TS"} - - {id: 211, cat: Movies/UHD, desc: "UHD/4k - Movies"} - - {id: 212, cat: TV/UHD, desc: "UHD/4k - TV shows"} - - {id: 299, cat: Movies/Other, desc: "Video Other"} - # Applications - - {id: 300, cat: PC, desc: "Applications"} - - {id: 301, cat: PC, desc: "Windows"} - - {id: 302, cat: PC/Mac, desc: "Mac"} - - {id: 303, cat: PC, desc: "UNIX"} - - {id: 304, cat: PC/Mobile-Other, desc: "Handheld"} - - {id: 305, cat: PC/Mobile-iOS, desc: "IOS (iPad/iPhone)"} - - {id: 306, cat: PC/Mobile-Android, desc: "Android"} - - {id: 399, cat: PC, desc: "Other OS"} - # Games - - {id: 400, cat: Console, desc: "Games"} - - {id: 401, cat: PC/Games, desc: "PC"} - - {id: 402, cat: PC/Mac, desc: "Mac"} - - {id: 403, cat: Console/PS4, desc: "PSx"} - - {id: 404, cat: Console/XBox, desc: "XBOX360"} - - {id: 405, cat: Console/Wii, desc: "Wii"} - - {id: 406, cat: Console/Other, desc: "Handheld"} - - {id: 407, cat: Console/Other, desc: "IOS (iPad/iPhone)"} - - {id: 408, cat: Console/Other, desc: "Android"} - - {id: 499, cat: Console/Other, desc: "Games Other"} - # Porn - - {id: 500, cat: XXX, desc: "Porn"} - - {id: 501, cat: XXX, desc: "Movies"} - - {id: 502, cat: XXX/DVD, desc: "Movies DVDR"} - - {id: 503, cat: XXX/ImageSet, desc: "Pictures"} - - {id: 504, cat: XXX, desc: "Games"} - - {id: 505, cat: XXX/x264, desc: "HD - Movies"} - - {id: 506, cat: XXX, desc: "Movie Clips"} - - {id: 507, cat: XXX/UHD, desc: "UHD/4k - Movies"} - - {id: 599, cat: XXX/Other, desc: "Porn other"} - # Other - - {id: 600, cat: Other, desc: "Other"} - - {id: 601, cat: Books/EBook, desc: "E-books"} - - {id: 602, cat: Books/Comics, desc: "Comics"} - - {id: 603, cat: Books, desc: "Pictures"} - - {id: 604, cat: Books, desc: "Covers"} - - {id: 605, cat: Books, desc: "Physibles"} - - {id: 699, cat: Books/Other, desc: "Other Other"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: apiurl - label: API URL - type: text - default: apibay.org - - name: uploader - type: text - label: Filter by Uploader - - name: info_uploader - type: info - label: About filtering by Uploader - default: "You can filter by Uploader by entering a Case Sensitive username, or leave empty to get all results.
Note: this is the username of the Uploader and not the Groupname that often show up at the end of TPB titles, eg -MeGusta." - -search: - paths: - - path: "https://{{ .Config.apiurl }}/{{ if .Keywords }}q.php?q={{ .Keywords }}&cat={{ join .Categories \",\" }}{{ else }}precompiled/data_top100_recent.json{{ end }}" - response: - type: json - - keywordsfilters: - # remove it's #8829 - - name: re_replace - args: ["(?i)\\bit's\\b", ""] - # replace simplified Chinese as this confuses TPB search engine #7291 - - name: re_replace - args: ["([\\p{IsCJKUnifiedIdeographs}\\W]+)", "."] - - name: tolower - - rows: - selector: "${{ if .Config.uploader }}:has(username:contains({{ .Config.uploader }})){{ else }}{{ end }}" - count: - selector: $[0].id - - fields: - _id: - selector: id - category: - selector: category - title: - selector: name - filters: - - name: re_replace - args: ["- (\\w+-?\\w*)$", "-$1"] - - name: re_replace # Season X / Season X Complete --> S0X - args: ["(?i)\\bSeason[\\s\\.]+(\\d)([\\s\\.]+Complete)?\\b", "S0$1"] - - name: re_replace # Season XX / Season XX Complete --> SXX - args: ["(?i)\\bSeason[\\s\\.]+(\\d{1,2})([\\s\\.]+Complete)?\\b", "S$1"] - details: - text: "{{ .Config.sitelink }}description.php?id={{ .Result._id }}" - infohash: - selector: info_hash - imdbid: - selector: imdb - date: - # unix - selector: added - size: - selector: size - files: - selector: num_files - optional: true - seeders: - selector: seeders - leechers: - selector: leechers - _username: - selector: username - description: - selector: name - filters: - - name: prepend - args: "Uploader: {{ .Result._username }}
" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# json engine n/a diff --git a/backend/src/trove/indexers/catalog/yts.yml b/backend/src/trove/indexers/catalog/yts.yml deleted file mode 100644 index 77a86d9..0000000 --- a/backend/src/trove/indexers/catalog/yts.yml +++ /dev/null @@ -1,140 +0,0 @@ ---- -id: yts -name: YTS -description: "YTS is a Public torrent site specialising in HD movies of small size" -type: public -language: en-US -encoding: UTF-8 -requestDelay: 2.5 # 2.5 requests per second (2 causes problems) -links: - # if the primary domain changes then don't forget to update the details, download and poster replace args - - https://yts.bz/ - # official domain list are at https://yifystatus.com/ and official proxies list are at https://ytsproxies.com/ - - https://yts.ninjaproxy1.com/ - - https://yts.proxyninja.org/ - - https://yts.proxyninja.net/ - - https://yts.torrentbay.st/ - - https://yts.torrentsbay.org/ -legacylinks: - - https://yts.am/ # redirects to .bz - - https://yts.ag/ # redirects to .bz - - https://yts.gg/ # redirects to .bz - - https://yts.mx/ - - https://yts.mrunblock.bond/ - - https://yts.nocensor.cloud/ - - https://yts.unblockit.download/ - - https://yts.lt/ # redirects to .bz - - https://yts.unblockninja.com/ - -caps: - categorymappings: - # note: the API does not support searching with categories, so these are dummy ones for torznab compatibility - # we map these newznab cats with the returned quality value in the releases routine. - - {id: 45, cat: Movies/HD, desc: "Movies/x264/720p"} - - {id: 44, cat: Movies/HD, desc: "Movies/x264/1080p"} - - {id: 46, cat: Movies/UHD, desc: "Movies/x264/2160p"} - - {id: 47, cat: Movies/3D, desc: "Movies/x264/3D"} - - modes: - search: [q] - movie-search: [q, imdbid] - -settings: - - name: apiurl - label: API URL - type: text - default: movies-api.accel.li - -search: - paths: - - path: "https://{{ .Config.apiurl }}/api/v2/list_movies.json" - response: - type: json - - inputs: - query_term: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # without this the API sometimes returns nothing - limit: 50 - sort_by: date_added - order_by: desc - keywordsfilters: - # ignore ' (e.g. search for america's Next Top Model) - - name: re_replace - args: ["[^\\w]+", " "] - - rows: - selector: data.movies - attribute: torrents - multiple: true - # bug at YTS can return movie_count > 0 and no movie torrents #12598 - missingAttributeEqualsNoResults: true - count: - selector: data.movie_count - - fields: - _quality: - selector: quality - category: - selector: quality - case: - "720p": 45 - "1080p": 44 - "2160p": 46 - "3D": 47 - "*": 45 - _audio: - selector: audio_channels - _depth: - selector: bit_depth - _type: - selector: type - _codec: - selector: video_codec - year: - selector: ..year - title_default: - selector: ..title - filters: - - name: append - args: " ({{ .Result.year }})" - title: - selector: ..title_long - optional: true - default: "{{ .Result.title_default }}" - filters: - - name: replace - args: [":", ""] - - name: append - args: " {{ .Result._quality }} {{ if eq .Result._type \"web\" }}WEBRip{{ else }}BRRip{{ end }} {{ if eq .Result._audio \"5.1\" }}5.1 {{ else }}{{ end }}{{ if eq .Result._depth \"10\" }}10Bit {{ else }}{{ end }}{{ .Result._codec }} -YTS" - details: - selector: ..url - filters: - - name: re_replace - args: ["^https?:\\/\\/yts\\.(mx|lt|bz)\\/", "{{ .Config.sitelink }}"] # fix for 12494 - download: - selector: url - filters: - - name: re_replace - args: ["^https?:\\/\\/yts\\.(mx|lt|bz)\\/", "{{ .Config.sitelink }}"] # fix for 12494 - infohash: - selector: hash - poster: - selector: ..large_cover_image - filters: - - name: re_replace - args: ["^https?:\\/\\/yts\\.(mx|lt|bz)\\/", "{{ .Config.sitelink }}"] # fix for 12494 - imdbid: - selector: ..imdb_code - date: - selector: date_uploaded_unix - size: - selector: size_bytes - seeders: - selector: seeds - leechers: - selector: peers - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# json api v2 diff --git a/backend/tests/api/test_catalog_api.py b/backend/tests/api/test_catalog_api.py index 2a0fc6c..d241b0f 100644 --- a/backend/tests/api/test_catalog_api.py +++ b/backend/tests/api/test_catalog_api.py @@ -15,46 +15,46 @@ def test_list_catalog_returns_entries(client: TestClient) -> None: resp = client.get("/api/indexers/catalog") assert resp.status_code == 200 body = resp.json() - assert len(body) >= 12 + assert len(body) >= 10 slugs = {e["slug"] for e in body} - assert "thepiratebay" in slugs - tpb = next(e for e in body if e["slug"] == "thepiratebay") - assert tpb["already_installed"] is False - assert tpb["default_mirror"] in tpb["mirrors"] + assert "1337x" in slugs + entry = next(e for e in body if e["slug"] == "1337x") + assert entry["already_installed"] is False + assert entry["default_mirror"] in entry["mirrors"] def test_install_catalog_entry_creates_indexer(client: TestClient) -> None: _login(client) resp = client.post( - "/api/indexers/catalog/thepiratebay", - json={"base_url": "https://thepiratebay.org", "name": None}, + "/api/indexers/catalog/1337x", + json={"base_url": "https://1337x.to", "name": None}, ) assert resp.status_code == 201 body = resp.json() assert body["type"] == "cardigann" - assert body["base_url"] == "https://thepiratebay.org" - assert body["name"] == "The Pirate Bay" + assert body["base_url"] == "https://1337x.to" + assert body["name"] == "1337x" # already_installed flips on a subsequent list listing = client.get("/api/indexers/catalog").json() - tpb = next(e for e in listing if e["slug"] == "thepiratebay") - assert tpb["already_installed"] is True + entry = next(e for e in listing if e["slug"] == "1337x") + assert entry["already_installed"] is True def test_install_twice_dedups_name(client: TestClient) -> None: _login(client) first = client.post( - "/api/indexers/catalog/thepiratebay", - json={"base_url": "https://thepiratebay.org"}, + "/api/indexers/catalog/1337x", + json={"base_url": "https://1337x.to"}, ) second = client.post( - "/api/indexers/catalog/thepiratebay", - json={"base_url": "https://tpb.party"}, + "/api/indexers/catalog/1337x", + json={"base_url": "https://1337x.st"}, ) assert first.status_code == 201 assert second.status_code == 201 - assert first.json()["name"] == "The Pirate Bay" - assert second.json()["name"] == "The Pirate Bay-2" + assert first.json()["name"] == "1337x" + assert second.json()["name"] == "1337x-2" def test_install_unknown_slug_404(client: TestClient) -> None: @@ -69,7 +69,7 @@ def test_install_unknown_slug_404(client: TestClient) -> None: def test_install_rejects_base_url_not_in_mirrors(client: TestClient) -> None: _login(client) resp = client.post( - "/api/indexers/catalog/thepiratebay", + "/api/indexers/catalog/1337x", json={"base_url": "https://totally-evil-mirror.example.com"}, ) assert resp.status_code == 422 diff --git a/backend/tests/test_catalog.py b/backend/tests/test_catalog.py index 6771ad6..4fbdb91 100644 --- a/backend/tests/test_catalog.py +++ b/backend/tests/test_catalog.py @@ -16,9 +16,9 @@ def _reset_cache() -> None: def test_registry_loads() -> None: entries = catalog.list_entries() - assert len(entries) >= 12 + assert len(entries) >= 10 slugs = {e.slug for e in entries} - for required in ("thepiratebay", "1337x", "nyaa", "eztv", "yts"): + for required in ("1337x", "nyaa", "eztv", "limetorrents"): assert required in slugs, f"missing catalog entry: {required}" From 7e7441ec225f6008aaaafe1fc7b1ede8f14b8bdb Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 16:54:48 +0200 Subject: [PATCH 35/39] test: capture Nyaa search HTML fixture Co-Authored-By: Claude Sonnet 4.6 --- .../tests/fixtures/catalog/nyaa-search.html | 375 ++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 backend/tests/fixtures/catalog/nyaa-search.html diff --git a/backend/tests/fixtures/catalog/nyaa-search.html b/backend/tests/fixtures/catalog/nyaa-search.html new file mode 100644 index 0000000..067acea --- /dev/null +++ b/backend/tests/fixtures/catalog/nyaa-search.html @@ -0,0 +1,375 @@ + + + + + ubuntu :: Nyaa + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CategoryNameLinkSizeDate
+ + Software - Applications + + + Koha Live CD Release 3 (3.0.4 Ubuntu 9.10 Desktop x86) + + + + 624.0 MiB2009-11-03 07:03010
+
+ +
+
Displaying results 1-1 out of 1 results.
+Please refine your search results if you can't find what you were looking for.
+ +
+
+ + + + \ No newline at end of file From 32d00c66323f7b65c8877179804ee6d7bb32bd60 Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 16:55:10 +0200 Subject: [PATCH 36/39] docs: revise catalog list after dropping TPB and YTS Co-Authored-By: Claude Sonnet 4.6 --- backend/src/trove/docs/03-indexers.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/src/trove/docs/03-indexers.md b/backend/src/trove/docs/03-indexers.md index 4f59d96..7efa80b 100644 --- a/backend/src/trove/docs/03-indexers.md +++ b/backend/src/trove/docs/03-indexers.md @@ -89,10 +89,11 @@ For trackers that don't have a native API, Cardigann uses YAML definition files 5. Save and test **What works**: -- Static `search.paths[0].path` -- `search.rows.selector` (CSS selectors) +- `search.paths[0].path` with Go-template expansion (`{{ .Keywords }}`, `{{ if .Keywords }}…{{ else }}…{{ end }}`, `{{ .Config.X }}`) +- `search.rows.selector` (CSS selectors, including template-expanded ones) - Field extraction: `title`, `download`, `size`, `infohash`, `category` -- Basic filters: `replace`, `regexp`, `append`, `prepend` +- Basic filters: `replace`, `regexp`, `append`, `prepend`, `trim`, `tolower`, `re_replace` +- `settings:` block defaults populated automatically as `{{ .Config.X }}` values **What doesn't work yet**: - Login flows (cookie, form, POST) @@ -102,7 +103,9 @@ For trackers that don't have a native API, Cardigann uses YAML definition files ## Catalog (public sites, one click) -For public, no-account torrent sites, Trove ships with a curated catalog of pre-configured definitions including The Pirate Bay, 1337x, TorrentGalaxy, LimeTorrents, Nyaa, EZTV, YTS, and others. Five slugs map to substitute definitions (ExtraTorrent, KickAssTorrents, TorrentDownload, TorrentProject2, Tokyo Toshokan) because the original sites have no upstream Cardigann YAML — each entry honestly declares its actual source in the description. +For public, no-account torrent sites, Trove ships with a curated catalog of pre-configured definitions including 1337x, TorrentGalaxy, LimeTorrents, Nyaa, EZTV, and others. Five slugs map to substitute definitions (ExtraTorrent, KickAssTorrents, TorrentDownload, TorrentProject2, Tokyo Toshokan) because the original sites have no upstream Cardigann YAML — each entry honestly declares its actual source in the description. + +The Pirate Bay and YTS are not in the current catalog because both return JSON responses that require a native driver rather than the HTML-scraping Cardigann parser. They are candidates for a follow-up JSON-indexer batch. **How to install:** From fefd8da34f00b8cfd9cf69ad34d9f308a0683668 Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 16:56:21 +0200 Subject: [PATCH 37/39] style: ruff fix unused noqa directives and unused variable in template tests Co-Authored-By: Claude Sonnet 4.6 --- backend/src/trove/indexers/cardigann.py | 4 ++-- backend/tests/test_cardigann_templates.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/trove/indexers/cardigann.py b/backend/src/trove/indexers/cardigann.py index 066413d..2478676 100644 --- a/backend/src/trove/indexers/cardigann.py +++ b/backend/src/trove/indexers/cardigann.py @@ -216,7 +216,7 @@ def expand_template( for _ in range(10): before = text - def _ifelse(m: re.Match[str]) -> str: # noqa: E306 + def _ifelse(m: re.Match[str]) -> str: return m.group(2) if keywords_present else m.group(3) text = _IF_ELSE_END_RE.sub(_ifelse, text) @@ -227,7 +227,7 @@ def _ifelse(m: re.Match[str]) -> str: # noqa: E306 for _ in range(10): before = text - def _if(m: re.Match[str]) -> str: # noqa: E306 + def _if(m: re.Match[str]) -> str: return m.group(2) if keywords_present else "" text = _IF_END_RE.sub(_if, text) diff --git a/backend/tests/test_cardigann_templates.py b/backend/tests/test_cardigann_templates.py index 75fa62a..63ada02 100644 --- a/backend/tests/test_cardigann_templates.py +++ b/backend/tests/test_cardigann_templates.py @@ -95,7 +95,7 @@ def test_1337x_url_construction_uses_expanded_template() -> None: catalog.reset_cache_for_tests() d = load_definition_yaml(catalog.read_yaml("1337x")) - drv = CardigannIndexer(d, base_url="https://1337x.to") + CardigannIndexer(d, base_url="https://1337x.to") # verify instantiation succeeds path = expand_template(d.search_path, keywords="ubuntu", config=d.config_defaults) assert "{{" not in path, f"path still has templates: {path}" assert "ubuntu" in path From e90b45369450104a2f4badfe3171cc42973172d2 Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 17:03:49 +0200 Subject: [PATCH 38/39] feat(cardigann): support or-expressions and inline re_replace in templates Fixes torrentgalaxy and bitsearch search URLs which used template constructs the initial expander did not cover. Sanity sweep confirms all 10 catalog sites now produce URLs without unresolved template tokens. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/trove/indexers/cardigann.py | 41 +++++++++++++++++++++++ backend/tests/test_cardigann_templates.py | 34 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/backend/src/trove/indexers/cardigann.py b/backend/src/trove/indexers/cardigann.py index 2478676..e240b50 100644 --- a/backend/src/trove/indexers/cardigann.py +++ b/backend/src/trove/indexers/cardigann.py @@ -184,6 +184,16 @@ def load_definition_yaml(text: str) -> CardigannDefinition: _JOIN_RE = re.compile( r'\{\{\s*join\s+\.Categories\s+"[^"]*"\s*\}\}', ) +# Go template `or` expression used as a value (not inside an if condition). +# Example: {{ or .Query.IMDBID .Keywords }} +# Returns the first truthy arg. We only support .Query.* (always empty) and +# .Keywords — enough for the YAMLs in the current catalog. +_OR_EXPR_RE = re.compile(r"\{\{\s*or\s+(.+?)\s*\}\}") +# Go template `re_replace` function call used inline (not as a field filter). +# Example: {{ re_replace .Config.sort "_" "" }} +_RE_REPLACE_INLINE_RE = re.compile( + r'\{\{\s*re_replace\s+\.Config\.([\w\-]+)\s+"([^"]*)"\s+"([^"]*)"\s*\}\}' +) _KEYWORDS_RE = re.compile(r"\{\{\s*\.Keywords\s*\}\}") _QUERY_IMDB_RE = re.compile(r"\{\{\s*\.Query\.(IMDBID|TMDBID|TVDBID)\s*\}\}") _CONFIG_RE = re.compile(r"\{\{\s*\.Config\.([\w\-]+)\s*\}\}") @@ -234,6 +244,37 @@ def _if(m: re.Match[str]) -> str: if text == before: break + # `or` expression as value: return first truthy arg. + def _or_value(m: re.Match[str]) -> str: + args = m.group(1).split() + for arg in args: + if arg == ".Keywords": + if keywords_present: + return keywords + elif arg.startswith(".Query."): + # .Query.IMDBID, .Query.TMDBID, etc. are always empty here. + continue + elif arg.startswith(".Config."): + name = arg[len(".Config.") :] + val = cfg.get(name, "") + if val: + return val + # Unknown reference → skip and try next arg. + return "" + + text = _OR_EXPR_RE.sub(_or_value, text) + + # Inline re_replace on a Config value. + def _inline_re_replace(m: re.Match[str]) -> str: + config_name, pattern, replacement = m.group(1), m.group(2), m.group(3) + source = cfg.get(config_name, "") + try: + return re.sub(pattern, replacement, source) + except re.error: + return source + + text = _RE_REPLACE_INLINE_RE.sub(_inline_re_replace, text) + # Variable substitutions. text = _KEYWORDS_RE.sub(lambda _: keywords, text) text = _QUERY_IMDB_RE.sub("", text) diff --git a/backend/tests/test_cardigann_templates.py b/backend/tests/test_cardigann_templates.py index 63ada02..bb1c428 100644 --- a/backend/tests/test_cardigann_templates.py +++ b/backend/tests/test_cardigann_templates.py @@ -100,3 +100,37 @@ def test_1337x_url_construction_uses_expanded_template() -> None: assert "{{" not in path, f"path still has templates: {path}" assert "ubuntu" in path assert path.startswith("/") or "search" in path or "sort-" in path + + +def test_expand_or_expression_returns_first_truthy() -> None: + # Go template `or` returns first truthy arg. .Query.IMDBID is always empty, + # so `or .Query.IMDBID .Keywords` should return keywords. + tmpl = "get-posts/keywords:{{ or .Query.IMDBID .Keywords }}" + assert expand_template(tmpl, keywords="ubuntu") == "get-posts/keywords:ubuntu" + + +def test_expand_or_expression_fallback_to_keywords() -> None: + tmpl = "{{ or .Query.IMDBID .Keywords }}" + assert expand_template(tmpl, keywords="ubuntu") == "ubuntu" + # When nothing is truthy, result is empty string (last arg is also empty). + assert expand_template(tmpl, keywords="") == "" + + +def test_expand_inline_re_replace_on_config() -> None: + # bitsearch path uses: search{{ re_replace .Config.sort "_" "" }}?q=... + cfg = {"sort": "date_desc"} + tmpl = '{{ re_replace .Config.sort "_" "" }}' + assert expand_template(tmpl, keywords="x", config=cfg) == "datedesc" + + +def test_expand_bitsearch_full_path() -> None: + cfg = {"sort": "date_desc"} + tmpl = '{{ if .Keywords }}search{{ re_replace .Config.sort "_" "" }}?q={{ .Keywords }}{{ else }}/{{ end }}' + assert expand_template(tmpl, keywords="ubuntu", config=cfg) == "searchdatedesc?q=ubuntu" + + +def test_expand_torrentgalaxy_full_path() -> None: + tmpl = "get-posts/{{ if or .Query.IMDBID .Keywords }}keywords:{{ or .Query.IMDBID .Keywords }}{{ else }}{{ end }}{{ range .Categories }}:category:{{.}}{{end}}" + result = expand_template(tmpl, keywords="ubuntu") + assert result == "get-posts/keywords:ubuntu" + assert "{{" not in result From 369a165aea55eb2c972f5d921468047e0502cfce Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Mon, 20 Apr 2026 17:20:29 +0200 Subject: [PATCH 39/39] v0.11.0: public torrent-site catalog with Go template support --- backend/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2837537..06381fb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "trove" -version = "0.10.4" +version = "0.11.0" description = "A modern replacement for FlexGet with multi-indexer search, Usenet support, and optional local AI." readme = "../README.md" requires-python = ">=3.12"