diff --git a/docs/api/openapi.json b/docs/api/openapi.json
index e828f0d6..e7b43333 100644
--- a/docs/api/openapi.json
+++ b/docs/api/openapi.json
@@ -23881,6 +23881,7 @@
"required": [
"ledgerId",
"seriesId",
+ "seriesTitle",
"sourceId",
"pluginId",
"language",
@@ -23911,6 +23912,10 @@
"type": "string",
"format": "uuid"
},
+ "seriesTitle": {
+ "type": "string",
+ "description": "Series display title (`series_metadata.title`, falling back to the\nseries directory name). Carried in the event so notifications can\nrender a clickable series link without a second round-trip."
+ },
"sourceId": {
"type": "string",
"format": "uuid"
@@ -34859,6 +34864,18 @@
"$ref": "#/components/schemas/BoolOperator"
}
}
+ },
+ {
+ "type": "object",
+ "description": "Filter by whether release tracking is enabled for the series.\n\n`IsTrue` returns only series whose `series_tracking.tracked` flag is\n`true`. `IsFalse` returns everything else, including series with no\n`series_tracking` row at all (the common case for a fresh library).",
+ "required": [
+ "isTracked"
+ ],
+ "properties": {
+ "isTracked": {
+ "$ref": "#/components/schemas/BoolOperator"
+ }
+ }
}
],
"description": "Series-level search conditions\n\nConditions can be composed using `allOf` (AND) and `anyOf` (OR).\nUses untagged enum for cleaner JSON without explicit type field."
diff --git a/plugins/release-mangaupdates/src/index.test.ts b/plugins/release-mangaupdates/src/index.test.ts
index 0a066ec5..85105a23 100644
--- a/plugins/release-mangaupdates/src/index.test.ts
+++ b/plugins/release-mangaupdates/src/index.test.ts
@@ -1,6 +1,6 @@
import { HostRpcClient, type TrackedSeriesEntry } from "@ashdev/codex-plugin-sdk";
-import { describe, expect, it, vi } from "vitest";
-import { pollSeries } from "./index.js";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { _resetState, poll, pollSeries } from "./index.js";
import { EXTERNAL_ID_SOURCE_MANGAUPDATES } from "./manifest.js";
// -----------------------------------------------------------------------------
@@ -218,4 +218,213 @@ describe("pollSeries", () => {
expect(out.parsed).toBe(3);
expect(out.recorded).toBe(0); // every record returned deduped:true
});
+
+ it("uses the channel-level as payloadUrl on the v1 RSS feed shape", async () => {
+ // The current MU v1 feed has no per-item . The plugin should
+ // fall through to the channel-level link (the series page) rather
+ // than emitting an opaque `urn:mu:` URN, which is useless for the
+ // user clicking through from the inbox.
+ const v1Feed = `
+
+
+ https://www.mangaupdates.com/series/uu4rl66/series-slug
+
+ Series v.13 c.116
+ Galaxy Degen Scans
+
+
+ Series c.113a
+ Comikey
+
+
+ Series
+ OneshotGroup
+
+
+ `;
+ const { rpc, calls } = makeMockRpc(() => ({ ledgerId: "ld", deduped: false }));
+ const out = await pollSeries(rpc, "src-1", trackedEntry("series-1"), {
+ blockedGroups: [],
+ timeoutMs: 1000,
+ fetchImpl: mockFetchOk(v1Feed),
+ });
+ // Title-only item (no chapter/volume) is dropped before reaching record().
+ expect(out.parsed).toBe(2);
+ expect(out.recorded).toBe(2);
+
+ const recordCalls = calls.filter((c) => c.method === "releases/record");
+ expect(recordCalls).toHaveLength(2);
+ for (const call of recordCalls) {
+ const params = call.params as { candidate: { payloadUrl: string } };
+ expect(params.candidate.payloadUrl).toBe(
+ "https://www.mangaupdates.com/series/uu4rl66/series-slug",
+ );
+ }
+ });
+
+ it("emits distinct candidates for the same chapter from different groups", async () => {
+ // Three groups releasing the same chapter must surface as three
+ // ledger rows. The externalReleaseId hash includes the group so the
+ // host's `(source_id, external_release_id)` dedup doesn't collapse
+ // them into one.
+ const sameChapterDifferentGroups = `
+
+
+ https://www.mangaupdates.com/series/abc/series
+
+ Series c.200
+ Asura
+
+
+ Series c.200
+ FLAME-SCANS
+
+
+ Series c.200
+ LeviatanScans
+
+
+ `;
+ const { rpc, calls } = makeMockRpc(() => ({ ledgerId: "ld", deduped: false }));
+ const out = await pollSeries(rpc, "src-1", trackedEntry("series-1"), {
+ blockedGroups: [],
+ timeoutMs: 1000,
+ fetchImpl: mockFetchOk(sameChapterDifferentGroups),
+ });
+ expect(out.parsed).toBe(3);
+ expect(out.recorded).toBe(3);
+
+ const ids = calls
+ .filter((c) => c.method === "releases/record")
+ .map(
+ (c) =>
+ (c.params as { candidate: { externalReleaseId: string } }).candidate.externalReleaseId,
+ );
+ expect(new Set(ids).size).toBe(3);
+ });
+});
+
+// -----------------------------------------------------------------------------
+// poll (top-level): count_tracked + report_progress integration
+// -----------------------------------------------------------------------------
+
+describe("poll", () => {
+ beforeEach(() => {
+ _resetState();
+ });
+
+ it("calls count_tracked once and report_progress per series with the right denominator", async () => {
+ // Two tracked series, both with MU IDs, both upstream-200 with one item.
+ const tracked: TrackedSeriesEntry[] = [
+ {
+ seriesId: "series-1",
+ externalIds: { [EXTERNAL_ID_SOURCE_MANGAUPDATES]: "11111" } as Record,
+ },
+ {
+ seriesId: "series-2",
+ externalIds: { [EXTERNAL_ID_SOURCE_MANGAUPDATES]: "22222" } as Record,
+ },
+ ];
+ const v1Feed = `
+
+
+ https://www.mangaupdates.com/series/abc/series
+
+ Series c.1
+ SomeGroup
+
+
+ `;
+
+ const { rpc, calls } = makeMockRpc((method) => {
+ if (method === "releases/count_tracked") return { total: tracked.length };
+ if (method === "releases/list_tracked") return { tracked, nextOffset: undefined };
+ if (method === "releases/record") return { ledgerId: "ld", deduped: false };
+ if (method === "releases/report_progress") return { emitted: true };
+ throw new Error(`unexpected method: ${method}`);
+ });
+
+ // Initialize plugin state directly (bypass createReleaseSourcePlugin).
+ // The SDK normally injects state through `onInitialize`; for this test
+ // we only need the RPC client wired up, since `poll` reads `state.*`
+ // for blocked groups + timeout but works fine with the defaults.
+ //
+ // `Response.text()` consumes the body, so each `fetch` call needs a
+ // fresh `Response` — `mockImplementation` returns a new instance each
+ // invocation.
+ const fetchImpl = vi
+ .fn()
+ .mockImplementation(
+ async () => new Response(v1Feed, { status: 200, headers: { etag: '"e"' } }),
+ ) as unknown as typeof fetch;
+ // Replace global fetch for this test so pollSeries -> fetcher uses it.
+ const origFetch = globalThis.fetch;
+ globalThis.fetch = fetchImpl;
+ try {
+ await poll({ sourceId: "src-1", sourceKey: "default", config: null, etag: null }, rpc);
+ } finally {
+ globalThis.fetch = origFetch;
+ }
+
+ const countCalls = calls.filter((c) => c.method === "releases/count_tracked");
+ expect(countCalls).toHaveLength(1);
+ expect((countCalls[0]?.params as { sourceId: string }).sourceId).toBe("src-1");
+
+ const progressCalls = calls.filter((c) => c.method === "releases/report_progress");
+ // One emit per tracked series: 2 total. Denominator equals count.
+ expect(progressCalls).toHaveLength(2);
+ expect(progressCalls[0]?.params).toMatchObject({ current: 1, total: 2 });
+ expect(progressCalls[1]?.params).toMatchObject({ current: 2, total: 2 });
+ });
+
+ it("falls back to progressive denominator when count_tracked is unsupported", async () => {
+ // Older host: count_tracked returns METHOD_NOT_FOUND. The plugin
+ // should keep working and emit progress with `total = current`.
+ const tracked: TrackedSeriesEntry[] = [
+ {
+ seriesId: "series-1",
+ externalIds: { [EXTERNAL_ID_SOURCE_MANGAUPDATES]: "11111" } as Record,
+ },
+ ];
+ const v1Feed = `
+
+
+ https://www.mangaupdates.com/series/abc/series
+ Series c.1G
+
+ `;
+
+ const { rpc, calls } = makeMockRpc((method) => {
+ if (method === "releases/count_tracked") {
+ // Synthesize a JSON-RPC METHOD_NOT_FOUND error.
+ const err = Object.assign(new Error("Method not found"), { code: -32_601 });
+ // Throwing inside `respond` is captured by the mock writeFn and
+ // surfaced as an error response; HostRpcClient wraps it in
+ // HostRpcError which the plugin catches.
+ throw err;
+ }
+ if (method === "releases/list_tracked") return { tracked, nextOffset: undefined };
+ if (method === "releases/record") return { ledgerId: "ld", deduped: false };
+ if (method === "releases/report_progress") return { emitted: true };
+ throw new Error(`unexpected method: ${method}`);
+ });
+
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(v1Feed, { status: 200, headers: { etag: '"e"' } }),
+ ) as unknown as typeof fetch;
+ const origFetch = globalThis.fetch;
+ globalThis.fetch = fetchImpl;
+ try {
+ await poll({ sourceId: "src-1", sourceKey: "default", config: null, etag: null }, rpc);
+ } finally {
+ globalThis.fetch = origFetch;
+ }
+
+ const progressCalls = calls.filter((c) => c.method === "releases/report_progress");
+ expect(progressCalls).toHaveLength(1);
+ // No total known => current == total.
+ expect(progressCalls[0]?.params).toMatchObject({ current: 1, total: 1 });
+ });
});
diff --git a/plugins/release-mangaupdates/src/index.ts b/plugins/release-mangaupdates/src/index.ts
index e1356013..6f3dee29 100644
--- a/plugins/release-mangaupdates/src/index.ts
+++ b/plugins/release-mangaupdates/src/index.ts
@@ -82,6 +82,10 @@ interface RecordResponse {
deduped: boolean;
}
+interface CountTrackedResponse {
+ total: number;
+}
+
async function listTracked(
rpc: HostRpcClient,
sourceId: string,
@@ -95,6 +99,54 @@ async function listTracked(
});
}
+/**
+ * Total tracked-series denominator for this source, scoped by the
+ * plugin's `requires_external_ids` manifest declaration. Returns `null`
+ * when the host doesn't know the method (older host build) — callers
+ * fall back to progressive denominator emits in that case.
+ */
+async function countTracked(rpc: HostRpcClient, sourceId: string): Promise {
+ try {
+ const r = await rpc.call(RELEASES_METHODS.COUNT_TRACKED, {
+ sourceId,
+ });
+ return r.total;
+ } catch (err) {
+ if (err instanceof HostRpcError && err.code === -32601) {
+ // Host doesn't know `count_tracked` — older build. Degrade silently.
+ return null;
+ }
+ const reason = err instanceof Error ? err.message : String(err);
+ logger.warn(`count_tracked failed for ${sourceId}: ${reason}`);
+ return null;
+ }
+}
+
+/**
+ * Best-effort progress emit. Failures are swallowed — progress is a
+ * UX nice-to-have, never a reason to abort a poll.
+ */
+async function reportProgress(
+ rpc: HostRpcClient,
+ current: number,
+ total: number,
+ message?: string,
+): Promise {
+ try {
+ await rpc.call(
+ RELEASES_METHODS.REPORT_PROGRESS,
+ message !== undefined ? { current, total, message } : { current, total },
+ );
+ } catch (err) {
+ if (err instanceof HostRpcError && err.code === -32601) {
+ // Older host without progress support — silently drop.
+ return;
+ }
+ const reason = err instanceof Error ? err.message : String(err);
+ logger.debug(`report_progress dropped: ${reason}`);
+ }
+}
+
async function recordCandidate(
rpc: HostRpcClient,
sourceId: string,
@@ -175,8 +227,24 @@ function effectiveLanguagesForSeries(_entry: TrackedSeriesEntry): string[] {
/**
* Map a `ParsedRssItem` to a `ReleaseCandidate`. Confidence is 1.0 because
* the match is keyed by external ID — there's no fuzzy matching.
+ *
+ * `payloadUrl` priority: per-item link (legacy feed shape) → channel-level
+ * series page link (current v1 RSS shape) → last-resort `urn:mu:` URN. The
+ * URN fallback should never fire in practice; it exists so a malformed
+ * feed without even a channel link doesn't break the host's non-empty
+ * `payload_url` invariant.
*/
-function toCandidate(entry: TrackedSeriesEntry, item: ParsedRssItem): ReleaseCandidate {
+function toCandidate(
+ entry: TrackedSeriesEntry,
+ item: ParsedRssItem,
+ channelLink: string | null,
+): ReleaseCandidate {
+ const payloadUrl =
+ item.link.length > 0
+ ? item.link
+ : channelLink && channelLink.length > 0
+ ? channelLink
+ : `urn:mu:${item.externalReleaseId}`;
const candidate: ReleaseCandidate = {
seriesMatch: {
codexSeriesId: entry.seriesId,
@@ -191,7 +259,7 @@ function toCandidate(entry: TrackedSeriesEntry, item: ParsedRssItem): ReleaseCan
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}`,
+ payloadUrl,
observedAt: item.observedAt,
};
return candidate;
@@ -294,7 +362,7 @@ export async function pollSeries(
}
// result.kind === "ok"
- const items = parseFeed(result.body);
+ const { items, channelLink } = parseFeed(result.body);
const filters = resolveFilters({
languages: effectiveLanguagesForSeries(entry),
blockedGroups: options.blockedGroups,
@@ -305,7 +373,7 @@ export async function pollSeries(
for (const item of items) {
if (!passesFilters(item, filters)) continue;
matched++;
- const candidate = toCandidate(entry, item);
+ const candidate = toCandidate(entry, item, channelLink);
const outcome = await recordCandidate(rpc, sourceId, candidate);
if (!outcome) continue;
if (outcome.deduped) {
@@ -332,10 +400,24 @@ export async function pollSeries(
// Top-level poll handler
// =============================================================================
-async function poll(params: ReleasePollRequest, rpc: HostRpcClient): Promise {
+/**
+ * Top-level poll handler. Exported for tests (no underscore prefix because
+ * it's actually a load-bearing function that just happens to live behind
+ * the SDK plugin wrapper at module scope; `_resetState` is the
+ * pattern for state-only test seams).
+ */
+export async function poll(
+ params: ReleasePollRequest,
+ rpc: HostRpcClient,
+): Promise {
const sourceId = params.sourceId;
const blockedGroups = parseCommaList(state.blockedGroupsCsv);
+ // Pre-count so progress emits can carry a stable denominator. Falls
+ // back to progressive ('N polled' with no total) when the host doesn't
+ // implement count_tracked, keeping us forward-compatible.
+ const total = await countTracked(rpc, sourceId);
+
let parsed = 0;
let matched = 0;
let recorded = 0;
@@ -369,6 +451,16 @@ async function poll(params: ReleasePollRequest, rpc: HostRpcClient): Promise 0) {
diff --git a/plugins/release-mangaupdates/src/parser.test.ts b/plugins/release-mangaupdates/src/parser.test.ts
index 4023be76..b48041ce 100644
--- a/plugins/release-mangaupdates/src/parser.test.ts
+++ b/plugins/release-mangaupdates/src/parser.test.ts
@@ -82,6 +82,41 @@ describe("parseTitle", () => {
const t = parseTitle("c.143 by Group (en) ");
expect(t.language).toBe("en");
});
+
+ it("parses chapter from a current-format title (series prefix, no group)", () => {
+ // The MU v1 RSS feed ships titles like 'Series Name v.13 c.116' with
+ // the group living in . Chapter and volume must still
+ // come out cleanly; group is null because the title doesn't carry it.
+ const t = parseTitle("Solo Leveling v.13 c.116");
+ expect(t.chapter).toBe(116);
+ expect(t.volume).toBe(13);
+ expect(t.group).toBeNull();
+ expect(t.language).toBe("en");
+ });
+
+ it("strips letter suffix from chapter (c.113a -> 113)", () => {
+ // MangaUpdates uses 'a'/'b' suffixes for split chapter releases. The
+ // older `\b` regex required a word boundary the digit-letter join
+ // can't satisfy, so these dropped to chapter=null. Capture the integer
+ // and let the group-keyed externalReleaseId keep the halves distinct.
+ const a = parseTitle("Series v.13 c.113a");
+ expect(a.chapter).toBe(113);
+ expect(a.volume).toBe(13);
+ const b = parseTitle("Series v.13 c.113b");
+ expect(b.chapter).toBe(113);
+ expect(b.volume).toBe(13);
+ });
+
+ it("preserves decimal chapters when an `a/b` suffix is absent", () => {
+ const t = parseTitle("Series c.113.5");
+ expect(t.chapter).toBe(113.5);
+ });
+
+ it("returns null chapter and volume for a series-name-only title", () => {
+ const t = parseTitle("Solo Leveling");
+ expect(t.chapter).toBeNull();
+ expect(t.volume).toBeNull();
+ });
});
// -----------------------------------------------------------------------------
@@ -132,6 +167,52 @@ describe("parseItem", () => {
expect(a?.externalReleaseId.startsWith("t:")).toBe(true);
});
+ it("includes the group in the deterministic id so different groups don't collide", () => {
+ // The current MU v1 RSS feed has no // per item,
+ // so all 3 fall to the deterministic-hash branch. If the hash didn't
+ // include the group, three groups posting the same chapter would all
+ // hash to the same externalReleaseId and dedupe down to one row.
+ const a = parseItem(`
+ Series c.200
+ Asura
+ `);
+ const b = parseItem(`
+ Series c.200
+ FLAME-SCANS
+ `);
+ const c = parseItem(`
+ Series c.200
+ Asura
+ `);
+ expect(a?.externalReleaseId).not.toBe(b?.externalReleaseId);
+ // Same group + same title hashes to the same id (idempotent re-poll).
+ expect(a?.externalReleaseId).toBe(c?.externalReleaseId);
+ });
+
+ it("reads the scanlation group from on the v1 RSS feed", () => {
+ const xml = `
+ Solo Leveling v.13 c.116
+ Galaxy Degen Scans
+ `;
+ const item = parseItem(xml);
+ expect(item).not.toBeNull();
+ if (!item) return;
+ expect(item.group).toBe("Galaxy Degen Scans");
+ expect(item.chapter).toBe(116);
+ expect(item.volume).toBe(13);
+ });
+
+ it("skips items that carry neither chapter nor volume", () => {
+ // Series-name-only entries / oneshot announcements / series headers
+ // are inbox noise — the host has no useful sort key for them and they
+ // surface as empty `Ch / Vol` rows in the UI.
+ const xml = `
+ Solo Leveling
+ Some Group
+ `;
+ expect(parseItem(xml)).toBeNull();
+ });
+
it("returns null for a malformed item missing title", () => {
const xml = `https://example.com`;
expect(parseItem(xml)).toBeNull();
@@ -203,7 +284,8 @@ const multilingualFeed = `
describe("parseFeed", () => {
it("parses all items in a multi-language fixture", () => {
- const items = parseFeed(multilingualFeed);
+ const { items, channelLink } = parseFeed(multilingualFeed);
+ expect(channelLink).toBeNull();
expect(items).toHaveLength(5);
expect(items[0]?.language).toBe("en");
expect(items[1]?.language).toBe("es");
@@ -216,12 +298,52 @@ describe("parseFeed", () => {
expect(items[4]?.language).toBe("en");
});
- it("returns an empty array for an empty channel", () => {
- expect(parseFeed("")).toEqual([]);
+ it("returns an empty result for an empty channel", () => {
+ expect(parseFeed("")).toEqual({
+ channelLink: null,
+ items: [],
+ });
});
- it("returns an empty array for malformed XML", () => {
+ it("returns an empty result for malformed XML", () => {
// Non-fatal: parseFeed should never throw, just return whatever it can.
- expect(parseFeed("<<>>")).toEqual([]);
+ expect(parseFeed("<<>>")).toEqual({ channelLink: null, items: [] });
+ });
+
+ it("extracts the channel-level link from the v1 RSS shape", () => {
+ // Mirror of the real `https://api.mangaupdates.com/v1/series/{id}/rss`
+ // shape: chapters in the title, group in , no per-item
+ // links, channel-level link points at the series page.
+ const v1Feed = `
+
+
+ Series Title - Releases on MangaUpdates
+ https://www.mangaupdates.com/series/uu4rl66/series-slug
+ ...
+
+ Series Title v.13 c.116
+ Galaxy Degen Scans
+
+
+ Series Title c.113a
+ Comikey
+
+
+ Series Title
+ OneshotGroup
+
+
+ `;
+ const { items, channelLink } = parseFeed(v1Feed);
+ expect(channelLink).toBe("https://www.mangaupdates.com/series/uu4rl66/series-slug");
+ // Third item drops out: no chapter, no volume.
+ expect(items).toHaveLength(2);
+ expect(items[0]?.chapter).toBe(116);
+ expect(items[0]?.volume).toBe(13);
+ expect(items[0]?.group).toBe("Galaxy Degen Scans");
+ // c.113a -> chapter 113, suffix discarded.
+ expect(items[1]?.chapter).toBe(113);
+ expect(items[1]?.volume).toBeNull();
+ expect(items[1]?.group).toBe("Comikey");
});
});
diff --git a/plugins/release-mangaupdates/src/parser.ts b/plugins/release-mangaupdates/src/parser.ts
index 321cc2c1..56b2abc4 100644
--- a/plugins/release-mangaupdates/src/parser.ts
+++ b/plugins/release-mangaupdates/src/parser.ts
@@ -3,12 +3,16 @@
*
* Per-series feed: `https://api.mangaupdates.com/v1/series/{series_id}/rss`
*
- * Each `` is one scanlation release. The plugin extracts:
- * - chapter / volume from the title
- * - scanlation group from the title
- * - language tag (parenthesized two-letter code) from the title
- * - link (the MangaUpdates release page) used as `payloadUrl`
- * - pubDate as `observedAt`
+ * The v1 RSS feed is intentionally sparse:
+ * - `` carries `{Series Name} {v.N}? {c.N}` — chapter and/or volume
+ * suffixed with optional letter (`c.113a`, `c.113b` for split chapters)
+ * - `` carries the scanlation group name
+ * - per-item ``, ``, `` are NOT present; only the
+ * channel-level `` (the series page on mangaupdates.com) exists
+ *
+ * Items that carry neither chapter nor volume info are dropped — they're
+ * usually announcements ("oneshot release", series-name-only entries) and
+ * have no place in an inbox.
*
* Implementation note: we do NOT pull in a heavy XML parser. The MangaUpdates
* RSS format is simple, well-formed, and stable. A small targeted regex
@@ -112,23 +116,34 @@ export function parseTitle(title: string): {
} {
const trimmed = title.trim();
- // Chapter: c.N or ch.N (allow decimals).
+ // Chapter: c.N or ch.N. Decimals (`47.5`) and letter suffixes (`113a`,
+ // `113b` for split chapters) are both supported; the letter suffix is
+ // stripped so `c.113a` and `c.113b` map to chapter 113. Letter-suffix
+ // variants get distinct externalReleaseIds via the group, so they remain
+ // separate ledger rows even though they share an integer. The lookahead
+ // (`(?![0-9])`) replaces the older `\b` so the trailing letter doesn't
+ // block the match the way `\b` does between two word characters.
let chapter: number | null = null;
- const chMatch = trimmed.match(/\bc(?:h)?\.?\s*([0-9]+(?:\.[0-9]+)?)\b/i);
+ const chMatch = trimmed.match(/\bc(?:h)?\.?\s*([0-9]+(?:\.[0-9]+)?)[a-z]?(?![0-9])/i);
if (chMatch?.[1]) {
const n = Number.parseFloat(chMatch[1]);
if (Number.isFinite(n)) chapter = n;
}
- // Volume: v.N or vol.N.
+ // Volume: v.N or vol.N. Letter suffixes accepted and discarded for the
+ // same reason as chapters.
let volume: number | null = null;
- const volMatch = trimmed.match(/\bv(?:ol)?\.?\s*([0-9]+)\b/i);
+ const volMatch = trimmed.match(/\bv(?:ol)?\.?\s*([0-9]+)[a-z]?(?![0-9])/i);
if (volMatch?.[1]) {
const n = Number.parseInt(volMatch[1], 10);
if (Number.isFinite(n)) volume = n;
}
- // Group: "by " up to "(" or end.
+ // Group: legacy "by " pattern. The current MangaUpdates v1 RSS
+ // feed places the scanlation group in ``, not the title;
+ // this branch is kept as a fallback so older / legacy feed shapes still
+ // surface a group. Captured up to `(` or end-of-string so a trailing
+ // `(en)` language tag doesn't bleed into the group name.
let group: string | null = null;
const groupMatch = trimmed.match(/\bby\s+(.+?)(?:\s*\([a-z]{2,3}\)\s*)?$/i);
if (groupMatch?.[1]) {
@@ -174,23 +189,29 @@ function pubDateToIso(raw: string | null): string {
}
/**
- * Derive a stable external_release_id. Prefer ``, then the link URL,
- * otherwise fall back to a deterministic hash of `(title + pubDate)`.
+ * Derive a stable external_release_id.
*
- * Stability is what matters: re-polling the same item must produce the same
- * ID so the host's `(source_id, external_release_id)` dedup catches it.
+ * Priority:
+ * 1. `` if present (richest legacy format).
+ * 2. `` if present (legacy format with per-item links).
+ * 3. Deterministic hash of `(title + group + pubDate)` for the current
+ * v1 RSS shape, which carries none of the above per-item fields.
+ * Including the group in the hash is what lets multiple groups
+ * releasing the same chapter ("c.200" by Asura, by FLAME-SCANS,
+ * by LeviatanScans) hash to distinct IDs and become distinct
+ * ledger rows. Same-group same-chapter re-polls collide on the
+ * hash and dedupe, which is what the host expects.
*/
function deriveExternalReleaseId(
guid: string | null,
link: string | null,
title: string,
+ group: string | null,
pubDate: string | null,
): string {
if (guid && guid.trim().length > 0) return guid.trim();
if (link && link.trim().length > 0) return link.trim();
- // Deterministic fallback for feeds that omit both. djb2-ish hash keeps the
- // ID short while staying stable across polls.
- const fallback = `${title}|${pubDate ?? ""}`;
+ const fallback = `${title}|${group ?? ""}|${pubDate ?? ""}`;
let h = 5381;
for (let i = 0; i < fallback.length; i++) {
h = ((h << 5) + h + fallback.charCodeAt(i)) | 0;
@@ -200,7 +221,10 @@ function deriveExternalReleaseId(
/**
* Parse a single MangaUpdates `` block into a `ParsedRssItem`. Returns
- * null if the title is missing entirely (truly malformed item).
+ * null when the item is unusable:
+ * - missing `` (truly malformed), or
+ * - title carries neither chapter nor volume (announcements, oneshot
+ * stubs, series-name-only entries — pure inbox noise).
*/
export function parseItem(itemXml: string): ParsedRssItem | null {
const title = extractTagText(itemXml, "title");
@@ -209,11 +233,18 @@ export function parseItem(itemXml: string): ParsedRssItem | null {
const link = extractTagText(itemXml, "link");
const guid = extractTagText(itemXml, "guid");
const pubDate = extractTagText(itemXml, "pubDate");
+ const description = extractTagText(itemXml, "description");
+
+ const { chapter, volume, group: groupFromTitle, language } = parseTitle(title);
+ if (chapter === null && volume === null) return null;
- const { chapter, volume, group, language } = parseTitle(title);
+ // The v1 RSS feed places the scanlation group in ``. Prefer
+ // it; fall back to the legacy "by " title pattern.
+ const descTrimmed = description?.trim();
+ const group = descTrimmed && descTrimmed.length > 0 ? descTrimmed : groupFromTitle;
return {
- externalReleaseId: deriveExternalReleaseId(guid, link, title, pubDate),
+ externalReleaseId: deriveExternalReleaseId(guid, link, title, group, pubDate),
title,
chapter,
volume,
@@ -224,13 +255,42 @@ export function parseItem(itemXml: string): ParsedRssItem | null {
};
}
+/** Parsed feed: items plus the channel-level link (if any). */
+export interface ParsedFeed {
+ /** Channel-level `` — the series page on mangaupdates.com. Used as
+ * the `payloadUrl` for releases when no per-item link exists (the v1
+ * RSS shape). `null` when the channel block is missing or malformed. */
+ channelLink: string | null;
+ items: ParsedRssItem[];
+}
+
+/**
+ * Parse a full MangaUpdates per-series RSS feed body. Items that fail
+ * `parseItem` (missing title, or no chapter/volume) are dropped silently —
+ * the feed parser is best-effort tolerant.
+ */
+export function parseFeed(xml: string): ParsedFeed {
+ return {
+ channelLink: extractChannelLink(xml),
+ items: splitItems(xml)
+ .map(parseItem)
+ .filter((i): i is ParsedRssItem => i !== null),
+ };
+}
+
/**
- * Parse a full MangaUpdates per-series RSS feed body into items. Bad items
- * (missing title) are dropped silently — the feed should be best-effort
- * tolerant.
+ * Extract the channel-level `` from a feed. The v1 RSS feed uses
+ * `https://...` and that URL is the series
+ * page on mangaupdates.com. We prefer the first `` *outside* any
+ * `` block so per-item legacy links (which we don't expect at the
+ * channel level anyway) can never bleed in.
*/
-export function parseFeed(xml: string): ParsedRssItem[] {
- return splitItems(xml)
- .map(parseItem)
- .filter((i): i is ParsedRssItem => i !== null);
+function extractChannelLink(xml: string): string | null {
+ // Strip every ... block before searching — cheap way to
+ // scope to the channel header.
+ const stripped = xml.replace(/]*>[\s\S]*?<\/item>/gi, "");
+ const link = extractTagText(stripped, "link");
+ if (!link) return null;
+ const trimmed = link.trim();
+ return trimmed.length > 0 ? trimmed : null;
}
diff --git a/plugins/sdk-typescript/src/types/releases.ts b/plugins/sdk-typescript/src/types/releases.ts
index 1345acbb..a2ce26d4 100644
--- a/plugins/sdk-typescript/src/types/releases.ts
+++ b/plugins/sdk-typescript/src/types/releases.ts
@@ -21,6 +21,25 @@
export const RELEASES_METHODS = {
/** List tracked series, scoped to what the plugin's manifest declared. */
LIST_TRACKED: "releases/list_tracked",
+ /**
+ * Count tracked series scoped to the plugin's `requiresExternalIds`.
+ *
+ * Plugins call this once at the start of a poll to learn the total
+ * denominator before iterating, so subsequent `REPORT_PROGRESS` calls
+ * carry a stable `current/total` ratio. Cheap (one batched DB lookup);
+ * safe to call from `poll`.
+ */
+ COUNT_TRACKED: "releases/count_tracked",
+ /**
+ * Report intra-poll progress to the host. The host translates this into
+ * a `TaskProgressEvent` on the active task's broadcaster; the inbox
+ * progress bar updates live. Best-effort — calls outside an active
+ * task scope are silently dropped, and rapid back-to-back calls are
+ * rate-limited (~10/sec) by the host. Plugins SHOULD call this after
+ * each unit of work (e.g. after each polled series) with `current` set
+ * to the count of completed units and `total` from `COUNT_TRACKED`.
+ */
+ REPORT_PROGRESS: "releases/report_progress",
/** Submit a candidate to the host's release ledger. */
RECORD: "releases/record",
/** Get persisted per-source state (etag, last_polled_at, last_error). */
diff --git a/src/api/routes/v1/dto/filter.rs b/src/api/routes/v1/dto/filter.rs
index 83a0c145..27ad97f5 100644
--- a/src/api/routes/v1/dto/filter.rs
+++ b/src/api/routes/v1/dto/filter.rs
@@ -107,6 +107,15 @@ pub enum SeriesCondition {
#[serde(rename = "hasUserRating")]
has_user_rating: BoolOperator,
},
+ /// Filter by whether release tracking is enabled for the series.
+ ///
+ /// `IsTrue` returns only series whose `series_tracking.tracked` flag is
+ /// `true`. `IsFalse` returns everything else, including series with no
+ /// `series_tracking` row at all (the common case for a fresh library).
+ IsTracked {
+ #[serde(rename = "isTracked")]
+ is_tracked: BoolOperator,
+ },
}
/// Book-level search conditions
@@ -631,4 +640,39 @@ mod tests {
_ => panic!("Expected HasUserRating condition with IsTrue operator"),
}
}
+
+ #[test]
+ fn test_is_tracked_condition_is_true() {
+ let condition = SeriesCondition::IsTracked {
+ is_tracked: BoolOperator::IsTrue,
+ };
+
+ let json = serde_json::to_string(&condition).unwrap();
+ assert!(json.contains(r#""isTracked""#));
+ assert!(json.contains(r#""operator":"isTrue""#));
+ }
+
+ #[test]
+ fn test_is_tracked_condition_is_false() {
+ let condition = SeriesCondition::IsTracked {
+ is_tracked: BoolOperator::IsFalse,
+ };
+
+ let json = serde_json::to_string(&condition).unwrap();
+ assert!(json.contains(r#""isTracked""#));
+ assert!(json.contains(r#""operator":"isFalse""#));
+ }
+
+ #[test]
+ fn test_is_tracked_condition_deserialization() {
+ let json = r#"{"isTracked":{"operator":"isTrue"}}"#;
+ let condition: SeriesCondition = serde_json::from_str(json).unwrap();
+
+ match condition {
+ SeriesCondition::IsTracked {
+ is_tracked: BoolOperator::IsTrue,
+ } => {}
+ _ => panic!("Expected IsTracked condition with IsTrue operator"),
+ }
+ }
}
diff --git a/src/events/mod.rs b/src/events/mod.rs
index dc3e9a52..7daacf4a 100644
--- a/src/events/mod.rs
+++ b/src/events/mod.rs
@@ -12,7 +12,10 @@ mod task_context;
mod types;
pub use broadcaster::{EventBroadcaster, RecordedEvent};
-pub use task_context::{current_recording_broadcaster, with_recording_broadcaster};
+pub use task_context::{
+ TaskIdentity, current_recording_broadcaster, current_task_identity, with_recording_broadcaster,
+ with_task_identity,
+};
// TaskProgress is part of the public API for task progress reporting
#[allow(unused_imports)]
pub use types::{
diff --git a/src/events/task_context.rs b/src/events/task_context.rs
index df5aad83..b5219e0a 100644
--- a/src/events/task_context.rs
+++ b/src/events/task_context.rs
@@ -19,6 +19,9 @@
//! call), so the task-local set up by [`crate::tasks::worker`] is in scope.
use std::sync::Arc;
+use std::sync::Mutex;
+
+use uuid::Uuid;
use super::EventBroadcaster;
@@ -27,6 +30,49 @@ tokio::task_local! {
/// worker around `handler.handle(...)`. Read by reverse-RPC handlers via
/// [`current_recording_broadcaster`].
static CURRENT_RECORDING_BROADCASTER: Arc;
+ /// Identity + progress-throttle state for the currently-executing task.
+ /// Set by the worker around `handler.handle(...)`. Read by reverse-RPC
+ /// handlers via [`current_task_identity`] when they need to construct a
+ /// `TaskProgressEvent` (which requires the task's id and type) or
+ /// rate-limit progress emits.
+ static CURRENT_TASK_IDENTITY: Arc;
+}
+
+/// Task identity exposed to reverse-RPC handlers via the
+/// [`CURRENT_TASK_IDENTITY`] task-local. Carries the fields needed to build
+/// a [`super::TaskProgressEvent`] plus a tiny throttle-state cell so
+/// `releases/report_progress` can drop emits arriving faster than the
+/// configured cadence.
+#[derive(Debug)]
+pub struct TaskIdentity {
+ pub task_id: Uuid,
+ pub task_type: String,
+ pub library_id: Option,
+ pub series_id: Option,
+ pub book_id: Option,
+ /// Last time a progress emit went through. `None` until the first emit.
+ /// Wrapped in a `Mutex` so reverse-RPC handlers (which see the identity
+ /// behind an `Arc`) can update it without a `&mut`.
+ pub last_progress_emit: Mutex