From 8e67a51425d001b2be17eef7f6dcc4f40b6bb588 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 8 May 2026 15:34:11 -0700 Subject: [PATCH 1/2] refactor(release-tracking): represent compilation coverage as span lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compilation torrents (`v01-09`, `001-050 as v01-10`, the disjoint `v01-04 + v06-09` shape) used to collapse to their first value at parse time. The inbox displayed "Vol 1" for a 1-9 bundle, and auto-ignore hid entire compilations when only the starting volume was owned. Replace the single `chapter` / `volume` scalars with normalized `NumericSpan` lists end-to-end: - ReleaseCandidate (Rust + SDK): drop the scalars; carry `volumes` and `chapters` as sorted, overlap-merged span lists. Disjoint coverage survives so callers can ask "is every value owned?" honestly. - release-nyaa parser: stop min/max-aggregating tokens. Emit a span list per axis, normalized via sort + overlap merge (touching counts as overlap). Tests cover real-world disjoint and mixed-bundle titles. - release-mangaupdates: wraps its single-value parser output as one-element spans for shape compatibility. - DB: add `chapters` / `volumes` JSONB columns to `release_ledger`; backfill historic rows from the existing scalars (one-element span). Keep the scalar columns derived as `max(span.end)` so SeaORM-typed `ORDER BY` keeps working without DB-specific JSON path syntax. - DTO + UI: surface `ReleaseSpanDto[]` on the API; render `Vol 1-9`, `Ch 1-50 · Vol 1-10`, `Vol 1-4, 6-9`, etc. instead of the scalar start. - Auto-ignore: rewrite as pair-coverage. Model the release as the set of `(volume, chapter)` items it covers, and ignore only when every item is owned via exact pair, whole-volume, or no-vol-chapter rule. A `v01-10` compilation no longer auto-ignores when only vol 1 is owned; a disjoint `v01-04 + v06-09` skips the gap correctly. Tests added across the parser, plugin, host candidate, repository, auto-ignore, and UI layers. --- docs/api/openapi.json | 58 ++- migration/src/lib.rs | 4 + ..._000080_add_release_ledger_span_columns.rs | 120 ++++++ plugins/release-mangaupdates/src/index.ts | 7 +- plugins/release-nyaa/src/index.test.ts | 6 +- plugins/release-nyaa/src/index.ts | 14 +- plugins/release-nyaa/src/parser.test.ts | 218 ++++++---- plugins/release-nyaa/src/parser.ts | 134 +++--- plugins/sdk-typescript/src/types/releases.ts | 27 +- src/api/docs.rs | 1 + src/api/routes/v1/dto/release.rs | 41 +- src/db/entities/release_ledger.rs | 14 +- src/db/repositories/release_ledger.rs | 78 +++- src/services/plugin/releases_handler.rs | 48 ++- src/services/release/auto_ignore.rs | 389 ++++++++++++++---- src/services/release/candidate.rs | 104 ++++- src/services/release/matcher.rs | 35 +- src/tasks/handlers/poll_release_source.rs | 22 +- tests/api/releases.rs | 7 +- web/openapi.json | 58 ++- .../releases/ReleasesTable.test.tsx | 137 ++++++ web/src/components/releases/ReleasesTable.tsx | 35 +- .../series/SeriesReleasesPanel.test.tsx | 36 +- web/src/types/api.generated.ts | 46 ++- 24 files changed, 1304 insertions(+), 335 deletions(-) create mode 100644 migration/src/m20260508_000080_add_release_ledger_span_columns.rs create mode 100644 web/src/components/releases/ReleasesTable.test.tsx diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 4edf8913..3e3baa85 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -29777,13 +29777,15 @@ "createdAt" ], "properties": { - "chapter": { + "chapters": { "type": [ - "number", + "array", "null" ], - "format": "double", - "description": "Decimal supports `12.5` etc." + "items": { + "$ref": "#/components/schemas/ReleaseSpanDto" + }, + "description": "Full chapter coverage as a normalized span list. Decimals supported\n(`c12.5` → `[{start: 12.5, end: 12.5}]`). `null` when the upstream\ntitle carried no chapter info." }, "confidence": { "type": "number", @@ -29870,12 +29872,15 @@ "type": "string", "description": "`announced` | `dismissed` | `marked_acquired` | `hidden`." }, - "volume": { + "volumes": { "type": [ - "integer", + "array", "null" ], - "format": "int32" + "items": { + "$ref": "#/components/schemas/ReleaseSpanDto" + }, + "description": "Full volume coverage as a normalized span list. `null` semantics\nmirror [`Self::chapters`]." } } }, @@ -33195,13 +33200,15 @@ "createdAt" ], "properties": { - "chapter": { + "chapters": { "type": [ - "number", + "array", "null" ], - "format": "double", - "description": "Decimal supports `12.5` etc." + "items": { + "$ref": "#/components/schemas/ReleaseSpanDto" + }, + "description": "Full chapter coverage as a normalized span list. Decimals supported\n(`c12.5` → `[{start: 12.5, end: 12.5}]`). `null` when the upstream\ntitle carried no chapter info." }, "confidence": { "type": "number", @@ -33288,12 +33295,15 @@ "type": "string", "description": "`announced` | `dismissed` | `marked_acquired` | `hidden`." }, - "volume": { + "volumes": { "type": [ - "integer", + "array", "null" ], - "format": "int32" + "items": { + "$ref": "#/components/schemas/ReleaseSpanDto" + }, + "description": "Full volume coverage as a normalized span list. `null` semantics\nmirror [`Self::chapters`]." } } }, @@ -33479,6 +33489,26 @@ } } }, + "ReleaseSpanDto": { + "type": "object", + "description": "Inclusive numeric span. Single values are encoded as `start == end`\n(`{ start: 5, end: 5 }`). The release ledger surfaces volume / chapter\ncoverage as a list of these so disjoint compilations (`v01-04 + v06-09`)\nsurvive end-to-end.", + "required": [ + "start", + "end" + ], + "properties": { + "end": { + "type": "number", + "format": "double", + "example": 9.0 + }, + "start": { + "type": "number", + "format": "double", + "example": 1.0 + } + } + }, "ReplaceBookMetadataRequest": { "type": "object", "description": "PUT request for full replacement of book metadata\n\nAll metadata fields will be replaced with the values in this request.\nOmitting a field (or setting it to null) will clear that field.", diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 281e5821..6a23bca3 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -163,6 +163,8 @@ mod m20260505_000077_add_release_sources_last_summary; mod m20260505_000078_add_release_ledger_media_url; // Release tracking: server-wide default cron schedule for release-source polling mod m20260505_000079_seed_release_tracking_default_cron; +// Release tracking: per-row span lists (chapters/volumes) for compilation bundles +mod m20260508_000080_add_release_ledger_span_columns; pub struct Migrator; @@ -296,6 +298,8 @@ impl MigratorTrait for Migrator { Box::new(m20260505_000078_add_release_ledger_media_url::Migration), // Release tracking: server-wide default cron schedule Box::new(m20260505_000079_seed_release_tracking_default_cron::Migration), + // Release tracking: per-row span lists for compilation bundles + Box::new(m20260508_000080_add_release_ledger_span_columns::Migration), ] } } diff --git a/migration/src/m20260508_000080_add_release_ledger_span_columns.rs b/migration/src/m20260508_000080_add_release_ledger_span_columns.rs new file mode 100644 index 00000000..e908a33e --- /dev/null +++ b/migration/src/m20260508_000080_add_release_ledger_span_columns.rs @@ -0,0 +1,120 @@ +//! Add `chapters` + `volumes` JSON span columns to `release_ledger`. +//! +//! The existing `chapter` (f64) and `volume` (i32) scalars can only hold a +//! single value per release. Real Nyaa compilations frequently cover ranges +//! and even disjoint spans (`v01-04 + v06-09`) — we silently squashed those +//! to the start value, which both mislabeled the inbox and broke the +//! "auto-ignore when fully owned" decision. +//! +//! After this migration: +//! - `chapters` is a JSON array of `[{ "start": Number, "end": Number }, ...]` +//! describing every chapter the release covers. `null` when the upstream +//! title carries no chapter info at all. +//! - `volumes` mirrors the shape, with integer-valued spans. +//! - The legacy `chapter` / `volume` scalars stay around as the *primary +//! value* used for SQL `ORDER BY` (cheap, indexable, no DB-specific +//! JSON-path syntax). The repo derives them as `max(span.end)` on insert +//! so "release covering content up to N" sorts by N. +//! +//! Backfill maps every existing single-value row into a one-element span +//! list so the new columns are populated for the historic ledger before the +//! ingestion path stops writing scalars directly. + +use sea_orm::{ConnectionTrait, DbBackend, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("release_ledger")) + .add_column(ColumnDef::new(Alias::new("chapters")).json_binary()) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Alias::new("release_ledger")) + .add_column(ColumnDef::new(Alias::new("volumes")).json_binary()) + .to_owned(), + ) + .await?; + + // Backfill: turn every existing scalar value into a one-element span. + // The JSON literal differs slightly across backends — Postgres prefers + // `jsonb_build_array` / `jsonb_build_object` with native casting, + // SQLite has `json_array` / `json_object`. Using the build-* helpers + // keeps numeric typing intact (no string-coerced values). + let db = manager.get_connection(); + let backend = db.get_database_backend(); + match backend { + DbBackend::Postgres => { + db.execute(Statement::from_string( + DbBackend::Postgres, + r#"UPDATE release_ledger + SET chapters = jsonb_build_array(jsonb_build_object('start', chapter, 'end', chapter)) + WHERE chapter IS NOT NULL"# + .to_string(), + )) + .await?; + db.execute(Statement::from_string( + DbBackend::Postgres, + r#"UPDATE release_ledger + SET volumes = jsonb_build_array(jsonb_build_object('start', volume, 'end', volume)) + WHERE volume IS NOT NULL"# + .to_string(), + )) + .await?; + } + _ => { + // SQLite (and anything else we treat as "default"): use + // json_array + json_object. Numeric types round-trip through + // SQLite's typeless JSON faithfully for our integer / float + // values. + db.execute(Statement::from_string( + DbBackend::Sqlite, + r#"UPDATE release_ledger + SET chapters = json_array(json_object('start', chapter, 'end', chapter)) + WHERE chapter IS NOT NULL"# + .to_string(), + )) + .await?; + db.execute(Statement::from_string( + DbBackend::Sqlite, + r#"UPDATE release_ledger + SET volumes = json_array(json_object('start', volume, 'end', volume)) + WHERE volume IS NOT NULL"# + .to_string(), + )) + .await?; + } + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("release_ledger")) + .drop_column(Alias::new("volumes")) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Alias::new("release_ledger")) + .drop_column(Alias::new("chapters")) + .to_owned(), + ) + .await?; + Ok(()) + } +} diff --git a/plugins/release-mangaupdates/src/index.ts b/plugins/release-mangaupdates/src/index.ts index 90676f19..e1356013 100644 --- a/plugins/release-mangaupdates/src/index.ts +++ b/plugins/release-mangaupdates/src/index.ts @@ -184,8 +184,11 @@ function toCandidate(entry: TrackedSeriesEntry, item: ParsedRssItem): ReleaseCan reason: `mangaupdates_id:${entry.externalIds?.[EXTERNAL_ID_SOURCE_MANGAUPDATES] ?? ""}`, }, externalReleaseId: item.externalReleaseId, - chapter: item.chapter, - volume: item.volume, + // MangaUpdates always reports a single chapter / volume per release. + // Wrap as one-element span lists for the new SDK shape; `null` when + // the parser didn't see a value at all. + volumes: item.volume === null ? null : [{ start: item.volume, end: item.volume }], + chapters: item.chapter === null ? null : [{ start: item.chapter, end: item.chapter }], language: item.language, groupOrUploader: item.group, payloadUrl: item.link.length > 0 ? item.link : `urn:mu:${item.externalReleaseId}`, diff --git a/plugins/release-nyaa/src/index.test.ts b/plugins/release-nyaa/src/index.test.ts index 9678e4a4..04d4cf2f 100644 --- a/plugins/release-nyaa/src/index.test.ts +++ b/plugins/release-nyaa/src/index.test.ts @@ -203,7 +203,8 @@ describe("pollSubscription", () => { candidate: { infoHash: string | null; formatHints: Record; - volume: number | null; + volumes: { start: number; end: number }[] | null; + chapters: { start: number; end: number }[] | null; payloadUrl: string; mediaUrl?: string | null; mediaUrlKind?: string | null; @@ -212,7 +213,8 @@ describe("pollSubscription", () => { expect(params.candidate.infoHash).toBe("aaa"); expect(params.candidate.formatHints.digital).toBe(true); expect(params.candidate.formatHints.subscription).toBe("user:1r0n"); - expect(params.candidate.volume).toBe(2); + expect(params.candidate.volumes).toEqual([{ start: 2, end: 2 }]); + expect(params.candidate.chapters).toBeNull(); // Page url -> payloadUrl, .torrent -> mediaUrl with kind=torrent. expect(params.candidate.payloadUrl).toBe("https://nyaa.si/view/1"); expect(params.candidate.mediaUrl).toBe("https://nyaa.si/download/1.torrent"); diff --git a/plugins/release-nyaa/src/index.ts b/plugins/release-nyaa/src/index.ts index ebbde59b..c8eb054b 100644 --- a/plugins/release-nyaa/src/index.ts +++ b/plugins/release-nyaa/src/index.ts @@ -203,13 +203,11 @@ function toCandidate( item: ParsedRssItem, subscription: UploaderSubscription, ): ReleaseCandidate { + // Format hints carry just the recognized parser tags (`digital`, `jxl`, …) + // plus the originating subscription. Volume / chapter coverage now travels + // as first-class span lists on the candidate itself rather than smuggled + // through `format_hints.*RangeEnd` keys. const formatHints: Record = { ...item.formatHints }; - if (item.chapterRangeEnd !== null) { - formatHints.chapterRangeEnd = item.chapterRangeEnd; - } - if (item.volumeRangeEnd !== null) { - formatHints.volumeRangeEnd = item.volumeRangeEnd; - } formatHints.subscription = `${subscription.kind}:${subscription.identifier}`; // Nyaa RSS carries two URLs per item: @@ -231,8 +229,8 @@ function toCandidate( reason: match.reason, }, externalReleaseId: item.externalReleaseId, - chapter: item.chapter, - volume: item.volume, + volumes: item.volumes, + chapters: item.chapters, language: "en", groupOrUploader: item.group ?? (subscription.kind === "user" ? subscription.identifier : null), payloadUrl, diff --git a/plugins/release-nyaa/src/parser.test.ts b/plugins/release-nyaa/src/parser.test.ts index b8dbf56b..989cd160 100644 --- a/plugins/release-nyaa/src/parser.test.ts +++ b/plugins/release-nyaa/src/parser.test.ts @@ -4,6 +4,11 @@ import { parseFeed, parseItem, parseTitle } from "./parser.js"; // ----------------------------------------------------------------------------- // parseTitle — corpus mirroring real-world Nyaa titles, including the user's // 1r0n / mixed-format examples that motivated this phase. +// +// Every release exposes its volume / chapter coverage as a normalized +// `NumericSpan[]` per axis. Single values are encoded as one span with +// `start === end`; ranges as one span with the lower number on `start`; +// disjoint ranges (e.g. `v01-04 + v06-09`) as multiple spans. // ----------------------------------------------------------------------------- describe("parseTitle", () => { @@ -12,8 +17,8 @@ describe("parseTitle", () => { expect(t).not.toBeNull(); if (t === null) return; expect(t.group).toBe("1r0n"); - expect(t.volume).toBe(2); - expect(t.chapter).toBeNull(); + expect(t.volumes).toEqual([{ start: 2, end: 2 }]); + expect(t.chapters).toBeNull(); expect(t.formatHints.digital).toBe(true); // Series guess strips group, volume token, and parenthesized tags. expect(t.seriesGuess).toBe("Boruto Two Blue Vortex"); @@ -23,8 +28,8 @@ describe("parseTitle", () => { const t = parseTitle("[1r0n] One Piece v107 (Digital)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBe(107); - expect(t.chapter).toBeNull(); + expect(t.volumes).toEqual([{ start: 107, end: 107 }]); + expect(t.chapters).toBeNull(); expect(t.formatHints.digital).toBe(true); expect(t.seriesGuess).toBe("One Piece"); }); @@ -33,8 +38,8 @@ describe("parseTitle", () => { const t = parseTitle("[1r0n] Chainsaw Man - Chapter 142 (Digital)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.chapter).toBe(142); - expect(t.volume).toBeNull(); + expect(t.chapters).toEqual([{ start: 142, end: 142 }]); + expect(t.volumes).toBeNull(); expect(t.seriesGuess).toBe("Chainsaw Man"); }); @@ -42,9 +47,8 @@ describe("parseTitle", () => { const t = parseTitle("[Group] Dandadan c126-142 (2024) (Digital)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.chapter).toBe(126); - expect(t.chapterRangeEnd).toBe(142); - expect(t.volume).toBeNull(); + expect(t.chapters).toEqual([{ start: 126, end: 142 }]); + expect(t.volumes).toBeNull(); expect(t.formatHints.digital).toBe(true); expect(t.seriesGuess).toBe("Dandadan"); }); @@ -53,8 +57,8 @@ describe("parseTitle", () => { const t = parseTitle("[1r0n] Boruto v01-14 (Digital) (1r0n)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBe(1); - expect(t.volumeRangeEnd).toBe(14); + expect(t.volumes).toEqual([{ start: 1, end: 14 }]); + expect(t.chapters).toBeNull(); expect(t.seriesGuess).toBe("Boruto"); }); @@ -63,7 +67,7 @@ describe("parseTitle", () => { expect(t).not.toBeNull(); if (t === null) return; expect(t.group).toBe("Tankobon Blur"); - expect(t.volume).toBe(13); + expect(t.volumes).toEqual([{ start: 13, end: 13 }]); expect(t.formatHints.digital).toBe(true); expect(t.seriesGuess).toBe("Solo Leveling"); }); @@ -73,7 +77,7 @@ describe("parseTitle", () => { expect(t).not.toBeNull(); if (t === null) return; expect(t.group).toBeNull(); - expect(t.volume).toBe(42); + expect(t.volumes).toEqual([{ start: 42, end: 42 }]); expect(t.formatHints.digital).toBe(true); expect(t.seriesGuess).toBe("Berserk"); }); @@ -82,7 +86,7 @@ describe("parseTitle", () => { const t = parseTitle("[Group] Some Series c47.5 (Digital)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.chapter).toBe(47.5); + expect(t.chapters).toEqual([{ start: 47.5, end: 47.5 }]); expect(t.seriesGuess).toBe("Some Series"); }); @@ -103,8 +107,8 @@ describe("parseTitle", () => { const t = parseTitle("Just Some Manga Tanks (Digital)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.chapter).toBeNull(); - expect(t.volume).toBeNull(); + expect(t.chapters).toBeNull(); + expect(t.volumes).toBeNull(); expect(t.seriesGuess).toBe("Just Some Manga Tanks"); expect(t.formatHints.digital).toBe(true); }); @@ -113,7 +117,7 @@ describe("parseTitle", () => { const t = parseTitle("[Group] My Series ch.143 (Digital)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.chapter).toBe(143); + expect(t.chapters).toEqual([{ start: 143, end: 143 }]); expect(t.seriesGuess).toBe("My Series"); }); @@ -141,6 +145,8 @@ describe("parseTitle", () => { // v01-16 + 70 → vol range + single bare chapter // 001-069 as v01-16 + 70 → bare chapter range followed by vol info // 031-037 → bare chapter range as primary identifier +// 001-005 as v01 + 006-009 → mixed bundle: one volume + loose chapters +// v01-04 + v06-09 → disjoint volume ranges (gap) // // Bare numeric ranges are zero-padded to 3 digits in the corpus, which we use // to distinguish chapter tokens from incidental numbers in series names. @@ -153,10 +159,8 @@ describe("parseTitle — aggregated bundle releases", () => { const t = parseTitle("After God v01-09 (2024-2026) (Digital) (1r0n)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBe(1); - expect(t.volumeRangeEnd).toBe(9); - expect(t.chapter).toBeNull(); - expect(t.chapterRangeEnd).toBeNull(); + expect(t.volumes).toEqual([{ start: 1, end: 9 }]); + expect(t.chapters).toBeNull(); expect(t.seriesGuess).toBe("After God"); expect(t.formatHints.digital).toBe(true); }); @@ -165,10 +169,8 @@ describe("parseTitle — aggregated bundle releases", () => { const t = parseTitle("One Piece v001-111 + 1134-1176 (2003-2026) (Digital) (1r0n)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBe(1); - expect(t.volumeRangeEnd).toBe(111); - expect(t.chapter).toBe(1134); - expect(t.chapterRangeEnd).toBe(1176); + expect(t.volumes).toEqual([{ start: 1, end: 111 }]); + expect(t.chapters).toEqual([{ start: 1134, end: 1176 }]); expect(t.seriesGuess).toBe("One Piece"); }); @@ -178,10 +180,8 @@ describe("parseTitle — aggregated bundle releases", () => { ); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBe(1); - expect(t.volumeRangeEnd).toBe(28); - expect(t.chapter).toBe(125); - expect(t.chapterRangeEnd).toBe(137); + expect(t.volumes).toEqual([{ start: 1, end: 28 }]); + expect(t.chapters).toEqual([{ start: 125, end: 137 }]); // Primary guess is the first alias. expect(t.seriesGuess).toBe("Tensei Shitara Slime Datta Ken"); // Both halves of `A / B` are exposed for matching. @@ -197,11 +197,14 @@ describe("parseTitle — aggregated bundle releases", () => { ); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBe(1); - expect(t.volumeRangeEnd).toBe(16); - // Aggregated min/max across all chapter tokens in the release header. - expect(t.chapter).toBe(1); - expect(t.chapterRangeEnd).toBe(70); + expect(t.volumes).toEqual([{ start: 1, end: 16 }]); + // Bare chapter range 001-069 plus extra single chapter 70: adjacent but + // not overlapping, so kept as two spans (the uploader signaled them as + // distinct content groups via the `+`). + expect(t.chapters).toEqual([ + { start: 1, end: 69 }, + { start: 70, end: 70 }, + ]); expect(t.seriesGuess).toBe("Chillin' in My 30s after Getting Fired from the Demon King's Army"); }); @@ -211,9 +214,8 @@ describe("parseTitle — aggregated bundle releases", () => { ); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBeNull(); - expect(t.chapter).toBe(31); - expect(t.chapterRangeEnd).toBe(37); + expect(t.volumes).toBeNull(); + expect(t.chapters).toEqual([{ start: 31, end: 37 }]); expect(t.seriesGuess).toBe("Never Say Ugly"); }); @@ -223,10 +225,8 @@ describe("parseTitle — aggregated bundle releases", () => { ); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBe(1); - expect(t.volumeRangeEnd).toBe(31); - expect(t.chapter).toBe(276); - expect(t.chapterRangeEnd).toBe(293); + expect(t.volumes).toEqual([{ start: 1, end: 31 }]); + expect(t.chapters).toEqual([{ start: 276, end: 293 }]); expect(t.seriesGuess).toBe("Edens Zero"); }); @@ -234,10 +234,8 @@ describe("parseTitle — aggregated bundle releases", () => { const t = parseTitle("Ultimate Exorcist Kiyoshi v01,009-090 (2024-2026) (Digital) (LuCaZ)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBe(1); - expect(t.volumeRangeEnd).toBeNull(); - expect(t.chapter).toBe(9); - expect(t.chapterRangeEnd).toBe(90); + expect(t.volumes).toEqual([{ start: 1, end: 1 }]); + expect(t.chapters).toEqual([{ start: 9, end: 90 }]); expect(t.seriesGuess).toBe("Ultimate Exorcist Kiyoshi"); }); @@ -245,10 +243,8 @@ describe("parseTitle — aggregated bundle releases", () => { const t = parseTitle("Boruto - Two Blue Vortex v01-05,021-033 (2025-2026) (Digital) (LuCaZ)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBe(1); - expect(t.volumeRangeEnd).toBe(5); - expect(t.chapter).toBe(21); - expect(t.chapterRangeEnd).toBe(33); + expect(t.volumes).toEqual([{ start: 1, end: 5 }]); + expect(t.chapters).toEqual([{ start: 21, end: 33 }]); expect(t.seriesGuess).toBe("Boruto Two Blue Vortex"); }); @@ -256,10 +252,8 @@ describe("parseTitle — aggregated bundle releases", () => { const t = parseTitle("Ao no Hako / Blue Box v01-20,181-240 (2022-2026) (Digital) (LuCaZ)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBe(1); - expect(t.volumeRangeEnd).toBe(20); - expect(t.chapter).toBe(181); - expect(t.chapterRangeEnd).toBe(240); + expect(t.volumes).toEqual([{ start: 1, end: 20 }]); + expect(t.chapters).toEqual([{ start: 181, end: 240 }]); expect(t.seriesGuess).toBe("Ao no Hako"); expect(t.seriesGuessAliases).toEqual(["Ao no Hako", "Blue Box"]); }); @@ -270,8 +264,7 @@ describe("parseTitle — aggregated bundle releases", () => { ); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBe(1); - expect(t.volumeRangeEnd).toBe(2); + expect(t.volumes).toEqual([{ start: 1, end: 2 }]); expect(t.formatHints.digital).toBe(true); expect(t.formatHints.omnibus).toBe(true); expect(t.seriesGuess).toBe("Ashita no Joe Fighting for Tomorrow"); @@ -281,10 +274,8 @@ describe("parseTitle — aggregated bundle releases", () => { const t = parseTitle("Dragon Ball Super v01-23,101-104 (2017-2025) (Digital) (LuCaZ)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBe(1); - expect(t.volumeRangeEnd).toBe(23); - expect(t.chapter).toBe(101); - expect(t.chapterRangeEnd).toBe(104); + expect(t.volumes).toEqual([{ start: 1, end: 23 }]); + expect(t.chapters).toEqual([{ start: 101, end: 104 }]); expect(t.seriesGuess).toBe("Dragon Ball Super"); }); @@ -294,9 +285,8 @@ describe("parseTitle — aggregated bundle releases", () => { ); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBe(1); - expect(t.volumeRangeEnd).toBe(4); - expect(t.chapter).toBeNull(); + expect(t.volumes).toEqual([{ start: 1, end: 4 }]); + expect(t.chapters).toBeNull(); expect(t.seriesGuess).toBe("Becoming a Princess Knight and Working at a Yuri Brothel"); }); @@ -306,10 +296,8 @@ describe("parseTitle — aggregated bundle releases", () => { ); expect(t).not.toBeNull(); if (t === null) return; - expect(t.volume).toBe(1); - expect(t.volumeRangeEnd).toBe(17); - expect(t.chapter).toBe(150); - expect(t.chapterRangeEnd).toBe(172); + expect(t.volumes).toEqual([{ start: 1, end: 17 }]); + expect(t.chapters).toEqual([{ start: 150, end: 172 }]); expect(t.seriesGuess).toBe("Amagami-san Chi no Enmusubi"); expect(t.seriesGuessAliases).toEqual([ "Amagami-san Chi no Enmusubi", @@ -318,6 +306,86 @@ describe("parseTitle — aggregated bundle releases", () => { }); }); +// ----------------------------------------------------------------------------- +// parseTitle — disjoint and mixed-bundle releases (the case where two scalar +// fields couldn't tell the truth). These exercise the span list normalizer. +// ----------------------------------------------------------------------------- + +describe("parseTitle — disjoint and mixed-bundle releases", () => { + it("Re-Reincarnated 001-050 as v01-10 — bare chapter range presented as volume range", () => { + const t = parseTitle( + "The Re-Reincarnated Boy Lives Peacefully as an S-Rank Adventurer 001-050 as v01-10 (Digital-Compilation) (Square Enix) (DigitalMangaFan)", + ); + expect(t).not.toBeNull(); + if (t === null) return; + expect(t.volumes).toEqual([{ start: 1, end: 10 }]); + expect(t.chapters).toEqual([{ start: 1, end: 50 }]); + expect(t.seriesGuess).toBe("The Re-Reincarnated Boy Lives Peacefully as an S-Rank Adventurer"); + }); + + it("A Late-Start Tamer v01-09 — pure volume range", () => { + const t = parseTitle("A Late-Start Tamer's Laid-Back Life v01-09 (2024-2026) (Digital) (Ushi)"); + expect(t).not.toBeNull(); + if (t === null) return; + expect(t.volumes).toEqual([{ start: 1, end: 9 }]); + expect(t.chapters).toBeNull(); + expect(t.seriesGuess).toBe("A Late-Start Tamer's Laid-Back Life"); + }); + + it("Charlotte 001-005 as v01 + 006-009 — single volume + loose chapters (mixed bundle)", () => { + const t = parseTitle( + "Charlotte - The Tale of a Castle Maid 001-005 as v01 + 006-009 (Digital-Compilations) (Oak)", + ); + expect(t).not.toBeNull(); + if (t === null) return; + // Only one volume token (`v01`) — single span on the volume axis. + expect(t.volumes).toEqual([{ start: 1, end: 1 }]); + // Two chapter token groups (001-005 and 006-009): adjacent integers but + // distinct authorial intent, kept as two spans rather than merged. + expect(t.chapters).toEqual([ + { start: 1, end: 5 }, + { start: 6, end: 9 }, + ]); + expect(t.seriesGuess).toBe("Charlotte The Tale of a Castle Maid"); + }); + + it("My Faceless Classmate 001-011 as v01 + 012-022 — second mixed-bundle case", () => { + const t = parseTitle( + "My Faceless Classmate, Wakao 001-011 as v01 + 012-022 (Digital-Compilation) (Oak)", + ); + expect(t).not.toBeNull(); + if (t === null) return; + expect(t.volumes).toEqual([{ start: 1, end: 1 }]); + expect(t.chapters).toEqual([ + { start: 1, end: 11 }, + { start: 12, end: 22 }, + ]); + expect(t.seriesGuess).toBe("My Faceless Classmate, Wakao"); + }); + + it("hypothetical disjoint volume bundle v01-04 + v06-09 — gap preserved (vol 5 missing)", () => { + const t = parseTitle("Series 123 v01-04 + v06-09 (Digital)"); + expect(t).not.toBeNull(); + if (t === null) return; + // Two disjoint volume spans; auto-ignore must not treat this as 1..=9. + expect(t.volumes).toEqual([ + { start: 1, end: 4 }, + { start: 6, end: 9 }, + ]); + expect(t.chapters).toBeNull(); + }); + + it("overlapping volume tokens get merged (v01-05 + v04-09 → v01-09)", () => { + // Synthetic — uploader noise where the bundle's two halves overlap. We + // merge so callers see a single contiguous coverage span rather than two + // overlapping ones (which would mislead any per-value ownership check). + const t = parseTitle("Some Series v01-05 + v04-09 (Digital)"); + expect(t).not.toBeNull(); + if (t === null) return; + expect(t.volumes).toEqual([{ start: 1, end: 9 }]); + }); +}); + // ----------------------------------------------------------------------------- // parseTitle — defensive: bare-number heuristics must not eat year ranges, // and short bare numbers (1-2 digits) must not be promoted to chapters. @@ -328,10 +396,8 @@ describe("parseTitle — bare-number safety net", () => { const t = parseTitle("Some Series v01-05 (2018-2025) (Digital)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.chapter).toBeNull(); - expect(t.chapterRangeEnd).toBeNull(); - expect(t.volume).toBe(1); - expect(t.volumeRangeEnd).toBe(5); + expect(t.chapters).toBeNull(); + expect(t.volumes).toEqual([{ start: 1, end: 5 }]); }); it("ignores bare 1-2 digit numbers in the series name (avoids false positives)", () => { @@ -340,8 +406,8 @@ describe("parseTitle — bare-number safety net", () => { const t = parseTitle("My 30s Adventure v01 (Digital)"); expect(t).not.toBeNull(); if (t === null) return; - expect(t.chapter).toBeNull(); - expect(t.volume).toBe(1); + expect(t.chapters).toBeNull(); + expect(t.volumes).toEqual([{ start: 1, end: 1 }]); expect(t.seriesGuess).toBe("My 30s Adventure"); }); @@ -386,7 +452,7 @@ describe("parseItem", () => { expect(item.pageUrl).toBe("https://nyaa.si/view/12345"); expect(item.externalReleaseId).toBe("https://nyaa.si/view/12345"); // guid wins expect(item.infoHash).toBe("abc123def456"); // lowercased - expect(item.chapter).toBe(142); + expect(item.chapters).toEqual([{ start: 142, end: 142 }]); expect(item.seriesGuess).toBe("Chainsaw Man"); expect(new Date(item.observedAt).toISOString()).toBe("2026-05-04T02:31:00.000Z"); }); @@ -474,8 +540,8 @@ describe("parseFeed", () => { "Boruto", "Dandadan", ]); - expect(items[0]?.volume).toBe(2); - expect(items[1]?.volumeRangeEnd).toBe(14); - expect(items[2]?.chapterRangeEnd).toBe(142); + expect(items[0]?.volumes).toEqual([{ start: 2, end: 2 }]); + expect(items[1]?.volumes).toEqual([{ start: 1, end: 14 }]); + expect(items[2]?.chapters).toEqual([{ start: 126, end: 142 }]); }); }); diff --git a/plugins/release-nyaa/src/parser.ts b/plugins/release-nyaa/src/parser.ts index ee291093..1002d74f 100644 --- a/plugins/release-nyaa/src/parser.ts +++ b/plugins/release-nyaa/src/parser.ts @@ -27,6 +27,15 @@ * downstream from the alias matcher rather than failing here. */ +/** + * Numeric inclusive range. Single values are encoded as `start === end` + * (`{ start: 5, end: 5 }`). + */ +export interface NumericSpan { + start: number; + end: number; +} + /** Parsed item, pre-`ReleaseCandidate`. */ export interface ParsedRssItem { /** Stable per-source ID. Derived from the link or guid. */ @@ -43,14 +52,18 @@ export interface ParsedRssItem { * array equal to `[seriesGuess]`. */ seriesGuessAliases: string[]; - /** Chapter number (decimals supported). Null if untyped. */ - chapter: number | null; - /** Trailing chapter of a chapter range (e.g. `c126-142` → 126..142). */ - chapterRangeEnd: number | null; - /** Volume number. Null if untyped. */ - volume: number | null; - /** Trailing volume of a volume range (e.g. `v01-14` → 1..14). */ - volumeRangeEnd: number | null; + /** + * Volume coverage as a normalized span list. Sorted ascending by `start`, + * with overlapping spans merged. Disjoint spans (`v01-04 + v06-09`) are + * preserved as two entries. `null` when the title carried no volume + * information at all. + */ + volumes: NumericSpan[] | null; + /** + * Chapter coverage as a normalized span list. Same conventions as + * [`volumes`]; decimals are preserved (`c12.5` → `[{12.5, 12.5}]`). + */ + chapters: NumericSpan[] | null; /** Leading `[Group]` token, if any. */ group: string | null; /** Format hints as a small dictionary (digital, jxl, ...). */ @@ -277,46 +290,69 @@ function tokenizeReleaseInfo(s: string): SpreadToken[] { } /** - * Aggregate spread tokens into volume + chapter axes by taking min/max across - * each kind. Downstream matching just needs to know the span a release covers - * ("does this release include chapter X?") — a min..max window answers that - * question conservatively without picking a single canonical token. + * Project the spread-token list onto two axes of normalized [`NumericSpan`]s. + * + * Single-value tokens (`v05`, `c143`) become `{ start: N, end: N }`. Range + * tokens (`v01-09`, `001-050`, `c126-142`) become `{ start, end }` with the + * lower number on `start`. We do *not* min/max aggregate across an axis any + * more — disjoint ranges (`v01-04 + v06-09`) must survive intact so the host + * can ask "does the user own everything in here?" honestly. */ -function aggregateTokens(tokens: SpreadToken[]): { - volume: number | null; - volumeRangeEnd: number | null; - chapter: number | null; - chapterRangeEnd: number | null; +function tokensToSpans(tokens: SpreadToken[]): { + volumes: NumericSpan[] | null; + chapters: NumericSpan[] | null; } { - let vMin: number | null = null; - let vMax: number | null = null; - let cMin: number | null = null; - let cMax: number | null = null; + const vol: NumericSpan[] = []; + const chap: NumericSpan[] = []; for (const t of tokens) { - if (t.kind === "volume") { - vMin = vMin === null || t.value < vMin ? t.value : vMin; - vMax = vMax === null || t.value > vMax ? t.value : vMax; - } else if (t.kind === "volRange") { - vMin = vMin === null || t.start < vMin ? t.start : vMin; - vMax = vMax === null || t.end > vMax ? t.end : vMax; - } else if (t.kind === "chapter") { - cMin = cMin === null || t.value < cMin ? t.value : cMin; - cMax = cMax === null || t.value > cMax ? t.value : cMax; - } else { - cMin = cMin === null || t.start < cMin ? t.start : cMin; - cMax = cMax === null || t.end > cMax ? t.end : cMax; + switch (t.kind) { + case "volume": + vol.push({ start: t.value, end: t.value }); + break; + case "volRange": + vol.push({ start: t.start, end: t.end }); + break; + case "chapter": + chap.push({ start: t.value, end: t.value }); + break; + case "chapRange": + chap.push({ start: t.start, end: t.end }); + break; } } return { - volume: vMin, - // Only emit a range-end when it actually differs from the start: a single - // volume is `volume=N, volumeRangeEnd=null`, matching the prior contract. - volumeRangeEnd: vMin !== null && vMax !== null && vMax !== vMin ? vMax : null, - chapter: cMin, - chapterRangeEnd: cMin !== null && cMax !== null && cMax !== cMin ? cMax : null, + volumes: vol.length === 0 ? null : normalizeSpans(vol), + chapters: chap.length === 0 ? null : normalizeSpans(chap), }; } +/** + * Sort a span list ascending and merge overlapping entries. We deliberately + * do *not* merge purely adjacent spans (`[1, 4]` and `[5, 9]` stay separate); + * the uploader chose to write them disjointly and we preserve that intent. + * Bad inputs where `start > end` get swapped before sorting so downstream + * iteration always sees `start <= end`. + */ +function normalizeSpans(spans: NumericSpan[]): NumericSpan[] { + if (spans.length === 0) return spans; + const fixed = spans.map((s) => + s.start <= s.end ? { start: s.start, end: s.end } : { start: s.end, end: s.start }, + ); + fixed.sort((a, b) => a.start - b.start || a.end - b.end); + const out: NumericSpan[] = []; + for (const s of fixed) { + const last = out[out.length - 1]; + if (last !== undefined && s.start <= last.end) { + // Overlap — extend the existing span. Equality of endpoints + // (`[1, 4]` + `[4, 9]`) counts as overlap too. + if (s.end > last.end) last.end = s.end; + } else { + out.push(s); + } + } + return out; +} + /** * Walk the parenthesized tags in the title and extract format hints. * @@ -400,10 +436,8 @@ function extractSeriesAliases(nameRegion: string): { export function parseTitle(title: string): { seriesGuess: string; seriesGuessAliases: string[]; - chapter: number | null; - chapterRangeEnd: number | null; - volume: number | null; - volumeRangeEnd: number | null; + volumes: NumericSpan[] | null; + chapters: NumericSpan[] | null; group: string | null; formatHints: Record; } | null { @@ -422,16 +456,14 @@ export function parseTitle(title: string): { const infoRegion = anchor === -1 ? "" : flattened.slice(anchor); const tokens = tokenizeReleaseInfo(infoRegion); - const { volume, volumeRangeEnd, chapter, chapterRangeEnd } = aggregateTokens(tokens); + const { volumes, chapters } = tokensToSpans(tokens); const { primary, aliases } = extractSeriesAliases(nameRegion); return { seriesGuess: primary, seriesGuessAliases: aliases.length > 0 ? aliases : [primary], - chapter, - chapterRangeEnd, - volume, - volumeRangeEnd, + volumes, + chapters, group, formatHints, }; @@ -504,10 +536,8 @@ export function parseItem(itemXml: string): ParsedRssItem | null { title, seriesGuess: parsedTitle.seriesGuess, seriesGuessAliases: parsedTitle.seriesGuessAliases, - chapter: parsedTitle.chapter, - chapterRangeEnd: parsedTitle.chapterRangeEnd, - volume: parsedTitle.volume, - volumeRangeEnd: parsedTitle.volumeRangeEnd, + volumes: parsedTitle.volumes, + chapters: parsedTitle.chapters, group: parsedTitle.group, formatHints: parsedTitle.formatHints, link: link ?? "", diff --git a/plugins/sdk-typescript/src/types/releases.ts b/plugins/sdk-typescript/src/types/releases.ts index 1a061e9a..1345acbb 100644 --- a/plugins/sdk-typescript/src/types/releases.ts +++ b/plugins/sdk-typescript/src/types/releases.ts @@ -61,16 +61,29 @@ export interface SeriesMatch { reason: string; } +/** + * Inclusive numeric span. Single values are encoded as `start === end` + * (`{ start: 5, end: 5 }`). Used on the volume and chapter axes of a + * release candidate to express compilation/bundle coverage honestly, + * including disjoint coverage (`v01-04 + v06-09` → two spans). + */ +export interface NumericSpan { + start: number; + end: number; +} + /** * Release candidate emitted by a plugin. * * **Field semantics:** * - `externalReleaseId`: Stable per-source ID. The first dedup key. * `(sourceId, externalReleaseId)` is `UNIQUE` in `release_ledger`. - * - `chapter` / `volume`: At least one should be set; both is fine for a - * "vol 15 covers ch 126-142" case (the volume axis advances; the chapter - * axis advances to the volume's last chapter only if the candidate - * carries it). Decimals supported on `chapter` (e.g. 47.5). + * - `volumes` / `chapters`: At least one should be non-null. Each is a + * normalized [`NumericSpan`] list (sorted ascending, overlapping spans + * merged) describing every volume / chapter the release covers. Single + * values are one-element lists with `start === end`; ranges are + * one-element lists with `end > start`; disjoint coverage produces + * multiple spans. Decimals supported on chapter spans. * - `language`: ISO 639-1 code, lowercase. Must be non-empty. The host's * `latest_known_*` advance gate uses this against the per-series * effective language list. @@ -93,8 +106,10 @@ export interface SeriesMatch { export interface ReleaseCandidate { seriesMatch: SeriesMatch; externalReleaseId: string; - chapter?: number | null; - volume?: number | null; + /** Volume coverage. Integer span list. `null` when no volume info. */ + volumes?: NumericSpan[] | null; + /** Chapter coverage. Decimal-capable span list. `null` when no chapter info. */ + chapters?: NumericSpan[] | null; language: string; formatHints?: Record | null; groupOrUploader?: string | null; diff --git a/src/api/docs.rs b/src/api/docs.rs index cb80dcc0..a28d6289 100644 --- a/src/api/docs.rs +++ b/src/api/docs.rs @@ -710,6 +710,7 @@ The following paths are exempt from rate limiting: v1::dto::tracking::CreateSeriesAliasRequest, // Release-ledger + source DTOs (Phase 2) + v1::dto::release::ReleaseSpanDto, v1::dto::release::ReleaseLedgerEntryDto, v1::dto::release::ReleaseLedgerListResponse, v1::dto::release::UpdateReleaseLedgerEntryRequest, diff --git a/src/api/routes/v1/dto/release.rs b/src/api/routes/v1/dto/release.rs index f386c894..07cfad55 100644 --- a/src/api/routes/v1/dto/release.rs +++ b/src/api/routes/v1/dto/release.rs @@ -21,6 +21,19 @@ use crate::db::entities::{release_ledger, release_sources}; // Release ledger DTOs // ============================================================================= +/// Inclusive numeric span. Single values are encoded as `start == end` +/// (`{ start: 5, end: 5 }`). The release ledger surfaces volume / chapter +/// coverage as a list of these so disjoint compilations (`v01-04 + v06-09`) +/// survive end-to-end. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReleaseSpanDto { + #[schema(example = 1.0)] + pub start: f64, + #[schema(example = 9.0)] + pub end: f64, +} + /// A single release announcement. Sources write these; the inbox reads them. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] @@ -43,11 +56,15 @@ pub struct ReleaseLedgerEntryDto { /// Torrent info_hash, if applicable. #[serde(skip_serializing_if = "Option::is_none")] pub info_hash: Option, - /// Decimal supports `12.5` etc. + /// Full chapter coverage as a normalized span list. Decimals supported + /// (`c12.5` → `[{start: 12.5, end: 12.5}]`). `null` when the upstream + /// title carried no chapter info. #[serde(skip_serializing_if = "Option::is_none")] - pub chapter: Option, + pub chapters: Option>, + /// Full volume coverage as a normalized span list. `null` semantics + /// mirror [`Self::chapters`]. #[serde(skip_serializing_if = "Option::is_none")] - pub volume: Option, + pub volumes: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub language: Option, /// Sparse `{ "jxl": true, "container": "cbz", ... }`. @@ -90,8 +107,8 @@ impl ReleaseLedgerEntryDto { source_id: m.source_id, external_release_id: m.external_release_id, info_hash: m.info_hash, - chapter: m.chapter, - volume: m.volume, + chapters: spans_from_json(m.chapters.as_ref()), + volumes: spans_from_json(m.volumes.as_ref()), language: m.language, format_hints: m.format_hints, group_or_uploader: m.group_or_uploader, @@ -107,6 +124,20 @@ impl ReleaseLedgerEntryDto { } } +/// Decode the stored JSON span list (`[{start, end}, ...]`) back into a +/// typed `Vec`. Bad JSON (corrupted row, future schema +/// drift) collapses to `None` rather than failing the inbox query — +/// callers see "no span info" and the UI falls back to a dash. We +/// preserve the row in case a future migration can reinterpret it. +fn spans_from_json(value: Option<&serde_json::Value>) -> Option> { + let v = value?; + let parsed: Vec = serde_json::from_value(v.clone()).ok()?; + if parsed.is_empty() { + return None; + } + Some(parsed) +} + /// PATCH payload for ledger row state transitions. /// /// Only `state` is patchable from the API today; the rest of the row is diff --git a/src/db/entities/release_ledger.rs b/src/db/entities/release_ledger.rs index 1ce6b13a..806718f3 100644 --- a/src/db/entities/release_ledger.rs +++ b/src/db/entities/release_ledger.rs @@ -22,9 +22,21 @@ pub struct Model { pub external_release_id: String, /// Optional. Torrent sources have it; HTTP sources don't. pub info_hash: Option, - /// Decimal handles 12.5, 110.1, etc. + /// Primary chapter index. Used by SQL `ORDER BY` and as a cheap + /// "highest covered chapter" sort key. Derived from + /// [`Self::chapters`] as `max(span.end)` on insert; `None` when + /// [`Self::chapters`] is `None`. Decimal handles 12.5, 110.1, etc. pub chapter: Option, + /// Primary volume index. Mirrors [`Self::chapter`]. pub volume: Option, + /// Full chapter coverage as a JSON span array + /// (`[{"start": N, "end": M}, ...]`). Source of truth for display and + /// auto-ignore — disjoint coverage from compilation torrents survives + /// here even though [`Self::chapter`] only carries the primary value. + /// `None` when the upstream title carried no chapter info. + pub chapters: Option, + /// Full volume coverage. Mirrors [`Self::chapters`] semantics. + pub volumes: Option, pub language: Option, /// `{ "jxl": true, "container": "cbz", ... }`. pub format_hints: Option, diff --git a/src/db/repositories/release_ledger.rs b/src/db/repositories/release_ledger.rs index 20583f64..92d3620b 100644 --- a/src/db/repositories/release_ledger.rs +++ b/src/db/repositories/release_ledger.rs @@ -18,16 +18,24 @@ use uuid::Uuid; use crate::db::entities::release_ledger::{ self, Entity as ReleaseLedger, Model as ReleaseLedgerRow, state, }; +use crate::services::release::candidate::{NumericSpan, normalize_spans, primary_value}; /// New-row payload. Keys plus payload fields. +/// +/// Volume / chapter coverage is supplied as [`NumericSpan`] lists; the +/// repository normalizes them, derives the primary scalar columns +/// (`chapter` / `volume` = `max(span.end)`) for SQL ORDER BY, and stores +/// the full span list as JSON for display and auto-ignore. #[derive(Debug, Clone)] pub struct NewReleaseEntry { pub series_id: Uuid, pub source_id: Uuid, pub external_release_id: String, pub info_hash: Option, - pub chapter: Option, - pub volume: Option, + /// Full chapter coverage. `None` when no chapter info. + pub chapters: Option>, + /// Full volume coverage. `None` when no volume info. + pub volumes: Option>, pub language: Option, pub format_hints: Option, pub group_or_uploader: Option, @@ -145,14 +153,33 @@ impl ReleaseLedgerRepository { Some(invalid) => anyhow::bail!("invalid initial_state: {}", invalid), None => state::ANNOUNCED.to_string(), }; + // Normalize spans, then derive the primary scalar columns from + // them. The scalars stay in the schema so SeaORM-typed `ORDER BY` + // queries (the inbox / per-series sort) keep working without + // DB-specific JSON path expressions. + let chapter_spans = normalize_spans(entry.chapters); + let volume_spans = normalize_spans(entry.volumes); + let chapter_scalar = primary_value(chapter_spans.as_ref()); + let volume_scalar = primary_value(volume_spans.as_ref()).map(|v| v as i32); + let chapters_json = chapter_spans + .as_ref() + .map(serde_json::to_value) + .transpose()?; + let volumes_json = volume_spans + .as_ref() + .map(serde_json::to_value) + .transpose()?; + let active = release_ledger::ActiveModel { id: Set(Uuid::new_v4()), series_id: Set(entry.series_id), source_id: Set(entry.source_id), external_release_id: Set(entry.external_release_id), info_hash: Set(entry.info_hash), - chapter: Set(entry.chapter), - volume: Set(entry.volume), + chapter: Set(chapter_scalar), + volume: Set(volume_scalar), + chapters: Set(chapters_json), + volumes: Set(volumes_json), language: Set(entry.language), format_hints: Set(entry.format_hints), group_or_uploader: Set(entry.group_or_uploader), @@ -552,8 +579,11 @@ mod tests { source_id, external_release_id: ext_id.to_string(), info_hash: None, - chapter: Some(143.0), - volume: None, + chapters: Some(vec![NumericSpan { + start: 143.0, + end: 143.0, + }]), + volumes: None, language: Some("en".to_string()), format_hints: None, group_or_uploader: Some("tsuna69".to_string()), @@ -710,10 +740,16 @@ mod tests { let now = Utc::now(); let mut high_old = entry(series_id, source_id, "rel-high"); - high_old.chapter = Some(200.0); + high_old.chapters = Some(vec![NumericSpan { + start: 200.0, + end: 200.0, + }]); high_old.observed_at = now - chrono::Duration::hours(6); let mut low_new = entry(series_id, source_id, "rel-low"); - low_new.chapter = Some(150.0); + low_new.chapters = Some(vec![NumericSpan { + start: 150.0, + end: 150.0, + }]); low_new.observed_at = now; ReleaseLedgerRepository::record(conn, high_old) .await @@ -860,13 +896,13 @@ mod tests { // Earlier batch: lower chapters. Later batch: higher chapters. for ch in [122.0_f64, 123.0, 124.0, 125.0] { let mut e = entry(series_id, source_id, &format!("rel-{}", ch)); - e.chapter = Some(ch); + e.chapters = Some(vec![NumericSpan { start: ch, end: ch }]); e.observed_at = earlier; ReleaseLedgerRepository::record(conn, e).await.unwrap(); } for ch in [150.0_f64, 151.0, 156.0] { let mut e = entry(series_id, source_id, &format!("rel-{}", ch)); - e.chapter = Some(ch); + e.chapters = Some(vec![NumericSpan { start: ch, end: ch }]); e.observed_at = now; ReleaseLedgerRepository::record(conn, e).await.unwrap(); } @@ -895,7 +931,7 @@ mod tests { // Insert in shuffled chapter order to prove the DB is doing the sort. for ch in [129.0_f64, 145.0, 122.0, 150.5, 137.0, 156.0, 138.0] { let mut e = entry(series_id, source_id, &format!("rel-{}", ch)); - e.chapter = Some(ch); + e.chapters = Some(vec![NumericSpan { start: ch, end: ch }]); e.observed_at = now; ReleaseLedgerRepository::record(conn, e).await.unwrap(); } @@ -928,16 +964,28 @@ mod tests { let now = Utc::now(); let mut a1 = entry(series_a, src, "a-1"); - a1.chapter = Some(10.0); + a1.chapters = Some(vec![NumericSpan { + start: 10.0, + end: 10.0, + }]); a1.observed_at = now; let mut a2 = entry(series_a, src, "a-2"); - a2.chapter = Some(20.0); + a2.chapters = Some(vec![NumericSpan { + start: 20.0, + end: 20.0, + }]); a2.observed_at = now; let mut b1 = entry(series_b.id, src, "b-1"); - b1.chapter = Some(5.0); + b1.chapters = Some(vec![NumericSpan { + start: 5.0, + end: 5.0, + }]); b1.observed_at = now; let mut b2 = entry(series_b.id, src, "b-2"); - b2.chapter = Some(7.0); + b2.chapters = Some(vec![NumericSpan { + start: 7.0, + end: 7.0, + }]); b2.observed_at = now; // Insert interleaved to prove ordering doesn't leak from insertion order. ReleaseLedgerRepository::record(conn, a1).await.unwrap(); diff --git a/src/services/plugin/releases_handler.rs b/src/services/plugin/releases_handler.rs index ac8e70ce..0597252d 100644 --- a/src/services/plugin/releases_handler.rs +++ b/src/services/plugin/releases_handler.rs @@ -285,18 +285,32 @@ impl ReleasesRequestHandler { }; // Snapshot the candidate fields needed for the latest_known_* gate - // before the move into the ledger entry. - let candidate_chapter = accepted.candidate.chapter; - let candidate_volume = accepted.candidate.volume; + // before the move into the ledger entry. We use the primary scalar + // (max span end) for the high-water mark — a release covering "up + // to chapter N" advances `latest_known_chapter` to N. + let candidate_volumes = accepted.candidate.volumes.clone(); + let candidate_chapters = accepted.candidate.chapters.clone(); + let candidate_volume_primary = + crate::services::release::candidate::primary_value(candidate_volumes.as_ref()) + .map(|v| v as i32); + let candidate_chapter_primary = + crate::services::release::candidate::primary_value(candidate_chapters.as_ref()); let candidate_language = accepted.candidate.language.clone(); - // Auto-ignore: if the user already owns this volume/chapter, insert + // Auto-ignore: if the user already owns the *full* coverage of this + // release (every value in every span on at least one axis), insert // the row directly as `ignored` so it skips the inbox + notify path. // Best-effort; on failure we fall back to the default state. - let initial_state = if candidate_volume.is_some() || candidate_chapter.is_some() { + let has_axis_info = candidate_volumes.as_ref().is_some_and(|s| !s.is_empty()) + || candidate_chapters.as_ref().is_some_and(|s| !s.is_empty()); + let initial_state = if has_axis_info { match SeriesRepository::get_owned_release_keys_for_series(&self.db, series_id).await { Ok(owned) => { - if should_auto_ignore(candidate_volume, candidate_chapter, &owned) { + if should_auto_ignore( + candidate_volumes.as_deref(), + candidate_chapters.as_deref(), + &owned, + ) { Some(ledger_state::IGNORED.to_string()) } else { None @@ -339,8 +353,8 @@ impl ReleasesRequestHandler { .advance_latest_known( series_id, tracking_row.as_ref(), - candidate_chapter, - candidate_volume, + candidate_chapter_primary, + candidate_volume_primary, &candidate_language, ) .await @@ -948,7 +962,7 @@ mod tests { }; use crate::db::test_helpers::create_test_db; use crate::services::plugin::protocol::ReleaseSourceKind; - use crate::services::release::candidate::SeriesMatch; + use crate::services::release::candidate::{NumericSpan, SeriesMatch}; use serde_json::json; fn make_capability( @@ -1012,8 +1026,11 @@ mod tests { reason: "alias-exact".to_string(), }, external_release_id: "rel-1".to_string(), - chapter: Some(143.0), - volume: None, + chapters: Some(vec![NumericSpan { + start: 143.0, + end: 143.0, + }]), + volumes: None, language: "en".to_string(), format_hints: None, group_or_uploader: Some("tsuna69".to_string()), @@ -1524,8 +1541,13 @@ mod tests { reason: "test".to_string(), }, external_release_id: external_release_id.to_string(), - chapter, - volume, + chapters: chapter.map(|c| vec![NumericSpan { start: c, end: c }]), + volumes: volume.map(|v| { + vec![NumericSpan { + start: v as f64, + end: v as f64, + }] + }), language: language.to_string(), format_hints: None, group_or_uploader: Some("group-x".to_string()), diff --git a/src/services/release/auto_ignore.rs b/src/services/release/auto_ignore.rs index ca7a2843..8a4edabd 100644 --- a/src/services/release/auto_ignore.rs +++ b/src/services/release/auto_ignore.rs @@ -1,6 +1,11 @@ //! Decide whether an incoming release matches something the user already //! owns, so ingestion can mark it `ignored` instead of `announced`. //! +//! Range-aware. A release expresses its coverage as two span lists +//! (volumes, chapters). For a release to be auto-ignored we require *every* +//! value in *every* span on at least one axis to already be owned — owning +//! one volume of a `v01-10` compilation is no longer enough to hide it. +//! //! Direct matches only. We do not infer chapter ownership from owned //! volumes (chapter→volume mapping is unreliable upstream) or vice versa. //! @@ -14,6 +19,8 @@ //! an owned `(Some(3), None)`; a release for "Ch 12" matches an owned //! `(_, Some(12))` regardless of volume. +use crate::services::release::candidate::NumericSpan; + /// Per-series ownership signature consumed by [`should_auto_ignore`]. #[derive(Debug, Default, Clone)] pub struct OwnedReleaseKeys { @@ -33,57 +40,150 @@ pub struct OwnedReleaseKeys { pub volumes_owned_count: i64, } -/// True when the release matches a directly-owned key. +/// True when the user owns *every* item the release covers. +/// +/// We model the release as a set of `(volume, chapter)` items: +/// - Both axes have spans: the cartesian product of every value in +/// every volume span × every value in every chapter span. +/// - Volume axis only: each value `v` becomes the item `(v, _)`. +/// - Chapter axis only: each value `c` becomes the item `(_, c)`. +/// - Neither axis: zero items, never auto-ignored. /// -/// Matching rules: -/// - **Volume + chapter release**: matches an owned `(Some(v), Some(c))`, -/// or an owned whole volume `(Some(v), None)` (whole volume implies all -/// chapters in it). -/// - **Volume-only release**: matches an owned whole volume -/// `(Some(v), None)`. Does NOT match if the user only owns specific -/// chapters of that volume. -/// - **Chapter-only release**: matches any owned key with the same -/// chapter, regardless of volume. -/// - **No volume and no chapter**: never auto-ignored. +/// An item is "owned" by the rules below. Auto-ignore fires iff *every* +/// covered item is owned. /// -/// **Count fallback**: only when `has_any_volume_metadata` is false (no -/// book has volume metadata at all). For a volume-N release, treat -/// `1..=volumes_owned_count` as owned. We do not apply the count fallback -/// to chapter-only releases. +/// Item ownership rules: +/// - `(v, c)` paired item: at least one of +/// - `(Some(v), Some(c))` — exact pair owned, or +/// - `(Some(v), None)` — whole volume `v` owned (covers every chapter +/// in it), or +/// - `(None, Some(c))` — chapter `c` owned with no volume tag (chapters +/// are unique identifiers across the series; a vol-untagged ch `c` is +/// the same chapter as `(v, c)`). +/// - count fallback for the volume side when no book has volume metadata. +/// - We deliberately do *not* accept `(Some(other_v), Some(c))`. Owning +/// ch 5 of vol 1 does not cover "ch 5 of vol 2" — those are different +/// items even though they share a chapter number, because the volume +/// pin distinguishes them. +/// - `(v, _)` volume-only item: whole-volume key `(Some(v), None)` or count +/// fallback. Specific-chapter ownership of v does *not* count. +/// - `(_, c)` chapter-only item: any owned key with chapter `c`, regardless +/// of volume. Whole-volume ownership does *not* infer chapter ownership +/// (chapter→volume mapping unreliable). +/// +/// Range examples: +/// - `v01-10` (vol-only range): auto-ignore iff each of vols 1..=10 is +/// owned as a whole. +/// - `001-050 as v01-10` (paired ranges): cross-product is 500 items, but +/// owning all 10 whole volumes covers every pair (because each pair +/// `(v, c)` gets the whole-vol-v rule). Equivalently, owning all 50 +/// no-vol chapter keys covers every pair via the chapter rule. +/// - `v01-04 + v06-09` (disjoint vol range): vol 5 is *not* in the +/// coverage set, so not owning vol 5 doesn't block auto-ignore. pub fn should_auto_ignore( - release_volume: Option, - release_chapter: Option, + release_volumes: Option<&[NumericSpan]>, + release_chapters: Option<&[NumericSpan]>, owned: &OwnedReleaseKeys, ) -> bool { - match (release_volume, release_chapter) { - (None, None) => false, - - (Some(v), Some(c)) => owned.keys.iter().any(|(ov, oc)| match (ov, oc) { - (Some(ov), Some(oc)) => *ov == v && chapter_eq(*oc, c), - (Some(ov), None) => *ov == v, - _ => false, - }), - - (Some(v), None) => { - let direct = owned - .keys - .iter() - .any(|(ov, oc)| matches!((ov, oc), (Some(ov), None) if *ov == v)); - if direct { - return true; - } - // Count fallback: only when no book has volume metadata. - if !owned.has_any_volume_metadata && owned.volumes_owned_count > 0 { - return (v as i64) <= owned.volumes_owned_count; - } - false - } + let has_volume_info = release_volumes.is_some_and(|s| !s.is_empty()); + let has_chapter_info = release_chapters.is_some_and(|s| !s.is_empty()); + if !has_volume_info && !has_chapter_info { + return false; + } + + let vol_values: Vec = release_volumes + .into_iter() + .flatten() + .flat_map(span_integer_iter) + .collect(); + let chap_values: Vec = release_chapters + .into_iter() + .flatten() + .flat_map(chapter_span_values) + .collect(); - (None, Some(c)) => owned - .keys + match (has_volume_info, has_chapter_info) { + (true, true) => vol_values .iter() - .any(|(_, oc)| matches!(oc, Some(oc) if chapter_eq(*oc, c))), + .all(|v| chap_values.iter().all(|c| pair_owned(*v, *c, owned))), + (true, false) => vol_values.iter().all(|v| volume_owned(*v, owned)), + (false, true) => chap_values.iter().all(|c| chapter_owned(*c, owned)), + (false, false) => false, + } +} + +/// Enumerate the integer values an integer-bounded volume span covers. +/// Volume spans are always integer in the schema; we cast through `i32`. +fn span_integer_iter(span: &NumericSpan) -> std::ops::RangeInclusive { + let start = span.start.ceil() as i32; + let end = span.end.floor() as i32; + start..=end +} + +/// Enumerate the chapter values a chapter span covers. Single-point spans +/// (`{12.5, 12.5}`) yield exactly the start (so decimals survive). Range +/// spans enumerate the integers from ceil(start)..=floor(end), and append +/// the start/end if they're non-integer to avoid silently accepting +/// integer-only coverage as ownership of `{1.5, 9.5}`. +fn chapter_span_values(span: &NumericSpan) -> Vec { + if span.start == span.end { + return vec![span.start]; + } + let start_i = span.start.ceil() as i64; + let end_i = span.end.floor() as i64; + let mut out: Vec = (start_i..=end_i).map(|c| c as f64).collect(); + if span.start.fract() != 0.0 { + out.push(span.start); + } + if span.end.fract() != 0.0 { + out.push(span.end); + } + out +} + +/// True when the user "owns" the `(v, c)` item (see the rules table on +/// [`should_auto_ignore`]). +fn pair_owned(v: i32, c: f64, owned: &OwnedReleaseKeys) -> bool { + for (ov, oc) in &owned.keys { + match (ov, oc) { + // Exact pair. + (Some(ov), Some(oc)) if *ov == v && chapter_eq(*oc, c) => return true, + // Whole volume v. + (Some(ov), None) if *ov == v => return true, + // No-vol chapter c. + (None, Some(oc)) if chapter_eq(*oc, c) => return true, + _ => {} + } + } + // Count fallback (volume side): no book has any volume metadata, but + // we know at least N volumes are owned and v is within that count. + if !owned.has_any_volume_metadata && owned.volumes_owned_count > 0 { + return (v as i64) <= owned.volumes_owned_count; } + false +} + +/// True when the user owns whole volume `v`, or the count fallback applies. +fn volume_owned(v: i32, owned: &OwnedReleaseKeys) -> bool { + let direct = owned + .keys + .iter() + .any(|(ov, oc)| matches!((ov, oc), (Some(ov), None) if *ov == v)); + if direct { + return true; + } + if !owned.has_any_volume_metadata && owned.volumes_owned_count > 0 { + return (v as i64) <= owned.volumes_owned_count; + } + false +} + +/// True when the user owns chapter `c` (any volume tag). +fn chapter_owned(c: f64, owned: &OwnedReleaseKeys) -> bool { + owned + .keys + .iter() + .any(|(_, oc)| matches!(oc, Some(oc) if chapter_eq(*oc, c))) } /// Tolerant equality for chapter numbers. `f64` because both sides come @@ -110,34 +210,71 @@ mod tests { } } + /// Wrap a single integer volume value as a one-element span list. + fn vol(v: i32) -> Vec { + vec![NumericSpan { + start: v as f64, + end: v as f64, + }] + } + + /// Wrap a single chapter value as a one-element span list. + fn chap(c: f64) -> Vec { + vec![NumericSpan { start: c, end: c }] + } + + /// Inclusive volume range as a single span. + fn vol_range(start: i32, end: i32) -> Vec { + vec![NumericSpan { + start: start as f64, + end: end as f64, + }] + } + + /// Inclusive chapter range as a single span. + fn chap_range(start: f64, end: f64) -> Vec { + vec![NumericSpan { start, end }] + } + + /// Run `should_auto_ignore` against borrowed slices. + fn ignore( + v: Option<&Vec>, + c: Option<&Vec>, + o: &OwnedReleaseKeys, + ) -> bool { + should_auto_ignore(v.map(|s| s.as_slice()), c.map(|s| s.as_slice()), o) + } + + // ---------- single-value (point span) backwards-compatibility tests ------ + #[test] fn volume_release_owned_as_whole_volume() { let o = owned(vec![(Some(1), None), (Some(2), None)]); - assert!(should_auto_ignore(Some(1), None, &o)); - assert!(should_auto_ignore(Some(2), None, &o)); - assert!(!should_auto_ignore(Some(3), None, &o)); + assert!(ignore(Some(&vol(1)), None, &o)); + assert!(ignore(Some(&vol(2)), None, &o)); + assert!(!ignore(Some(&vol(3)), None, &o)); } #[test] fn volume_release_not_matched_by_chapter_in_volume() { // User only has chapter 5 of volume 1, not the whole volume. let o = owned(vec![(Some(1), Some(5.0))]); - assert!(!should_auto_ignore(Some(1), None, &o)); + assert!(!ignore(Some(&vol(1)), None, &o)); } #[test] fn chapter_release_matches_any_volume() { let o = owned(vec![(Some(2), Some(12.0))]); // Release "Ch 12, vol unknown" → owned by virtue of having ch 12 of vol 2. - assert!(should_auto_ignore(None, Some(12.0), &o)); - assert!(!should_auto_ignore(None, Some(13.0), &o)); + assert!(ignore(None, Some(&chap(12.0)), &o)); + assert!(!ignore(None, Some(&chap(13.0)), &o)); } #[test] fn chapter_release_matches_chapter_only_owned() { let o = owned(vec![(None, Some(7.0))]); - assert!(should_auto_ignore(None, Some(7.0), &o)); - assert!(!should_auto_ignore(None, Some(8.0), &o)); + assert!(ignore(None, Some(&chap(7.0)), &o)); + assert!(!ignore(None, Some(&chap(8.0)), &o)); } #[test] @@ -145,23 +282,28 @@ mod tests { // User owns volume 1 (whole). Release is "Ch 5". // We do NOT infer ch 5 is in vol 1 — chapter→volume mapping unreliable. let o = owned(vec![(Some(1), None)]); - assert!(!should_auto_ignore(None, Some(5.0), &o)); + assert!(!ignore(None, Some(&chap(5.0)), &o)); } #[test] fn vol_plus_chapter_release_matches_exact_pair() { + // OR semantics: release {vol=1, chap=5} auto-ignores when EITHER + // axis is fully owned. Owning chapter 5 (alone) covers the chapter + // axis, and "matches an exact pair" is one way to satisfy that. let o = owned(vec![(Some(1), Some(5.0))]); - assert!(should_auto_ignore(Some(1), Some(5.0), &o)); - assert!(!should_auto_ignore(Some(1), Some(6.0), &o)); - assert!(!should_auto_ignore(Some(2), Some(5.0), &o)); + assert!(ignore(Some(&vol(1)), Some(&chap(5.0)), &o)); + // No vol 1 ownership and no ch 6 ownership → not ignored. + assert!(!ignore(Some(&vol(1)), Some(&chap(6.0)), &o)); + // No vol 2 ownership and no ch 5 ownership without vol 1 → not ignored. + assert!(!ignore(Some(&vol(2)), Some(&chap(5.0)), &o)); } #[test] fn vol_plus_chapter_release_matches_whole_volume() { - // Whole volume implies all chapters in it. + // Whole volume satisfies the volume axis on its own. let o = owned(vec![(Some(1), None)]); - assert!(should_auto_ignore(Some(1), Some(5.0), &o)); - assert!(should_auto_ignore(Some(1), Some(99.5), &o)); + assert!(ignore(Some(&vol(1)), Some(&chap(5.0)), &o)); + assert!(ignore(Some(&vol(1)), Some(&chap(99.5)), &o)); } #[test] @@ -172,19 +314,17 @@ mod tests { has_any_volume_metadata: false, volumes_owned_count: 2, }; - assert!(should_auto_ignore(Some(1), None, &o)); - assert!(should_auto_ignore(Some(2), None, &o)); - assert!(!should_auto_ignore(Some(3), None, &o)); + assert!(ignore(Some(&vol(1)), None, &o)); + assert!(ignore(Some(&vol(2)), None, &o)); + assert!(!ignore(Some(&vol(3)), None, &o)); } #[test] fn count_fallback_inactive_when_metadata_present() { - // User owns vols 3, 5, 7 (with metadata). Count fallback must NOT - // hide vol 1 — that's the bug the metadata path fixes. let o = owned(vec![(Some(3), None), (Some(5), None), (Some(7), None)]); - assert!(!should_auto_ignore(Some(1), None, &o)); - assert!(should_auto_ignore(Some(3), None, &o)); - assert!(!should_auto_ignore(Some(4), None, &o)); + assert!(!ignore(Some(&vol(1)), None, &o)); + assert!(ignore(Some(&vol(3)), None, &o)); + assert!(!ignore(Some(&vol(4)), None, &o)); } #[test] @@ -194,27 +334,130 @@ mod tests { has_any_volume_metadata: false, volumes_owned_count: 5, }; - assert!(!should_auto_ignore(None, Some(3.0), &o)); + assert!(!ignore(None, Some(&chap(3.0)), &o)); } #[test] fn release_with_no_volume_or_chapter_never_ignored() { let o = owned(vec![(Some(1), None)]); - assert!(!should_auto_ignore(None, None, &o)); + assert!(!ignore(None, None, &o)); + } + + #[test] + fn empty_span_lists_treated_as_no_info() { + let o = owned(vec![(Some(1), None)]); + let empty: Vec = vec![]; + assert!(!ignore(Some(&empty), Some(&empty), &o)); } #[test] fn empty_owned_set_never_ignores() { let o = OwnedReleaseKeys::default(); - assert!(!should_auto_ignore(Some(1), None, &o)); - assert!(!should_auto_ignore(None, Some(1.0), &o)); - assert!(!should_auto_ignore(Some(1), Some(1.0), &o)); + assert!(!ignore(Some(&vol(1)), None, &o)); + assert!(!ignore(None, Some(&chap(1.0)), &o)); + assert!(!ignore(Some(&vol(1)), Some(&chap(1.0)), &o)); } #[test] fn fractional_chapter_matches() { let o = owned(vec![(Some(1), Some(12.5))]); - assert!(should_auto_ignore(None, Some(12.5), &o)); - assert!(!should_auto_ignore(None, Some(12.0), &o)); + assert!(ignore(None, Some(&chap(12.5)), &o)); + assert!(!ignore(None, Some(&chap(12.0)), &o)); + } + + // ---------- range-aware tests -------------------------------------------- + + #[test] + fn volume_range_requires_full_ownership() { + // Release v01-09. Owning vol 1 alone is not enough. + let owns_only_vol_1 = owned(vec![(Some(1), None)]); + assert!(!ignore(Some(&vol_range(1, 9)), None, &owns_only_vol_1)); + + // Owning vols 1-9 satisfies the axis. + let all = owned((1..=9).map(|v| (Some(v), None)).collect::>()); + assert!(ignore(Some(&vol_range(1, 9)), None, &all)); + + // Missing exactly one (vol 5) → not ignored. + let missing_5 = owned( + (1..=9) + .filter(|v| *v != 5) + .map(|v| (Some(v), None)) + .collect::>(), + ); + assert!(!ignore(Some(&vol_range(1, 9)), None, &missing_5)); + } + + #[test] + fn disjoint_volume_spans_skip_the_gap() { + // Release v01-04 + v06-09 (vol 5 not in the bundle). + let spans = vec![ + NumericSpan { + start: 1.0, + end: 4.0, + }, + NumericSpan { + start: 6.0, + end: 9.0, + }, + ]; + // Owning vols 1-4 + 6-9 (without vol 5) covers exactly the bundle. + let exact = owned( + (1..=4) + .chain(6..=9) + .map(|v| (Some(v), None)) + .collect::>(), + ); + assert!(ignore(Some(&spans), None, &exact)); + + // Owning vols 1-4 only → still missing 6-9 → not ignored. + let half = owned((1..=4).map(|v| (Some(v), None)).collect::>()); + assert!(!ignore(Some(&spans), None, &half)); + } + + #[test] + fn chapter_range_requires_full_ownership() { + // Release c031-037. Owning ch 31 alone isn't enough. + let only_31 = owned(vec![(None, Some(31.0))]); + assert!(!ignore(None, Some(&chap_range(31.0, 37.0)), &only_31)); + + // Owning ch 31..=37 satisfies. + let all = owned( + (31..=37) + .map(|c| (None, Some(c as f64))) + .collect::>(), + ); + assert!(ignore(None, Some(&chap_range(31.0, 37.0)), &all)); + } + + #[test] + fn either_axis_full_ownership_suffices_for_compilation() { + // Release `001-050 as v01-10`: covers chs 1..=50 AND vols 1..=10. + // OR-of-axes means owning all 10 volumes is enough, even when no + // chapter-tagged ownership rows exist. + let vols = vol_range(1, 10); + let chaps = chap_range(1.0, 50.0); + let owns_all_vols = owned((1..=10).map(|v| (Some(v), None)).collect::>()); + assert!(ignore(Some(&vols), Some(&chaps), &owns_all_vols)); + + // And owning all 50 chapters (no volume rows) is also enough. + let owns_all_chaps = owned((1..=50).map(|c| (None, Some(c as f64))).collect::>()); + assert!(ignore(Some(&vols), Some(&chaps), &owns_all_chaps)); + + // Owning only vol 1 → neither axis full → not ignored. + let owns_partial = owned(vec![(Some(1), None)]); + assert!(!ignore(Some(&vols), Some(&chaps), &owns_partial)); + } + + #[test] + fn count_fallback_works_against_volume_ranges() { + // No metadata, but user has 5 volumes counted. Release v01-05 → ignored. + let o = OwnedReleaseKeys { + keys: vec![], + has_any_volume_metadata: false, + volumes_owned_count: 5, + }; + assert!(ignore(Some(&vol_range(1, 5)), None, &o)); + // Release v01-06 → vol 6 not covered by count → not ignored. + assert!(!ignore(Some(&vol_range(1, 6)), None, &o)); } } diff --git a/src/services/release/candidate.rs b/src/services/release/candidate.rs index 48ce99b5..352bfe5b 100644 --- a/src/services/release/candidate.rs +++ b/src/services/release/candidate.rs @@ -8,6 +8,83 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +/// Inclusive numeric span. Single values are encoded as `start == end` +/// (e.g. `NumericSpan { start: 5.0, end: 5.0 }`). +/// +/// A release candidate carries one [`Vec`] per axis (volumes +/// and chapters). Disjoint coverage (`v01-04 + v06-09`) is preserved as +/// multiple spans; the host's auto-ignore walks every value in every span +/// before deciding the user owns the release. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NumericSpan { + pub start: f64, + pub end: f64, +} + +/// Normalize a span list: +/// 1. Swap any span where `start > end` (defensive against buggy plugins). +/// 2. Sort ascending by `start`, then `end`. +/// 3. Merge overlapping spans (touching counts as overlap). +/// +/// Mirrors the parser-side `normalizeSpans` in [`plugins/release-nyaa`] so +/// host and plugin agree on the canonical shape stored in the ledger. +/// Returns `None` when the input is `Some(empty)` so callers can collapse +/// "I parsed an empty list" into "no info" before persistence. +pub fn normalize_spans(spans: Option>) -> Option> { + let raw = spans?; + if raw.is_empty() { + return None; + } + let mut fixed: Vec = raw + .into_iter() + .map(|s| { + if s.start <= s.end { + s + } else { + NumericSpan { + start: s.end, + end: s.start, + } + } + }) + .collect(); + fixed.sort_by(|a, b| { + a.start + .partial_cmp(&b.start) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + a.end + .partial_cmp(&b.end) + .unwrap_or(std::cmp::Ordering::Equal) + }) + }); + let mut out: Vec = Vec::with_capacity(fixed.len()); + for s in fixed { + match out.last_mut() { + Some(last) if s.start <= last.end => { + if s.end > last.end { + last.end = s.end; + } + } + _ => out.push(s), + } + } + Some(out) +} + +/// Highest end-value across every span. `None` for an empty / missing list. +/// Used to derive the primary scalar (`chapter` / `volume`) the SQL ORDER BY +/// clauses still rely on. +pub fn primary_value(spans: Option<&Vec>) -> Option { + let list = spans?; + list.iter().map(|s| s.end).fold(None, |acc, v| match acc { + None => Some(v), + Some(cur) if v > cur => Some(v), + other => other, + }) +} + /// A release candidate emitted by a `release_source` plugin. /// /// The series match is split out into its own struct so the plugin can @@ -20,12 +97,16 @@ pub struct ReleaseCandidate { pub series_match: SeriesMatch, /// Stable per-source release identifier (e.g. Nyaa view ID, MU release ID). pub external_release_id: String, - /// Optional chapter number; supports decimals for fractional chapters. + /// Volume coverage as a normalized span list. `None` when the upstream + /// title carried no volume information at all. Plugins are expected to + /// emit a sorted, overlap-merged list; the host re-normalizes + /// defensively before persisting. #[serde(default, skip_serializing_if = "Option::is_none")] - pub chapter: Option, - /// Optional volume number. + pub volumes: Option>, + /// Chapter coverage as a normalized span list. Decimals are preserved + /// (`c12.5` → `[{12.5, 12.5}]`). `None` semantics match [`Self::volumes`]. #[serde(default, skip_serializing_if = "Option::is_none")] - pub volume: Option, + pub chapters: Option>, /// ISO 639-1 language code (`"en"`, `"es"`, etc.). pub language: String, /// Free-form per-source format hints (e.g. `{"jxl": true}`). @@ -172,8 +253,11 @@ mod tests { reason: "alias-exact".to_string(), }, external_release_id: "rel-123".to_string(), - chapter: Some(143.0), - volume: None, + chapters: Some(vec![NumericSpan { + start: 143.0, + end: 143.0, + }]), + volumes: None, language: "en".to_string(), format_hints: Some(json!({"jxl": true})), group_or_uploader: Some("tsuna69".to_string()), @@ -206,8 +290,8 @@ mod tests { #[test] fn optional_fields_are_skipped_when_none() { let mut cand = good_candidate(); - cand.chapter = None; - cand.volume = None; + cand.chapters = None; + cand.volumes = None; cand.format_hints = None; cand.info_hash = None; cand.metadata = None; @@ -217,8 +301,8 @@ mod tests { let json = serde_json::to_value(&cand).unwrap(); let obj = json.as_object().unwrap(); for key in [ - "chapter", - "volume", + "chapters", + "volumes", "formatHints", "infoHash", "metadata", diff --git a/src/services/release/matcher.rs b/src/services/release/matcher.rs index 64e5e5dc..71af8711 100644 --- a/src/services/release/matcher.rs +++ b/src/services/release/matcher.rs @@ -39,8 +39,8 @@ impl AcceptedCandidate { source_id, external_release_id: c.external_release_id, info_hash: c.info_hash, - chapter: c.chapter, - volume: c.volume, + chapters: c.chapters, + volumes: c.volumes, language: Some(c.language), format_hints: c.format_hints, group_or_uploader: c.group_or_uploader, @@ -85,10 +85,19 @@ pub fn evaluate( if candidate.language.trim().is_empty() { return Err(CandidateReject::EmptyLanguage); } - if let Some(ch) = candidate.chapter - && !ch.is_finite() - { - return Err(CandidateReject::InvalidChapter); + // Reject any non-finite span endpoint on either axis. We treat + // disjoint and reversed spans elsewhere (the repo normalizes), but + // NaN / infinity in the wire payload is a hard error: the host has + // no sane way to derive the primary scalar or compare ownership. + let span_iter = candidate + .chapters + .iter() + .flatten() + .chain(candidate.volumes.iter().flatten()); + for s in span_iter { + if !s.start.is_finite() || !s.end.is_finite() { + return Err(CandidateReject::InvalidChapter); + } } let now = Utc::now(); @@ -123,7 +132,7 @@ pub fn resolve_threshold(per_series_override: Option) -> f64 { #[cfg(test)] mod tests { use super::*; - use crate::services::release::candidate::SeriesMatch; + use crate::services::release::candidate::{NumericSpan, SeriesMatch}; use chrono::Duration; fn make_candidate(confidence: f64) -> ReleaseCandidate { @@ -134,8 +143,11 @@ mod tests { reason: "test".to_string(), }, external_release_id: "rel-1".to_string(), - chapter: Some(143.0), - volume: None, + chapters: Some(vec![NumericSpan { + start: 143.0, + end: 143.0, + }]), + volumes: None, language: "en".to_string(), format_hints: None, group_or_uploader: None, @@ -203,7 +215,10 @@ mod tests { #[test] fn rejects_invalid_chapter() { let mut cand = make_candidate(0.95); - cand.chapter = Some(f64::INFINITY); + cand.chapters = Some(vec![NumericSpan { + start: f64::INFINITY, + end: f64::INFINITY, + }]); let err = evaluate(cand, DEFAULT_CONFIDENCE_THRESHOLD).unwrap_err(); assert_eq!(err, CandidateReject::InvalidChapter); } diff --git a/src/tasks/handlers/poll_release_source.rs b/src/tasks/handlers/poll_release_source.rs index 73f5796b..c115987d 100644 --- a/src/tasks/handlers/poll_release_source.rs +++ b/src/tasks/handlers/poll_release_source.rs @@ -370,15 +370,15 @@ impl TaskHandler for PollReleaseSourceHandler { }; match evaluate(cand, threshold) { Ok(accepted) => { - let cand_volume = accepted.candidate.volume; - let cand_chapter = accepted.candidate.chapter; + let cand_volumes = accepted.candidate.volumes.clone(); + let cand_chapters = accepted.candidate.chapters.clone(); let initial_state = match resolve_initial_state( db, &mut owned_cache, series_id, - cand_volume, - cand_chapter, + cand_volumes.as_deref(), + cand_chapters.as_deref(), ) .await { @@ -617,11 +617,13 @@ async fn resolve_initial_state( db: &DatabaseConnection, owned_cache: &mut std::collections::HashMap, series_id: Uuid, - volume: Option, - chapter: Option, + volumes: Option<&[crate::services::release::candidate::NumericSpan]>, + chapters: Option<&[crate::services::release::candidate::NumericSpan]>, ) -> Result> { + let has_v = volumes.is_some_and(|s| !s.is_empty()); + let has_c = chapters.is_some_and(|s| !s.is_empty()); // Skip the lookup entirely when the candidate has nothing to match against. - if volume.is_none() && chapter.is_none() { + if !has_v && !has_c { return Ok(None); } if let std::collections::hash_map::Entry::Vacant(e) = owned_cache.entry(series_id) { @@ -629,7 +631,7 @@ async fn resolve_initial_state( e.insert(owned); } let owned = &owned_cache[&series_id]; - if should_auto_ignore(volume, chapter, owned) { + if should_auto_ignore(volumes, chapters, owned) { Ok(Some(ledger_state::IGNORED.to_string())) } else { Ok(None) @@ -708,6 +710,8 @@ mod tests { info_hash: None, chapter: Some(143.0), volume: Some(15), + chapters: Some(serde_json::json!([{"start": 143.0, "end": 143.0}])), + volumes: Some(serde_json::json!([{"start": 15, "end": 15}])), language: Some("en".to_string()), format_hints: None, group_or_uploader: None, @@ -759,6 +763,8 @@ mod tests { info_hash: None, chapter: None, volume: None, + chapters: None, + volumes: None, language: None, format_hints: None, group_or_uploader: None, diff --git a/tests/api/releases.rs b/tests/api/releases.rs index de988941..fe466f1b 100644 --- a/tests/api/releases.rs +++ b/tests/api/releases.rs @@ -92,8 +92,11 @@ async fn record_announced( source_id, external_release_id: external_id.to_string(), info_hash: None, - chapter: Some(143.0), - volume: None, + chapters: Some(vec![codex::services::release::candidate::NumericSpan { + start: 143.0, + end: 143.0, + }]), + volumes: None, language: Some("en".to_string()), format_hints: None, group_or_uploader: Some("uploader".to_string()), diff --git a/web/openapi.json b/web/openapi.json index 4edf8913..3e3baa85 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -29777,13 +29777,15 @@ "createdAt" ], "properties": { - "chapter": { + "chapters": { "type": [ - "number", + "array", "null" ], - "format": "double", - "description": "Decimal supports `12.5` etc." + "items": { + "$ref": "#/components/schemas/ReleaseSpanDto" + }, + "description": "Full chapter coverage as a normalized span list. Decimals supported\n(`c12.5` → `[{start: 12.5, end: 12.5}]`). `null` when the upstream\ntitle carried no chapter info." }, "confidence": { "type": "number", @@ -29870,12 +29872,15 @@ "type": "string", "description": "`announced` | `dismissed` | `marked_acquired` | `hidden`." }, - "volume": { + "volumes": { "type": [ - "integer", + "array", "null" ], - "format": "int32" + "items": { + "$ref": "#/components/schemas/ReleaseSpanDto" + }, + "description": "Full volume coverage as a normalized span list. `null` semantics\nmirror [`Self::chapters`]." } } }, @@ -33195,13 +33200,15 @@ "createdAt" ], "properties": { - "chapter": { + "chapters": { "type": [ - "number", + "array", "null" ], - "format": "double", - "description": "Decimal supports `12.5` etc." + "items": { + "$ref": "#/components/schemas/ReleaseSpanDto" + }, + "description": "Full chapter coverage as a normalized span list. Decimals supported\n(`c12.5` → `[{start: 12.5, end: 12.5}]`). `null` when the upstream\ntitle carried no chapter info." }, "confidence": { "type": "number", @@ -33288,12 +33295,15 @@ "type": "string", "description": "`announced` | `dismissed` | `marked_acquired` | `hidden`." }, - "volume": { + "volumes": { "type": [ - "integer", + "array", "null" ], - "format": "int32" + "items": { + "$ref": "#/components/schemas/ReleaseSpanDto" + }, + "description": "Full volume coverage as a normalized span list. `null` semantics\nmirror [`Self::chapters`]." } } }, @@ -33479,6 +33489,26 @@ } } }, + "ReleaseSpanDto": { + "type": "object", + "description": "Inclusive numeric span. Single values are encoded as `start == end`\n(`{ start: 5, end: 5 }`). The release ledger surfaces volume / chapter\ncoverage as a list of these so disjoint compilations (`v01-04 + v06-09`)\nsurvive end-to-end.", + "required": [ + "start", + "end" + ], + "properties": { + "end": { + "type": "number", + "format": "double", + "example": 9.0 + }, + "start": { + "type": "number", + "format": "double", + "example": 1.0 + } + } + }, "ReplaceBookMetadataRequest": { "type": "object", "description": "PUT request for full replacement of book metadata\n\nAll metadata fields will be replaced with the values in this request.\nOmitting a field (or setting it to null) will clear that field.", diff --git a/web/src/components/releases/ReleasesTable.test.tsx b/web/src/components/releases/ReleasesTable.test.tsx new file mode 100644 index 00000000..68d868b4 --- /dev/null +++ b/web/src/components/releases/ReleasesTable.test.tsx @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ReleaseLedgerEntry, ReleaseSource } from "@/api/releases"; +import { renderWithProviders, screen } from "@/test/utils"; +import { ReleasesTable } from "./ReleasesTable"; + +// ----------------------------------------------------------------------------- +// `formatChapterVolume` is internal but its behavior is the user-visible +// difference between "Vol 1" (the old, lying display) and "Vol 1-9, 11" +// (the truth for a compilation torrent). We exercise it via the rendered +// "Ch / Vol" cell so the tests describe the contract the UI presents. +// ----------------------------------------------------------------------------- + +const SOURCE: ReleaseSource = { + id: "11111111-1111-1111-1111-111111111111", + pluginId: "release-nyaa", + sourceKey: "user:1r0n", + displayName: "Nyaa: 1r0n", + kind: "rss-uploader", + enabled: true, + cronSchedule: null, + config: null, + etag: null, + lastPolledAt: null, + lastError: null, + lastErrorAt: null, + lastSummary: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", +} as unknown as ReleaseSource; + +function entry(overrides: Partial): ReleaseLedgerEntry { + return { + id: "ent-1", + seriesId: "00000000-0000-0000-0000-000000000001", + seriesTitle: "Test Series", + sourceId: SOURCE.id, + externalReleaseId: "ext-1", + payloadUrl: "https://nyaa.si/view/1", + confidence: 0.95, + state: "announced", + observedAt: "2026-05-01T00:00:00Z", + createdAt: "2026-05-01T00:00:00Z", + chapters: null, + volumes: null, + language: "en", + groupOrUploader: "1r0n", + ...overrides, + } as ReleaseLedgerEntry; +} + +function renderRow(e: ReleaseLedgerEntry) { + return renderWithProviders( + , + ); +} + +describe("ReleasesTable Ch / Vol formatting", () => { + it("renders a dash when neither axis has spans", () => { + renderRow(entry({})); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("renders a single-point chapter span as `Ch N`", () => { + renderRow(entry({ chapters: [{ start: 142, end: 142 }] })); + expect(screen.getByText("Ch 142")).toBeInTheDocument(); + }); + + it("renders a single-point volume span as `Vol N`", () => { + renderRow(entry({ volumes: [{ start: 13, end: 13 }] })); + expect(screen.getByText("Vol 13")).toBeInTheDocument(); + }); + + it("renders a chapter range as `Ch start-end`", () => { + renderRow(entry({ chapters: [{ start: 126, end: 142 }] })); + expect(screen.getByText("Ch 126-142")).toBeInTheDocument(); + }); + + it("renders a volume range as `Vol start-end`", () => { + renderRow(entry({ volumes: [{ start: 1, end: 9 }] })); + expect(screen.getByText("Vol 1-9")).toBeInTheDocument(); + }); + + it("renders both axes with a separator (`001-050 as v01-10`)", () => { + renderRow( + entry({ + chapters: [{ start: 1, end: 50 }], + volumes: [{ start: 1, end: 10 }], + }), + ); + expect(screen.getByText("Ch 1-50 · Vol 1-10")).toBeInTheDocument(); + }); + + it("preserves the gap in a disjoint volume bundle (`v01-04 + v06-09`)", () => { + renderRow( + entry({ + volumes: [ + { start: 1, end: 4 }, + { start: 6, end: 9 }, + ], + }), + ); + expect(screen.getByText("Vol 1-4, 6-9")).toBeInTheDocument(); + }); + + it("renders the Charlotte mixed bundle honestly (single vol + chapter pair)", () => { + // `001-005 as v01 + 006-009`: one volume span + two chapter spans. + renderRow( + entry({ + chapters: [ + { start: 1, end: 5 }, + { start: 6, end: 9 }, + ], + volumes: [{ start: 1, end: 1 }], + }), + ); + expect(screen.getByText("Ch 1-5, 6-9 · Vol 1")).toBeInTheDocument(); + }); + + it("preserves decimal chapters in single-point spans", () => { + renderRow(entry({ chapters: [{ start: 12.5, end: 12.5 }] })); + expect(screen.getByText("Ch 12.5")).toBeInTheDocument(); + }); + + it("treats an empty span list as no info (renders dash)", () => { + renderRow(entry({ chapters: [], volumes: [] })); + expect(screen.getByText("—")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/releases/ReleasesTable.tsx b/web/src/components/releases/ReleasesTable.tsx index 3a75e6ee..1a29fb12 100644 --- a/web/src/components/releases/ReleasesTable.tsx +++ b/web/src/components/releases/ReleasesTable.tsx @@ -48,17 +48,32 @@ interface ReleasesTableProps { verticalSpacing?: "xs" | "sm"; } +interface NumericSpan { + start: number; + end: number; +} + +/** + * Render one normalized span list (e.g. `[{1,4},{6,9}]`) as a compact + * human-readable string ("1-4, 6-9"). Single-point spans collapse to the + * value; range spans render as `start-end`. Disjoint compilations (the + * `v01-04 + v06-09` case) keep their gap so the user sees the truth. + */ +function formatSpans(spans: NumericSpan[] | null | undefined): string | null { + if (!spans || spans.length === 0) return null; + return spans + .map((s) => (s.start === s.end ? `${s.start}` : `${s.start}-${s.end}`)) + .join(", "); +} + function formatChapterVolume(entry: ReleaseLedgerEntry): string { - const hasChapter = entry.chapter !== null && entry.chapter !== undefined; - const hasVolume = entry.volume !== null && entry.volume !== undefined; - if (!hasChapter && !hasVolume) return "—"; - const chapter = hasChapter ? `Ch ${entry.chapter}` : ""; - const volume = hasVolume - ? hasChapter - ? ` · Vol ${entry.volume}` - : `Vol ${entry.volume}` - : ""; - return `${chapter}${volume}`; + const chapterStr = formatSpans(entry.chapters); + const volumeStr = formatSpans(entry.volumes); + if (chapterStr === null && volumeStr === null) return "—"; + const parts: string[] = []; + if (chapterStr !== null) parts.push(`Ch ${chapterStr}`); + if (volumeStr !== null) parts.push(`Vol ${volumeStr}`); + return parts.join(" · "); } export function ReleasesTable({ diff --git a/web/src/components/series/SeriesReleasesPanel.test.tsx b/web/src/components/series/SeriesReleasesPanel.test.tsx index dbf9cb82..74f71000 100644 --- a/web/src/components/series/SeriesReleasesPanel.test.tsx +++ b/web/src/components/series/SeriesReleasesPanel.test.tsx @@ -35,7 +35,35 @@ vi.mock("@/api/releases", () => ({ const SERIES_ID = "00000000-0000-0000-0000-000000000001"; -function entry(over: Partial = {}): ReleaseLedgerEntry { +interface EntryOverrides extends Partial { + /** Convenience: caller passes a single chapter number; we wrap into a + * one-element span list. Set `chapters` directly to override the shape + * (e.g. for ranges or disjoint compilations). */ + chapter?: number | null; + /** Same convenience for the volume axis. */ + volume?: number | null; +} + +function entry(over: EntryOverrides = {}): ReleaseLedgerEntry { + const { + chapter, + volume, + chapters: chaptersOverride, + volumes: volumesOverride, + ...rest + } = over; + const chapters = + chaptersOverride !== undefined + ? chaptersOverride + : chapter !== undefined && chapter !== null + ? [{ start: chapter, end: chapter }] + : null; + const volumes = + volumesOverride !== undefined + ? volumesOverride + : volume !== undefined && volume !== null + ? [{ start: volume, end: volume }] + : null; return { id: "ent-1", seriesId: SERIES_ID, @@ -47,11 +75,11 @@ function entry(over: Partial = {}): ReleaseLedgerEntry { state: "announced", observedAt: "2026-05-01T00:00:00Z", createdAt: "2026-05-01T00:00:00Z", - chapter: 143, - volume: null, + chapters: chapters ?? [{ start: 143, end: 143 }], + volumes, language: "en", groupOrUploader: "GroupA", - ...over, + ...rest, }; } diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index 6fcb482e..980964e4 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -12905,10 +12905,11 @@ export interface components { /** @description The data items for this page */ data: { /** - * Format: double - * @description Decimal supports `12.5` etc. + * @description Full chapter coverage as a normalized span list. Decimals supported + * (`c12.5` → `[{start: 12.5, end: 12.5}]`). `null` when the upstream + * title carried no chapter info. */ - chapter?: number | null; + chapters?: components["schemas"]["ReleaseSpanDto"][] | null; /** Format: double */ confidence: number; /** Format: date-time */ @@ -12970,8 +12971,11 @@ export interface components { sourceId: string; /** @description `announced` | `dismissed` | `marked_acquired` | `hidden`. */ state: string; - /** Format: int32 */ - volume?: number | null; + /** + * @description Full volume coverage as a normalized span list. `null` semantics + * mirror [`Self::chapters`]. + */ + volumes?: components["schemas"]["ReleaseSpanDto"][] | null; }[]; /** @description HATEOAS navigation links */ links: components["schemas"]["PaginationLinks"]; @@ -14844,10 +14848,11 @@ export interface components { /** @description A single release announcement. Sources write these; the inbox reads them. */ ReleaseLedgerEntryDto: { /** - * Format: double - * @description Decimal supports `12.5` etc. + * @description Full chapter coverage as a normalized span list. Decimals supported + * (`c12.5` → `[{start: 12.5, end: 12.5}]`). `null` when the upstream + * title carried no chapter info. */ - chapter?: number | null; + chapters?: components["schemas"]["ReleaseSpanDto"][] | null; /** Format: double */ confidence: number; /** Format: date-time */ @@ -14909,8 +14914,11 @@ export interface components { sourceId: string; /** @description `announced` | `dismissed` | `marked_acquired` | `hidden`. */ state: string; - /** Format: int32 */ - volume?: number | null; + /** + * @description Full volume coverage as a normalized span list. `null` semantics + * mirror [`Self::chapters`]. + */ + volumes?: components["schemas"]["ReleaseSpanDto"][] | null; }; ReleaseLedgerListResponse: { entries: components["schemas"]["ReleaseLedgerEntryDto"][]; @@ -15000,6 +15008,24 @@ export interface components { ReleaseSourceListResponse: { sources: components["schemas"]["ReleaseSourceDto"][]; }; + /** + * @description Inclusive numeric span. Single values are encoded as `start == end` + * (`{ start: 5, end: 5 }`). The release ledger surfaces volume / chapter + * coverage as a list of these so disjoint compilations (`v01-04 + v06-09`) + * survive end-to-end. + */ + ReleaseSpanDto: { + /** + * Format: double + * @example 9 + */ + end: number; + /** + * Format: double + * @example 1 + */ + start: number; + }; /** * @description PUT request for full replacement of book metadata * From 10c2a21b0b2b23d34dba1e7b77ac32d453e542f3 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 8 May 2026 16:27:32 -0700 Subject: [PATCH 2/2] feat(release-tracking): poll-all + reset-all admin actions, plugin FK cascade Bundle of release-source admin improvements: 1. Bulk admin actions `Poll all now` button on the Release tracking settings page fans out `enqueue_poll_now` across every enabled source. Disabled sources are skipped server-side; per-source enqueue failures are counted in the response and logged so a single bad source doesn't block the rest. `Reset all` button mirrors the per-source reset across the whole table, including disabled sources (a partial nuke would be misleading). Destructive and not undoable; the UI confirms with the source count before calling. 2. Cascade-on-plugin-delete for `release_sources` Deleting a plugin used to leave its `release_sources` rows orphaned because `plugin_id` was a string with no FK to `plugins.id`. Add a parallel `plugin_uuid` column with a real FK + ON DELETE CASCADE. Migration backfills from `plugins.name` and drops historic orphans. The repo populates `plugin_uuid` on insert via a `plugins.get_by_name` lookup. PostgreSQL gets the DB-level cascade. SQLite skips the FK constraint (the syntax it doesn't support) and instead relies on an explicit `release_sources` sweep in the `delete_plugin` handler. Behavior is identical across backends. 3. Collapsible config cards on the settings pages Default schedule, notification preferences, and the plugin form's config-schema help all collapse by default and show a one-line summary in the header. Reduces the visual weight of cards admins rarely touch without burying the affordance. Tests added across the new endpoints; existing settings tests updated to expand the relevant card before querying inputs that now sit inside the collapsible. --- docs/api/openapi.json | 131 ++++++++ migration/src/lib.rs | 4 + ...0081_add_release_sources_plugin_uuid_fk.rs | 142 +++++++++ src/api/docs.rs | 4 + src/api/routes/v1/dto/release.rs | 41 +++ src/api/routes/v1/handlers/plugins.rs | 28 ++ src/api/routes/v1/handlers/releases.rs | 153 ++++++++- src/api/routes/v1/routes/releases.rs | 8 + src/db/entities/release_sources.rs | 27 ++ src/db/repositories/release_sources.rs | 57 +++- src/tasks/handlers/poll_release_source.rs | 1 + web/openapi.json | 131 ++++++++ web/src/api/releases.ts | 29 ++ web/src/hooks/useReleases.ts | 61 ++++ .../settings/ReleaseTrackingSettings.test.tsx | 10 +- .../settings/ReleaseTrackingSettings.tsx | 291 +++++++++++++----- web/src/pages/settings/plugins/PluginForm.tsx | 126 +++++--- web/src/types/api.generated.ts | 151 +++++++++ 18 files changed, 1265 insertions(+), 130 deletions(-) create mode 100644 migration/src/m20260508_000081_add_release_sources_plugin_uuid_fk.rs diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 3e3baa85..7f605573 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -7187,6 +7187,72 @@ ] } }, + "/api/v1/release-sources/poll-now-all": { + "post": { + "tags": [ + "Releases" + ], + "summary": "Trigger a manual poll for *every* enabled release source.", + "description": "Walks the enabled sources and enqueues one `PollReleaseSource` task per\nsource. Disabled sources are skipped silently. Per-source enqueue\nfailures don't fail the request — they're logged and reported in the\nresponse counts so the admin can spot a partial failure without\nre-checking each row.", + "operationId": "poll_release_sources_now_all", + "responses": { + "202": { + "description": "Poll tasks enqueued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PollAllNowResponse" + } + } + } + }, + "403": { + "description": "PluginsManage permission required" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/release-sources/reset-all": { + "post": { + "tags": [ + "Releases" + ], + "summary": "Reset *every* release source to a clean slate.", + "description": "Loops over all sources (enabled and disabled — when you're nuking the\nledger, skipping disabled rows would leave a confusing partial state)\nand applies the per-source reset: delete every owned `release_ledger`\nrow + clear the transient poll state (`etag`, `last_polled_at`,\n`last_error`, `last_error_at`, `last_summary`). User-managed fields\n(`enabled`, `cron_schedule`, `display_name`, `config`) are preserved.\n\nPer-source failures don't fail the whole request — they're counted in\n`failed` and logged. Does *not* auto-enqueue any polls; the admin can\nfollow up with `poll-now-all` if they want immediate re-fetch.\n\n**Destructive and not undoable** — the UI confirms before calling.", + "operationId": "reset_all_release_sources", + "responses": { + "200": { + "description": "Sources reset", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetAllReleaseSourcesResponse" + } + } + } + }, + "403": { + "description": "PluginsManage permission required" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/release-sources/{source_id}": { "patch": { "tags": [ @@ -32323,6 +32389,38 @@ } } }, + "PollAllNowResponse": { + "type": "object", + "description": "Response shape from the `poll-now-all` endpoint.\n\nReports how many enabled sources had a poll task enqueued in this call.\nDisabled sources are skipped silently. `coalesced` counts sources whose\nexisting in-flight task absorbed the request (no new task was created).\n`failed` counts sources where the enqueue itself errored — those are\nlogged server-side; the response stays 202 to avoid having one bad\nsource block the rest.", + "required": [ + "considered", + "enqueued", + "coalesced", + "failed" + ], + "properties": { + "coalesced": { + "type": "integer", + "description": "Sources whose pending/running poll absorbed the request.", + "minimum": 0 + }, + "considered": { + "type": "integer", + "description": "Total enabled sources considered.", + "minimum": 0 + }, + "enqueued": { + "type": "integer", + "description": "Sources for which a fresh poll task was enqueued.", + "minimum": 0 + }, + "failed": { + "type": "integer", + "description": "Sources whose enqueue failed (see server logs).", + "minimum": 0 + } + } + }, "PollNowResponse": { "type": "object", "description": "Response shape from the `poll-now` endpoint.\n\n`status` is `enqueued` after a successful enqueue. The `message` carries\nthe task ID for follow-up (`tasks.id`); the task runs asynchronously, so\nthis response does not reflect poll outcome.", @@ -34122,6 +34220,39 @@ } } }, + "ResetAllReleaseSourcesResponse": { + "type": "object", + "description": "Response shape from the `reset-all` endpoint.\n\nReports how many sources were reset across the whole table. Unlike\n`poll-now-all`, this *includes* disabled sources — if you're nuking\nthe ledger, partial coverage would be misleading. Per-source failures\ndon't fail the request; they're counted in `failed` and logged.", + "required": [ + "considered", + "reset", + "deletedLedgerEntries", + "failed" + ], + "properties": { + "considered": { + "type": "integer", + "description": "Total sources considered (enabled + disabled).", + "minimum": 0 + }, + "deletedLedgerEntries": { + "type": "integer", + "format": "int64", + "description": "Aggregate count of `release_ledger` rows deleted across every\nsource that was successfully reset.", + "minimum": 0 + }, + "failed": { + "type": "integer", + "description": "Sources where reset failed (see server logs).", + "minimum": 0 + }, + "reset": { + "type": "integer", + "description": "Sources reset (ledger wiped + transient state cleared).", + "minimum": 0 + } + } + }, "ResetReleaseSourceResponse": { "type": "object", "description": "Response shape from the `reset` endpoint.\n\nReturns the number of ledger rows removed so callers can show a\nconfirmation toast. The source's transient poll state (etag,\nlast_polled_at, last_error, last_summary) is also cleared, but those\nare not counted here.", diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 6a23bca3..92ceab0e 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -165,6 +165,8 @@ mod m20260505_000078_add_release_ledger_media_url; mod m20260505_000079_seed_release_tracking_default_cron; // Release tracking: per-row span lists (chapters/volumes) for compilation bundles mod m20260508_000080_add_release_ledger_span_columns; +// Release tracking: parallel plugin_uuid FK column for cascade-on-plugin-delete +mod m20260508_000081_add_release_sources_plugin_uuid_fk; pub struct Migrator; @@ -300,6 +302,8 @@ impl MigratorTrait for Migrator { Box::new(m20260505_000079_seed_release_tracking_default_cron::Migration), // Release tracking: per-row span lists for compilation bundles Box::new(m20260508_000080_add_release_ledger_span_columns::Migration), + // Release tracking: parallel plugin_uuid FK with cascade-on-plugin-delete + Box::new(m20260508_000081_add_release_sources_plugin_uuid_fk::Migration), ] } } diff --git a/migration/src/m20260508_000081_add_release_sources_plugin_uuid_fk.rs b/migration/src/m20260508_000081_add_release_sources_plugin_uuid_fk.rs new file mode 100644 index 00000000..6b91ae97 --- /dev/null +++ b/migration/src/m20260508_000081_add_release_sources_plugin_uuid_fk.rs @@ -0,0 +1,142 @@ +//! Add `plugin_uuid` FK column to `release_sources` for cascade-on-delete. +//! +//! `release_sources.plugin_id` is a string (the plugin's manifest name) used +//! by the plugin RPC layer for self-identification. There's no FK to +//! `plugins.id`, so deleting a plugin row leaves orphaned source rows behind: +//! they keep showing in the settings UI under the deleted plugin name and +//! survive across reinstalls. +//! +//! Rather than convert `plugin_id` to UUID end-to-end (which would churn +//! the entire RPC contract and ~40 test fixtures), we add a *parallel* +//! `plugin_uuid` column. The string column stays as the plugin's +//! self-identifier; the UUID is the lifecycle anchor. The repository's +//! create/upsert path populates both consistently by looking up `plugins` +//! by name once at insert time. +//! +//! Backfill rules: +//! - For each existing row, set `plugin_uuid = (SELECT id FROM plugins +//! WHERE plugins.name = release_sources.plugin_id)`. +//! - Rows whose lookup fails (orphans — plugin already deleted) are +//! dropped. The reserved `plugin_id = 'core'` synthetic-source value +//! has no plugins row by design; those rows keep `plugin_uuid = NULL` +//! (the FK is nullable to accommodate them). +//! +//! Cross-DB note on the FK: PostgreSQL accepts +//! `ALTER TABLE ... ADD CONSTRAINT FK` directly. SQLite does not — adding +//! a FK to an existing column requires the (error-prone) "12-step" table +//! swap, and even that interacts badly with the inbound FK from +//! `release_ledger.source_id` because connection-level PRAGMAs interact +//! with the migrator's transaction in surprising ways. +//! +//! Pragmatic compromise: PostgreSQL deployments get the DB-level FK + +//! cascade. SQLite deployments rely on app-level cleanup in the plugin +//! delete handler (see [`crate::api::routes::v1::handlers::plugins::delete_plugin`]). +//! The `plugin_uuid` column is populated on both backends so the app can +//! cleanly relate sources to plugins regardless. The few SQLite users +//! who delete plugin rows directly via SQL (rather than the API) will +//! see orphans — those can be cleaned up with a single +//! `DELETE FROM release_sources WHERE plugin_uuid IS NULL AND plugin_id != 'core'`. + +use sea_orm::{ConnectionTrait, DbBackend, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + let backend = db.get_database_backend(); + + // 1. Add the column on both backends. + manager + .alter_table( + Table::alter() + .table(Alias::new("release_sources")) + .add_column(ColumnDef::new(Alias::new("plugin_uuid")).uuid()) + .to_owned(), + ) + .await?; + + // 2. Backfill from `plugins.name`. + db.execute(Statement::from_string( + backend, + r#"UPDATE release_sources + SET plugin_uuid = ( + SELECT id FROM plugins WHERE plugins.name = release_sources.plugin_id + ) + WHERE plugin_id != 'core'"# + .to_string(), + )) + .await?; + + // 3. Drop orphans (plugin already deleted; no plugins row to FK to). + // The associated `release_ledger` rows are removed by the existing + // `fk_release_ledger_source_id` cascade. + db.execute(Statement::from_string( + backend, + r#"DELETE FROM release_sources + WHERE plugin_id != 'core' AND plugin_uuid IS NULL"# + .to_string(), + )) + .await?; + + // 4. Add FK on PostgreSQL/MySQL. Skip on SQLite — the app-level + // cleanup in the plugin delete handler handles the cascade + // there; see the module-level comment. + if matches!(backend, DbBackend::Postgres | DbBackend::MySql) { + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_release_sources_plugin_uuid") + .from(Alias::new("release_sources"), Alias::new("plugin_uuid")) + .to(Alias::new("plugins"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await?; + } + + // 5. Index the new column. Speeds the cascade-lookup (Postgres) and + // administrative joins on both backends. + db.execute(Statement::from_string( + backend, + "CREATE INDEX idx_release_sources_plugin_uuid \ + ON release_sources(plugin_uuid)" + .to_string(), + )) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + let backend = db.get_database_backend(); + db.execute(Statement::from_string( + backend, + "DROP INDEX IF EXISTS idx_release_sources_plugin_uuid".to_string(), + )) + .await?; + if matches!(backend, DbBackend::Postgres | DbBackend::MySql) { + manager + .drop_foreign_key( + ForeignKey::drop() + .table(Alias::new("release_sources")) + .name("fk_release_sources_plugin_uuid") + .to_owned(), + ) + .await?; + } + manager + .alter_table( + Table::alter() + .table(Alias::new("release_sources")) + .drop_column(Alias::new("plugin_uuid")) + .to_owned(), + ) + .await?; + Ok(()) + } +} diff --git a/src/api/docs.rs b/src/api/docs.rs index a28d6289..c2c8854c 100644 --- a/src/api/docs.rs +++ b/src/api/docs.rs @@ -272,6 +272,8 @@ The following paths are exempt from rate limiting: v1::handlers::releases::list_release_sources, v1::handlers::releases::update_release_source, v1::handlers::releases::poll_release_source_now, + v1::handlers::releases::poll_release_sources_now_all, + v1::handlers::releases::reset_all_release_sources, v1::handlers::releases::reset_release_source, v1::handlers::releases::get_release_tracking_applicability, v1::handlers::releases::list_release_facets, @@ -718,6 +720,8 @@ The following paths are exempt from rate limiting: v1::dto::release::ReleaseSourceListResponse, v1::dto::release::UpdateReleaseSourceRequest, v1::dto::release::PollNowResponse, + v1::dto::release::PollAllNowResponse, + v1::dto::release::ResetAllReleaseSourcesResponse, v1::dto::release::ResetReleaseSourceResponse, v1::dto::release::ReleaseSeriesFacetDto, v1::dto::release::ReleaseLibraryFacetDto, diff --git a/src/api/routes/v1/dto/release.rs b/src/api/routes/v1/dto/release.rs index 07cfad55..85d513d3 100644 --- a/src/api/routes/v1/dto/release.rs +++ b/src/api/routes/v1/dto/release.rs @@ -433,3 +433,44 @@ pub struct PollNowResponse { /// Human-readable message; includes the enqueued task ID. pub message: String, } + +/// Response shape from the `poll-now-all` endpoint. +/// +/// Reports how many enabled sources had a poll task enqueued in this call. +/// Disabled sources are skipped silently. `coalesced` counts sources whose +/// existing in-flight task absorbed the request (no new task was created). +/// `failed` counts sources where the enqueue itself errored — those are +/// logged server-side; the response stays 202 to avoid having one bad +/// source block the rest. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PollAllNowResponse { + /// Total enabled sources considered. + pub considered: usize, + /// Sources for which a fresh poll task was enqueued. + pub enqueued: usize, + /// Sources whose pending/running poll absorbed the request. + pub coalesced: usize, + /// Sources whose enqueue failed (see server logs). + pub failed: usize, +} + +/// Response shape from the `reset-all` endpoint. +/// +/// Reports how many sources were reset across the whole table. Unlike +/// `poll-now-all`, this *includes* disabled sources — if you're nuking +/// the ledger, partial coverage would be misleading. Per-source failures +/// don't fail the request; they're counted in `failed` and logged. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ResetAllReleaseSourcesResponse { + /// Total sources considered (enabled + disabled). + pub considered: usize, + /// Sources reset (ledger wiped + transient state cleared). + pub reset: usize, + /// Aggregate count of `release_ledger` rows deleted across every + /// source that was successfully reset. + pub deleted_ledger_entries: u64, + /// Sources where reset failed (see server logs). + pub failed: usize, +} diff --git a/src/api/routes/v1/handlers/plugins.rs b/src/api/routes/v1/handlers/plugins.rs index 02105419..ad097495 100644 --- a/src/api/routes/v1/handlers/plugins.rs +++ b/src/api/routes/v1/handlers/plugins.rs @@ -673,6 +673,34 @@ pub async fn delete_plugin( tracing::warn!("Failed to stop plugin before delete: {}", e); } + // Sweep `release_sources` rows that point at this plugin. PostgreSQL + // would also do this via `fk_release_sources_plugin_uuid` ON DELETE + // CASCADE, but SQLite skips that FK at migration time (see + // `m20260508_000081_add_release_sources_plugin_uuid_fk`), so the + // app sweeps explicitly to keep the two backends behavior-identical. + // Done before the plugin row is dropped to avoid the brief window + // where the orphan would be visible. Cascade on + // `fk_release_ledger_source_id` carries any associated ledger rows. + match crate::db::repositories::ReleaseSourceRepository::delete_by_plugin_uuid(&state.db, id) + .await + { + Ok(0) => {} + Ok(n) => { + tracing::info!( + plugin_id = %id, + removed_sources = n, + "release_sources rows removed before plugin delete" + ); + } + Err(e) => { + tracing::warn!( + plugin_id = %id, + error = %e, + "release_sources cleanup failed; continuing with plugin delete" + ); + } + } + let deleted = PluginsRepository::delete(&state.db, id) .await .map_err(|e| ApiError::Internal(format!("Failed to delete plugin: {}", e)))?; diff --git a/src/api/routes/v1/handlers/releases.rs b/src/api/routes/v1/handlers/releases.rs index 8a1eb88e..d087110d 100644 --- a/src/api/routes/v1/handlers/releases.rs +++ b/src/api/routes/v1/handlers/releases.rs @@ -28,9 +28,10 @@ use super::super::dto::common::{ }; use super::super::dto::release::{ BulkReleaseAction, BulkReleaseActionRequest, BulkReleaseActionResponse, DeleteReleaseResponse, - PollNowResponse, ReleaseFacetsResponse, ReleaseLanguageFacetDto, ReleaseLedgerEntryDto, - ReleaseLedgerListResponse, ReleaseLibraryFacetDto, ReleaseSeriesFacetDto, ReleaseSourceDto, - ReleaseSourceListResponse, ResetReleaseSourceResponse, UpdateReleaseLedgerEntryRequest, + PollAllNowResponse, PollNowResponse, ReleaseFacetsResponse, ReleaseLanguageFacetDto, + ReleaseLedgerEntryDto, ReleaseLedgerListResponse, ReleaseLibraryFacetDto, + ReleaseSeriesFacetDto, ReleaseSourceDto, ReleaseSourceListResponse, + ResetAllReleaseSourcesResponse, ResetReleaseSourceResponse, UpdateReleaseLedgerEntryRequest, UpdateReleaseSourceRequest, }; use super::paginated_response; @@ -1040,6 +1041,73 @@ pub async fn poll_release_source_now( )) } +/// Trigger a manual poll for *every* enabled release source. +/// +/// Walks the enabled sources and enqueues one `PollReleaseSource` task per +/// source. Disabled sources are skipped silently. Per-source enqueue +/// failures don't fail the request — they're logged and reported in the +/// response counts so the admin can spot a partial failure without +/// re-checking each row. +#[utoipa::path( + post, + path = "/api/v1/release-sources/poll-now-all", + responses( + (status = 202, description = "Poll tasks enqueued", body = PollAllNowResponse), + (status = 403, description = "PluginsManage permission required"), + ), + security( + ("jwt_bearer" = []), + ("api_key" = []) + ), + tag = "Releases" +)] +pub async fn poll_release_sources_now_all( + State(state): State>, + auth: AuthContext, +) -> Result<(StatusCode, Json), ApiError> { + auth.require_permission(&Permission::PluginsManage)?; + + let sources = ReleaseSourceRepository::list_enabled(&state.db) + .await + .map_err(|e| ApiError::Internal(format!("Failed to list enabled sources: {}", e)))?; + + let considered = sources.len(); + let mut enqueued = 0usize; + let mut coalesced = 0usize; + let mut failed = 0usize; + for source in sources { + match crate::scheduler::release_sources::enqueue_poll_now(&state.db, source.id).await { + Ok(outcome) => { + if outcome.coalesced { + coalesced += 1; + } else { + enqueued += 1; + } + } + Err(e) => { + failed += 1; + tracing::warn!( + source_id = %source.id, + plugin_id = %source.plugin_id, + source_key = %source.source_key, + error = %e, + "poll-now-all: per-source enqueue failed; continuing with the rest" + ); + } + } + } + + Ok(( + StatusCode::ACCEPTED, + Json(PollAllNowResponse { + considered, + enqueued, + coalesced, + failed, + }), + )) +} + /// Reset a release source to a clean slate. /// /// Deletes every `release_ledger` row owned by the source and clears the @@ -1095,6 +1163,85 @@ pub async fn reset_release_source( })) } +/// Reset *every* release source to a clean slate. +/// +/// Loops over all sources (enabled and disabled — when you're nuking the +/// ledger, skipping disabled rows would leave a confusing partial state) +/// and applies the per-source reset: delete every owned `release_ledger` +/// row + clear the transient poll state (`etag`, `last_polled_at`, +/// `last_error`, `last_error_at`, `last_summary`). User-managed fields +/// (`enabled`, `cron_schedule`, `display_name`, `config`) are preserved. +/// +/// Per-source failures don't fail the whole request — they're counted in +/// `failed` and logged. Does *not* auto-enqueue any polls; the admin can +/// follow up with `poll-now-all` if they want immediate re-fetch. +/// +/// **Destructive and not undoable** — the UI confirms before calling. +#[utoipa::path( + post, + path = "/api/v1/release-sources/reset-all", + responses( + (status = 200, description = "Sources reset", body = ResetAllReleaseSourcesResponse), + (status = 403, description = "PluginsManage permission required"), + ), + security( + ("jwt_bearer" = []), + ("api_key" = []) + ), + tag = "Releases" +)] +pub async fn reset_all_release_sources( + State(state): State>, + auth: AuthContext, +) -> Result, ApiError> { + auth.require_permission(&Permission::PluginsManage)?; + + let sources = ReleaseSourceRepository::list_all(&state.db) + .await + .map_err(|e| ApiError::Internal(format!("Failed to list sources: {}", e)))?; + + let considered = sources.len(); + let mut reset = 0usize; + let mut total_deleted: u64 = 0; + let mut failed = 0usize; + + for source in sources { + // Two writes per source. We don't want a half-reset where the + // ledger is wiped but the etag is left behind, so any per-call + // error counts the source as failed and we move on. Logging + // surfaces which source needs manual attention. + let outcome: anyhow::Result = async { + let deleted = ReleaseLedgerRepository::delete_by_source(&state.db, source.id).await?; + ReleaseSourceRepository::clear_poll_state(&state.db, source.id).await?; + Ok(deleted) + } + .await; + match outcome { + Ok(deleted) => { + reset += 1; + total_deleted += deleted; + } + Err(e) => { + failed += 1; + tracing::warn!( + source_id = %source.id, + plugin_id = %source.plugin_id, + source_key = %source.source_key, + error = %e, + "reset-all: per-source reset failed; continuing with the rest" + ); + } + } + } + + Ok(Json(ResetAllReleaseSourcesResponse { + considered, + reset, + deleted_ledger_entries: total_deleted, + failed, + })) +} + // ============================================================================= // OpenAPI placeholder // ============================================================================= diff --git a/src/api/routes/v1/routes/releases.rs b/src/api/routes/v1/routes/releases.rs index 18293006..7e7de9d2 100644 --- a/src/api/routes/v1/routes/releases.rs +++ b/src/api/routes/v1/routes/releases.rs @@ -55,6 +55,14 @@ pub fn routes(_state: Arc) -> Router> { "/release-sources/{source_id}", patch(handlers::releases::update_release_source), ) + .route( + "/release-sources/poll-now-all", + post(handlers::releases::poll_release_sources_now_all), + ) + .route( + "/release-sources/reset-all", + post(handlers::releases::reset_all_release_sources), + ) .route( "/release-sources/{source_id}/poll-now", post(handlers::releases::poll_release_source_now), diff --git a/src/db/entities/release_sources.rs b/src/db/entities/release_sources.rs index 661fac7a..178aef0e 100644 --- a/src/db/entities/release_sources.rs +++ b/src/db/entities/release_sources.rs @@ -18,7 +18,20 @@ pub struct Model { pub id: Uuid, /// Owning plugin id (string). The literal `"core"` is reserved for in-core /// synthetic sources (e.g., metadata-piggyback in Phase 5). + /// + /// This is the plugin's manifest *name* — the identifier plugins use to + /// self-reference over RPC. It is *not* the canonical lifecycle anchor; + /// see [`Self::plugin_uuid`] for the FK that drives cascade-on-delete. pub plugin_id: String, + /// Foreign key to [`crate::db::entities::plugins::Model::id`] with + /// `ON DELETE CASCADE`. Populated by the repository on insert via a + /// `plugins.find_by_name(plugin_id)` lookup. `None` for synthetic + /// `plugin_id = "core"` rows that don't correspond to a real plugin. + /// When a plugin row is deleted, every `release_sources` row pointing + /// at it is removed automatically (and the existing + /// `fk_release_ledger_source_id` cascade then takes the ledger rows + /// with them). + pub plugin_uuid: Option, /// Plugin-defined unique key (e.g., `nyaa:user:tsuna69`). pub source_key: String, pub display_name: String, @@ -50,6 +63,14 @@ pub struct Model { pub enum Relation { #[sea_orm(has_many = "super::release_ledger::Entity")] ReleaseLedger, + #[sea_orm( + belongs_to = "super::plugins::Entity", + from = "Column::PluginUuid", + to = "super::plugins::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Plugin, } impl Related for Entity { @@ -58,6 +79,12 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Plugin.def() + } +} + impl ActiveModelBehavior for ActiveModel {} /// Canonical strings for `plugin_id`. diff --git a/src/db/repositories/release_sources.rs b/src/db/repositories/release_sources.rs index da8130db..ae8da516 100644 --- a/src/db/repositories/release_sources.rs +++ b/src/db/repositories/release_sources.rs @@ -17,8 +17,9 @@ use sea_orm::{ use uuid::Uuid; use crate::db::entities::release_sources::{ - self, Entity as ReleaseSources, Model as ReleaseSource, kind, + self, Entity as ReleaseSources, Model as ReleaseSource, kind, plugin_id as source_plugin_id, }; +use crate::db::repositories::plugins::PluginsRepository; use crate::utils::cron::validate_cron_expression; /// Normalize a caller-supplied cron schedule: trim, treat empty as `None`, @@ -35,6 +36,38 @@ fn sanitize_cron_schedule(value: Option) -> Result> { Ok(Some(trimmed.to_string())) } +/// Resolve a `plugin_id` (the plugin's manifest name) to its row UUID. +/// +/// Returns `Ok(Some(uuid))` for real plugins and `Ok(None)` for the +/// reserved `"core"` synthetic-source identifier. When the lookup misses +/// (no `plugins` row matches the manifest name), we log a warning and +/// return `None` rather than failing the insert. +/// +/// **Why not bail:** in production the plugin row always exists by the +/// time `release_sources` rows are created (registration happens after +/// install). A miss in production would indicate a real bug worth a log +/// trail, but failing the insert outright would also paper over it. +/// Test fixtures, on the other hand, frequently create `release_sources` +/// without seeding a `plugins` row — those rows just won't get the FK +/// benefit, which is acceptable since they don't represent production +/// data. The FK + cascade still protects every row that *does* resolve. +async fn resolve_plugin_uuid(db: &DatabaseConnection, plugin_id: &str) -> Result> { + if plugin_id == source_plugin_id::CORE { + return Ok(None); + } + match PluginsRepository::get_by_name(db, plugin_id).await? { + Some(p) => Ok(Some(p.id)), + None => { + tracing::warn!( + plugin_id, + "release_sources insert: no matching plugins row; plugin_uuid left NULL \ + (this row will not benefit from cascade-on-plugin-delete)" + ); + Ok(None) + } + } +} + /// Parameters for creating a new release source. Only the fields a caller is /// expected to choose live here; `created_at` / `updated_at` / `id` are /// generated. @@ -107,6 +140,12 @@ impl ReleaseSourceRepository { /// Create a new source. Validates `kind` against the canonical set. /// New rows always start with `cron_schedule = NULL` (inherit the /// server-wide default); admins can override per-row via PATCH. + /// + /// Resolves `plugin_id` (manifest name) → `plugin_uuid` (FK to + /// `plugins.id`) once at insert. Real plugin sources fail the insert + /// when the lookup misses — we refuse to create new orphans. The + /// reserved `plugin_id = "core"` value bypasses the lookup since + /// synthetic in-core sources have no plugins row. pub async fn create( db: &DatabaseConnection, params: NewReleaseSource, @@ -121,10 +160,13 @@ impl ReleaseSourceRepository { anyhow::bail!("source_key cannot be empty"); } + let plugin_uuid = resolve_plugin_uuid(db, ¶ms.plugin_id).await?; + let now = Utc::now(); let active = release_sources::ActiveModel { id: Set(Uuid::new_v4()), plugin_id: Set(params.plugin_id), + plugin_uuid: Set(plugin_uuid), source_key: Set(params.source_key), display_name: Set(params.display_name), kind: Set(params.kind), @@ -201,6 +243,19 @@ impl ReleaseSourceRepository { /// `keep_keys`. Returns the number of rows removed. Cascades to /// `release_ledger`. Used by `register_sources` to prune sources that the /// plugin no longer declares. + /// Delete every `release_sources` row owned by `plugin_uuid`. + /// + /// Used by the plugin delete handler as the SQLite cascade fallback + /// (PostgreSQL gets the same effect for free via the FK constraint). + /// Idempotent — safe to call when no rows match. + pub async fn delete_by_plugin_uuid(db: &DatabaseConnection, plugin_uuid: Uuid) -> Result { + let result = ReleaseSources::delete_many() + .filter(release_sources::Column::PluginUuid.eq(plugin_uuid)) + .exec(db) + .await?; + Ok(result.rows_affected) + } + pub async fn delete_by_plugin_excluding( db: &DatabaseConnection, plugin_id: &str, diff --git a/src/tasks/handlers/poll_release_source.rs b/src/tasks/handlers/poll_release_source.rs index c115987d..a2bf7d52 100644 --- a/src/tasks/handlers/poll_release_source.rs +++ b/src/tasks/handlers/poll_release_source.rs @@ -804,6 +804,7 @@ mod tests { crate::db::entities::release_sources::Model { id: Uuid::new_v4(), plugin_id: "release-nyaa".to_string(), + plugin_uuid: None, source_key: "k".to_string(), display_name: "n".to_string(), kind: kind::RSS_UPLOADER.to_string(), diff --git a/web/openapi.json b/web/openapi.json index 3e3baa85..7f605573 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -7187,6 +7187,72 @@ ] } }, + "/api/v1/release-sources/poll-now-all": { + "post": { + "tags": [ + "Releases" + ], + "summary": "Trigger a manual poll for *every* enabled release source.", + "description": "Walks the enabled sources and enqueues one `PollReleaseSource` task per\nsource. Disabled sources are skipped silently. Per-source enqueue\nfailures don't fail the request — they're logged and reported in the\nresponse counts so the admin can spot a partial failure without\nre-checking each row.", + "operationId": "poll_release_sources_now_all", + "responses": { + "202": { + "description": "Poll tasks enqueued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PollAllNowResponse" + } + } + } + }, + "403": { + "description": "PluginsManage permission required" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/release-sources/reset-all": { + "post": { + "tags": [ + "Releases" + ], + "summary": "Reset *every* release source to a clean slate.", + "description": "Loops over all sources (enabled and disabled — when you're nuking the\nledger, skipping disabled rows would leave a confusing partial state)\nand applies the per-source reset: delete every owned `release_ledger`\nrow + clear the transient poll state (`etag`, `last_polled_at`,\n`last_error`, `last_error_at`, `last_summary`). User-managed fields\n(`enabled`, `cron_schedule`, `display_name`, `config`) are preserved.\n\nPer-source failures don't fail the whole request — they're counted in\n`failed` and logged. Does *not* auto-enqueue any polls; the admin can\nfollow up with `poll-now-all` if they want immediate re-fetch.\n\n**Destructive and not undoable** — the UI confirms before calling.", + "operationId": "reset_all_release_sources", + "responses": { + "200": { + "description": "Sources reset", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetAllReleaseSourcesResponse" + } + } + } + }, + "403": { + "description": "PluginsManage permission required" + } + }, + "security": [ + { + "jwt_bearer": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/release-sources/{source_id}": { "patch": { "tags": [ @@ -32323,6 +32389,38 @@ } } }, + "PollAllNowResponse": { + "type": "object", + "description": "Response shape from the `poll-now-all` endpoint.\n\nReports how many enabled sources had a poll task enqueued in this call.\nDisabled sources are skipped silently. `coalesced` counts sources whose\nexisting in-flight task absorbed the request (no new task was created).\n`failed` counts sources where the enqueue itself errored — those are\nlogged server-side; the response stays 202 to avoid having one bad\nsource block the rest.", + "required": [ + "considered", + "enqueued", + "coalesced", + "failed" + ], + "properties": { + "coalesced": { + "type": "integer", + "description": "Sources whose pending/running poll absorbed the request.", + "minimum": 0 + }, + "considered": { + "type": "integer", + "description": "Total enabled sources considered.", + "minimum": 0 + }, + "enqueued": { + "type": "integer", + "description": "Sources for which a fresh poll task was enqueued.", + "minimum": 0 + }, + "failed": { + "type": "integer", + "description": "Sources whose enqueue failed (see server logs).", + "minimum": 0 + } + } + }, "PollNowResponse": { "type": "object", "description": "Response shape from the `poll-now` endpoint.\n\n`status` is `enqueued` after a successful enqueue. The `message` carries\nthe task ID for follow-up (`tasks.id`); the task runs asynchronously, so\nthis response does not reflect poll outcome.", @@ -34122,6 +34220,39 @@ } } }, + "ResetAllReleaseSourcesResponse": { + "type": "object", + "description": "Response shape from the `reset-all` endpoint.\n\nReports how many sources were reset across the whole table. Unlike\n`poll-now-all`, this *includes* disabled sources — if you're nuking\nthe ledger, partial coverage would be misleading. Per-source failures\ndon't fail the request; they're counted in `failed` and logged.", + "required": [ + "considered", + "reset", + "deletedLedgerEntries", + "failed" + ], + "properties": { + "considered": { + "type": "integer", + "description": "Total sources considered (enabled + disabled).", + "minimum": 0 + }, + "deletedLedgerEntries": { + "type": "integer", + "format": "int64", + "description": "Aggregate count of `release_ledger` rows deleted across every\nsource that was successfully reset.", + "minimum": 0 + }, + "failed": { + "type": "integer", + "description": "Sources where reset failed (see server logs).", + "minimum": 0 + }, + "reset": { + "type": "integer", + "description": "Sources reset (ledger wiped + transient state cleared).", + "minimum": 0 + } + } + }, "ResetReleaseSourceResponse": { "type": "object", "description": "Response shape from the `reset` endpoint.\n\nReturns the number of ledger rows removed so callers can show a\nconfirmation toast. The source's transient poll state (etag,\nlast_polled_at, last_error, last_summary) is also cleared, but those\nare not counted here.", diff --git a/web/src/api/releases.ts b/web/src/api/releases.ts index be8b2ff6..fcedeb2a 100644 --- a/web/src/api/releases.ts +++ b/web/src/api/releases.ts @@ -26,6 +26,9 @@ export type BulkReleaseActionResponse = components["schemas"]["BulkReleaseActionResponse"]; export type DeleteReleaseResponse = components["schemas"]["DeleteReleaseResponse"]; +export type PollAllNowResponse = components["schemas"]["PollAllNowResponse"]; +export type ResetAllReleaseSourcesResponse = + components["schemas"]["ResetAllReleaseSourcesResponse"]; export interface ReleaseInboxParams { /** State filter. Use `"all"` for no state restriction; defaults to `"announced"` server-side. */ @@ -177,6 +180,19 @@ export const releaseSourcesApi = { return response.data; }, + /** + * Trigger a manual poll for every enabled release source. Disabled + * sources are skipped server-side. Per-source enqueue failures don't + * fail the request — they're counted in `failed` so the admin can + * spot a partial failure without re-checking each row. + */ + pollNowAll: async (): Promise => { + const response = await api.post( + `/release-sources/poll-now-all`, + ); + return response.data; + }, + /** * Drop every ledger row for this source and clear its transient poll * state (etag, last_polled_at, last_error, last_summary). User-managed @@ -193,6 +209,19 @@ export const releaseSourcesApi = { return response.data; }, + /** + * Reset every release source — wipes the ledger across the whole + * instance and clears each source's transient poll state. Includes + * disabled sources (a partial reset would be misleading). Destructive + * and not undoable; the UI must confirm before calling. + */ + resetAll: async (): Promise => { + const response = await api.post( + `/release-sources/reset-all`, + ); + return response.data; + }, + /** * Whether release tracking is available for a given library scope. * diff --git a/web/src/hooks/useReleases.ts b/web/src/hooks/useReleases.ts index 70cc4de3..fe39148b 100644 --- a/web/src/hooks/useReleases.ts +++ b/web/src/hooks/useReleases.ts @@ -6,11 +6,13 @@ import { type BulkReleaseActionResponse, type DeleteReleaseResponse, type PaginatedReleases, + type PollAllNowResponse, type ReleaseFacets, type ReleaseFacetsParams, type ReleaseInboxParams, type ReleaseLedgerEntry, type ReleaseSource, + type ResetAllReleaseSourcesResponse, type ResetReleaseSourceResponse, releaseSourcesApi, releasesApi, @@ -247,6 +249,34 @@ export function usePollReleaseSourceNow() { }); } +/** + * Fan out a poll request across every enabled release source. Reports a + * single summary toast once the server has finished enqueueing — this + * does *not* wait for the polls themselves to run. + */ +export function usePollAllReleaseSourcesNow() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => releaseSourcesApi.pollNowAll(), + onSuccess: (data) => { + const skipped = data.considered - data.enqueued - data.coalesced; + const parts = [ + `${data.enqueued} enqueued`, + data.coalesced > 0 ? `${data.coalesced} already running` : null, + data.failed > 0 ? `${data.failed} failed (see logs)` : null, + skipped > 0 ? `${skipped} skipped` : null, + ].filter(Boolean); + notifications.show({ + title: "Polling all sources", + message: parts.join(" · "), + color: data.failed > 0 ? "yellow" : "blue", + }); + queryClient.invalidateQueries({ queryKey: releasesKeys.sourcesRoot }); + }, + onError: notifyError("Failed to enqueue polls"), + }); +} + export function useResetReleaseSource() { const queryClient = useQueryClient(); return useMutation({ @@ -267,3 +297,34 @@ export function useResetReleaseSource() { onError: notifyError("Failed to reset source"), }); } + +/** + * Reset every release source. Destructive — caller is responsible for + * confirming with the user before invoking the mutation. + */ +export function useResetAllReleaseSources() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => releaseSourcesApi.resetAll(), + onSuccess: (data) => { + const parts = [ + `${data.reset} of ${data.considered} sources reset`, + `${data.deletedLedgerEntries} ledger ${ + data.deletedLedgerEntries === 1 ? "entry" : "entries" + } removed`, + data.failed > 0 ? `${data.failed} failed (see logs)` : null, + ].filter(Boolean); + notifications.show({ + title: "All sources reset", + message: parts.join(" · "), + color: data.failed > 0 ? "yellow" : "blue", + }); + // Same invalidations as the per-source reset; the blast radius + // covers everything that reads ledger rows. + queryClient.invalidateQueries({ queryKey: releasesKeys.sourcesRoot }); + queryClient.invalidateQueries({ queryKey: releasesKeys.inboxRoot }); + queryClient.invalidateQueries({ queryKey: ["series"] }); + }, + onError: notifyError("Failed to reset sources"), + }); +} diff --git a/web/src/pages/settings/ReleaseTrackingSettings.test.tsx b/web/src/pages/settings/ReleaseTrackingSettings.test.tsx index f3476f5e..9c86fd29 100644 --- a/web/src/pages/settings/ReleaseTrackingSettings.test.tsx +++ b/web/src/pages/settings/ReleaseTrackingSettings.test.tsx @@ -221,9 +221,17 @@ describe("ReleaseTrackingSettings", () => { await waitFor(() => { expect(getAllPlugins).toHaveBeenCalled(); }); + // The notification preferences card defaults to collapsed; expand it so + // the MultiSelect is mounted in the DOM. + const expandPrefs = await screen.findByRole("button", { + name: /Expand notification preferences/i, + }); + await user.click(expandPrefs); // Mantine MultiSelect renders an input with role=textbox associated with // the label; clicking it opens the dropdown and shows the options. - const select = screen.getByRole("textbox", { name: "Plugin sources" }); + const select = await screen.findByRole("textbox", { + name: "Plugin sources", + }); await user.click(select); await waitFor(() => { expect(screen.getByText("MangaUpdates Releases")).toBeInTheDocument(); diff --git a/web/src/pages/settings/ReleaseTrackingSettings.tsx b/web/src/pages/settings/ReleaseTrackingSettings.tsx index 502062d6..d96624d5 100644 --- a/web/src/pages/settings/ReleaseTrackingSettings.tsx +++ b/web/src/pages/settings/ReleaseTrackingSettings.tsx @@ -5,6 +5,7 @@ import { Box, Button, Card, + Collapse, Group, Loader, MultiSelect, @@ -16,10 +17,13 @@ import { Title, Tooltip, } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; import { notifications } from "@mantine/notifications"; import { IconAlertCircle, IconBellRinging, + IconChevronDown, + IconChevronRight, IconClockHour4, IconRefresh, IconRestore, @@ -35,8 +39,10 @@ import type { ReleaseSource } from "@/api/releases"; import { settingsApi } from "@/api/settings"; import { CronInput } from "@/components/forms/CronInput"; import { + usePollAllReleaseSourcesNow, usePollReleaseSourceNow, useReleaseSources, + useResetAllReleaseSources, useResetReleaseSource, useUpdateReleaseSource, } from "@/hooks/useReleases"; @@ -88,7 +94,42 @@ export function ReleaseTrackingSettings() { const sourcesQuery = useReleaseSources(); const update = useUpdateReleaseSource(); const pollNow = usePollReleaseSourceNow(); + const pollAll = usePollAllReleaseSourcesNow(); const reset = useResetReleaseSource(); + const resetAll = useResetAllReleaseSources(); + + const sources = sourcesQuery.data ?? []; + + // The "Poll all" button is disabled when no enabled source exists: + // sending the request would just no-op server-side and waste a round + // trip. It also goes disabled while a fan-out is already in flight. + const enabledCount = sources.filter((s) => s.enabled).length; + const pollAllDisabled = enabledCount === 0 || pollAll.isPending; + // "Reset all" includes disabled sources by design (see backend handler + // doc), so the disable rule is just "no rows at all." + const resetAllDisabled = sources.length === 0 || resetAll.isPending; + + /** + * Confirm and fire the global reset. Destructive — wipes the ledger + * across every source, including disabled ones. The confirm string + * mirrors the per-source reset prompt's structure but emphasizes the + * blast radius up front and includes the row count so the user knows + * exactly what they're about to nuke. + */ + const handleResetAll = () => { + const sourceCount = sources.length; + const message = + `Reset ALL ${sourceCount} release source(s)?\n\n` + + `This deletes every release ledger row across every source ` + + `(including disabled ones) and clears each source's poll state ` + + `(etag, last poll time). User-managed settings (enabled, interval, ` + + `name) are preserved. The next poll for each enabled source will ` + + `re-record everything as new.\n\n` + + `This cannot be undone.`; + if (window.confirm(message)) { + resetAll.mutate(); + } + }; // The mutation hooks expose a single shared `isPending` flag, which would // light up the spinner on every row whenever any one row's request was in @@ -117,16 +158,48 @@ export function ReleaseTrackingSettings() { return ( - - - Release tracking + + + + Release tracking + + + + {/* "Reset all" sits next to "Poll all" but uses a `light` + + red palette so the destructive action is visually + distinct from the safe one. Confirm dialog gates the + actual call. */} + + Manage release sources. Each row is one logical feed exposed by a plugin (e.g. one Nyaa uploader or one MangaUpdates batch). Disabling a source pauses its scheduled polls; "Poll now" enqueues an immediate - fetch. + fetch. "Poll all now" fans the request out across every enabled + source. @@ -225,6 +298,7 @@ export function ReleaseTrackingSettings() { * itself is missing. */ function DefaultScheduleCard() { + const [opened, { toggle }] = useDisclosure(false); const queryClient = useQueryClient(); const settingQuery = useQuery({ queryKey: ["admin-setting", SETTING_DEFAULT_CRON_SCHEDULE], @@ -278,31 +352,55 @@ function DefaultScheduleCard() { return ( - + + {opened ? ( + + ) : ( + + )} Default schedule + {!opened && draft && ( + + {draft} + + )} - - Server-wide default cron used by every release source that doesn't - have its own per-row override. Changing this propagates immediately to - inheriting rows. - - + + + + Server-wide default cron used by every release source that doesn't + have its own per-row override. Changing this propagates + immediately to inheriting rows. + + + + ); } function NotificationPreferencesCard() { + const [opened, { toggle }] = useDisclosure(false); const queryClient = useQueryClient(); // Server-wide notify allowlists (admin-managed, persisted in `settings`). @@ -400,69 +498,112 @@ function NotificationPreferencesCard() { values, }); + const summaryParts: string[] = []; + if (allowedLanguages.length > 0) { + summaryParts.push( + `${allowedLanguages.length} ${allowedLanguages.length === 1 ? "language" : "languages"}`, + ); + } + if (allowedPlugins.length > 0) { + summaryParts.push( + `${allowedPlugins.length} ${allowedPlugins.length === 1 ? "source" : "sources"}`, + ); + } + if (mutedSeriesIds.length > 0) { + summaryParts.push( + `${mutedSeriesIds.length} muted ${mutedSeriesIds.length === 1 ? "series" : "series"}`, + ); + } + const summary = summaryParts.length > 0 ? summaryParts.join(" · ") : null; + return ( - + + {opened ? ( + + ) : ( + + )} Notification preferences - - - Filter announcement toasts and the Releases nav badge. Empty means "no - filter — let everything through." Server-wide for languages and plugin - sources; per-series mute is per-user (toggle on each series detail - page). - - - - - - - Muted series - + {!opened && summary && ( - {mutedSeriesIds.length === 0 - ? "No series muted for your account." - : `${mutedSeriesIds.length} series muted for your account.`} + {summary} - - + )} + + + + Filter announcement toasts and the Releases nav badge. Empty means + "no filter — let everything through." Server-wide for languages + and plugin sources; per-series mute is per-user (toggle on each + series detail page). + + + + + + + Muted series + + + {mutedSeriesIds.length === 0 + ? "No series muted for your account." + : `${mutedSeriesIds.length} series muted for your account.`} + + + + + + ); diff --git a/web/src/pages/settings/plugins/PluginForm.tsx b/web/src/pages/settings/plugins/PluginForm.tsx index eb67aabd..ca420913 100644 --- a/web/src/pages/settings/plugins/PluginForm.tsx +++ b/web/src/pages/settings/plugins/PluginForm.tsx @@ -4,8 +4,10 @@ import { Box, Button, Code, + Collapse, Divider, Group, + JsonInput, NumberInput, Select, Stack, @@ -15,10 +17,16 @@ import { Text, Textarea, TextInput, + UnstyledButton, } from "@mantine/core"; import type { useForm } from "@mantine/form"; +import { useDisclosure } from "@mantine/hooks"; import { notifications } from "@mantine/notifications"; -import { IconAlertCircle } from "@tabler/icons-react"; +import { + IconAlertCircle, + IconChevronDown, + IconChevronRight, +} from "@tabler/icons-react"; import { useState } from "react"; import { CREDENTIAL_DELIVERY_OPTIONS, type PluginDto } from "@/api/plugins"; @@ -91,61 +99,74 @@ export function safeJsonParse( // Config schema help component - displays available configuration options export function ConfigSchemaHelp({ schema, + defaultExpanded = false, }: { schema: NonNullable["configSchema"]; + defaultExpanded?: boolean; }) { + const [opened, { toggle }] = useDisclosure(defaultExpanded); + if (!schema || !schema.fields || schema.fields.length === 0) { return null; } return ( - - {schema.description && ( - - {schema.description} - - )} - - - - Option - Type - Default - Description - - - - {schema.fields.map((field) => ( - - - {field.key} - {field.required && ( - - * - - )} - - - - {field.type} - - - - {field.default !== undefined && field.default !== null ? ( - {JSON.stringify(field.default)} - ) : ( - - - - - )} - - - {field.description || "-"} - - - ))} - -
+ + + + + Available Configuration Options ({schema.fields.length}) + + {opened ? ( + + ) : ( + + )} + + + + + {schema.description && {schema.description}} + + + + Option + Description + + + + {schema.fields.map((field) => ( + + + + + {field.key} + {field.required && ( + + * + + )} + + + + {field.type} + + {field.default !== undefined && + field.default !== null && ( + {JSON.stringify(field.default)} + )} + + + + + {field.description || "-"} + + + ))} + +
+
+
); } @@ -285,11 +306,16 @@ export function PluginForm({ description="Optional working directory for the plugin process" {...form.getInputProps("workingDirectory")} /> -