@ashdev/codex-plugin-release-mangaupdates 1.26.1 → 1.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -796,7 +796,7 @@ function dedupePreserveOrder(xs) {
796
796
  // package.json
797
797
  var package_default = {
798
798
  name: "@ashdev/codex-plugin-release-mangaupdates",
799
- version: "1.26.1",
799
+ version: "1.27.1",
800
800
  description: "MangaUpdates RSS release-source plugin for Codex - announces new chapter releases for tracked series in user-configured languages",
801
801
  main: "dist/index.js",
802
802
  bin: "dist/index.js",
@@ -835,7 +835,7 @@ var package_default = {
835
835
  node: ">=22.0.0"
836
836
  },
837
837
  dependencies: {
838
- "@ashdev/codex-plugin-sdk": "^1.26.1"
838
+ "@ashdev/codex-plugin-sdk": "^1.27.1"
839
839
  },
840
840
  devDependencies: {
841
841
  "@biomejs/biome": "^2.4.4",
package/dist/index.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../node_modules/@ashdev/codex-plugin-sdk/src/types/rpc.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/errors.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/request-context.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/host-rpc.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/logger.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/server.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/storage.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/types/releases.ts", "../src/fetcher.ts", "../src/parser.ts", "../src/filter.ts", "../package.json", "../src/manifest.ts", "../src/index.ts"],
4
- "sourcesContent": [null, null, null, null, null, null, null, null, "/**\n * MangaUpdates per-series RSS fetcher.\n *\n * Wraps `fetch` with conditional GET (`If-None-Match` from a stored ETag) and\n * a hard timeout. Returns a discriminated result so the caller can:\n * - act on `200`: parse the body, persist the new ETag.\n * - skip parse on `304`: nothing changed since last poll.\n * - report `429` / `5xx` upstream-status codes back to the host so the\n * per-host backoff layer can react.\n *\n * Network is the only side effect; nothing in here touches storage, the host,\n * or process state. That keeps it trivially testable: pass a mocked `fetch`\n * implementation and assert.\n */\n\n/** Discriminated fetch result. */\nexport type FetchResult =\n | { kind: \"ok\"; body: string; etag: string | null; status: 200 }\n | { kind: \"notModified\"; status: 304 }\n | { kind: \"error\"; status: number; message: string };\n\nexport interface FetcherOptions {\n /** Custom `fetch` impl (for testing). Defaults to global `fetch`. */\n fetchImpl?: typeof fetch;\n /** Per-request timeout. Defaults to 10s. */\n timeoutMs?: number;\n}\n\n/** Public base URL for MangaUpdates' v1 RSS API. */\nexport const MANGAUPDATES_RSS_BASE = \"https://api.mangaupdates.com/v1/series\";\n\n/**\n * Normalize a MangaUpdates series ID to its numeric form for API calls.\n *\n * MangaUpdates uses two interchangeable representations of the same ID:\n *\n * - **Numeric** (e.g. `15180124327`) \u2014 the internal primary key. Every\n * `/v1/series/...` API endpoint requires this form.\n * - **Base36 slug** (e.g. `6z1uqw7`) \u2014 a base36 encoding of the numeric\n * ID, used in public URLs only (`mangaupdates.com/series/6z1uqw7/...`).\n * The API rejects this form with a 405.\n *\n * Metadata sources (MangaBaka, etc.) typically scrape the public URL and\n * store the slug, so the value we receive on `entry.externalIds.mangaupdates`\n * is whatever the source happened to grab. Decode here so callers don't\n * have to know.\n *\n * Returns the input unchanged when it's already an all-digit string;\n * `null` when the input contains characters outside the base36 alphabet\n * (caller should surface as a configuration error).\n */\nexport function normalizeMangaUpdatesId(raw: string): string | null {\n const trimmed = raw.trim();\n if (trimmed.length === 0) return null;\n if (/^\\d+$/.test(trimmed)) return trimmed;\n if (!/^[0-9a-z]+$/i.test(trimmed)) return null;\n // parseInt('6z1uqw7', 36) = 15180124327. JS numbers are precise for\n // integers up to 2^53; MangaUpdates IDs sit well below that.\n const decoded = Number.parseInt(trimmed, 36);\n if (!Number.isFinite(decoded) || decoded <= 0) return null;\n return String(decoded);\n}\n\n/**\n * Build the per-series RSS URL. Accepts either the numeric ID or the\n * base36 slug \u2014 see `normalizeMangaUpdatesId` for the rationale.\n */\nexport function feedUrl(mangaUpdatesId: string): string {\n const normalized = normalizeMangaUpdatesId(mangaUpdatesId) ?? mangaUpdatesId;\n return `${MANGAUPDATES_RSS_BASE}/${normalized}/rss`;\n}\n\n/**\n * Conditional GET against a per-series RSS feed.\n *\n * @param mangaUpdatesId - The MangaUpdates series ID.\n * @param previousEtag - The ETag from the previous successful poll (if any).\n * @param opts - Fetcher options (custom fetch, timeout).\n */\nexport async function fetchSeriesFeed(\n mangaUpdatesId: string,\n previousEtag: string | null,\n opts: FetcherOptions = {},\n): Promise<FetchResult> {\n const fetchImpl = opts.fetchImpl ?? globalThis.fetch;\n const timeoutMs = opts.timeoutMs ?? 10_000;\n\n const url = feedUrl(mangaUpdatesId);\n const headers: Record<string, string> = {\n Accept: \"application/rss+xml, application/xml;q=0.9, */*;q=0.5\",\n \"User-Agent\": \"Codex-ReleaseTracker/1.0 (+https://github.com/AshDevFr/codex)\",\n };\n if (previousEtag) {\n headers[\"If-None-Match\"] = previousEtag;\n }\n\n // AbortSignal.timeout is the cleanest path. Falling back to a manual\n // controller would add complexity without value (we already require Node\n // 22+).\n const signal = AbortSignal.timeout(timeoutMs);\n\n let resp: Response;\n try {\n resp = await fetchImpl(url, { method: \"GET\", headers, signal });\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"Unknown fetch error\";\n // Treat aborts and other transport-level failures as 0/unavailable so\n // the host's per-host backoff layer can detect \"this domain is sad\n // right now\" without us having to invent a fake HTTP status.\n return { kind: \"error\", status: 0, message: msg };\n }\n\n if (resp.status === 304) {\n return { kind: \"notModified\", status: 304 };\n }\n\n if (resp.status === 200) {\n const body = await resp.text();\n const etag = resp.headers.get(\"etag\");\n return { kind: \"ok\", body, etag, status: 200 };\n }\n\n // Pass through 429 / 5xx so the host's backoff layer sees the real status.\n return {\n kind: \"error\",\n status: resp.status,\n message: `upstream returned ${resp.status} ${resp.statusText}`,\n };\n}\n", "/**\n * RSS parser for MangaUpdates per-series feeds.\n *\n * Per-series feed: `https://api.mangaupdates.com/v1/series/{series_id}/rss`\n *\n * The v1 RSS feed is intentionally sparse:\n * - `<title>` carries `{Series Name} {v.N}? {c.N}` \u2014 chapter and/or volume\n * suffixed with optional letter (`c.113a`, `c.113b` for split chapters)\n * - `<description>` carries the scanlation group name\n * - per-item `<link>`, `<guid>`, `<pubDate>` are NOT present; only the\n * channel-level `<link>` (the series page on mangaupdates.com) exists\n *\n * Items that carry neither chapter nor volume info are dropped \u2014 they're\n * usually announcements (\"oneshot release\", series-name-only entries) and\n * have no place in an inbox.\n *\n * Implementation note: we do NOT pull in a heavy XML parser. The MangaUpdates\n * RSS format is simple, well-formed, and stable. A small targeted regex\n * pipeline avoids a 100kb dependency and CVE surface for marginal benefit.\n */\n\n/** Parsed item, pre-`ReleaseCandidate`. */\nexport interface ParsedRssItem {\n /** Stable per-source ID. Derived from the release URL or guid. */\n externalReleaseId: string;\n /** Original title string. Useful for debugging / fallback. */\n title: string;\n /** Chapter number (decimals supported, e.g. \"47.5\"). */\n chapter: number | null;\n /** Volume number. */\n volume: number | null;\n /**\n * Language tag (lowercased ISO 639-1). Defaults to `\"en\"` when the title\n * doesn't carry an explicit `(xx)` code, since the MangaUpdates v1 RSS\n * endpoint serves the English release stream. The legacy\n * `UNKNOWN_LANGUAGE` sentinel is still exported for callers that want\n * to surface \"no tag detected\" explicitly, but the parser no longer\n * produces it on its own.\n */\n language: string;\n /** Scanlation group name (best-effort; nullable). */\n group: string | null;\n /** Release page URL on MangaUpdates. Used as `payloadUrl`. */\n link: string;\n /** ISO-8601 string. Falls back to \"now\" when pubDate is missing/invalid. */\n observedAt: string;\n}\n\n/** Sentinel returned when the language tag can't be detected. */\nexport const UNKNOWN_LANGUAGE = \"unknown\" as const;\n\n// -----------------------------------------------------------------------------\n// XML helpers\n// -----------------------------------------------------------------------------\n\n/** Strip CDATA wrapper if present, unescape `&amp;` `&lt;` `&gt;` `&quot;`. */\nfunction decodeXmlText(raw: string): string {\n let s = raw.trim();\n const cdataMatch = s.match(/^<!\\[CDATA\\[([\\s\\S]*?)]]>$/);\n if (cdataMatch?.[1] !== undefined) {\n s = cdataMatch[1];\n }\n return s\n .replace(/&amp;/g, \"&\")\n .replace(/&lt;/g, \"<\")\n .replace(/&gt;/g, \">\")\n .replace(/&quot;/g, '\"')\n .replace(/&#39;/g, \"'\")\n .replace(/&apos;/g, \"'\");\n}\n\n/** Pull the first `<tag>` text content from an XML fragment, or null. */\nfunction extractTagText(xml: string, tag: string): string | null {\n const re = new RegExp(`<${tag}[^>]*>([\\\\s\\\\S]*?)</${tag}>`, \"i\");\n const m = xml.match(re);\n if (!m?.[1]) return null;\n return decodeXmlText(m[1]);\n}\n\n/** Pull all `<item>...</item>` blocks from a feed. */\nfunction splitItems(xml: string): string[] {\n const out: string[] = [];\n const re = /<item\\b[^>]*>([\\s\\S]*?)<\\/item>/gi;\n for (;;) {\n const match = re.exec(xml);\n if (match === null) break;\n if (match[1] !== undefined) out.push(match[1]);\n }\n return out;\n}\n\n// -----------------------------------------------------------------------------\n// Title parsing\n// -----------------------------------------------------------------------------\n\n/**\n * Extract chapter/volume/group/language from a MangaUpdates RSS title.\n *\n * Observed shapes:\n * \"Vol.2 c.14 by GroupName (en)\"\n * \"v.2 c.14.5 by GroupName (es)\"\n * \"c.143 by GroupName\" (language missing)\n * \"Vol.15 by GroupName (en)\" (volume-only bundle)\n * \"c.143 (en)\" (no group)\n *\n * Volume tokens: `v.N`, `vol.N`, `Vol.N` (case-insensitive).\n * Chapter tokens: `c.N`, `ch.N`, `Ch.N` (decimals allowed).\n * Group: text between `by ` and the next `(` or end-of-string.\n * Language: trailing `(xx)` two-letter code, lowercased.\n */\nexport function parseTitle(title: string): {\n chapter: number | null;\n volume: number | null;\n group: string | null;\n language: string;\n} {\n const trimmed = title.trim();\n\n // Chapter: c.N or ch.N. Decimals (`47.5`) and letter suffixes (`113a`,\n // `113b` for split chapters) are both supported; the letter suffix is\n // stripped so `c.113a` and `c.113b` map to chapter 113. Letter-suffix\n // variants get distinct externalReleaseIds via the group, so they remain\n // separate ledger rows even though they share an integer. The lookahead\n // (`(?![0-9])`) replaces the older `\\b` so the trailing letter doesn't\n // block the match the way `\\b` does between two word characters.\n let chapter: number | null = null;\n const chMatch = trimmed.match(/\\bc(?:h)?\\.?\\s*([0-9]+(?:\\.[0-9]+)?)[a-z]?(?![0-9])/i);\n if (chMatch?.[1]) {\n const n = Number.parseFloat(chMatch[1]);\n if (Number.isFinite(n)) chapter = n;\n }\n\n // Volume: v.N or vol.N. Letter suffixes accepted and discarded for the\n // same reason as chapters.\n let volume: number | null = null;\n const volMatch = trimmed.match(/\\bv(?:ol)?\\.?\\s*([0-9]+)[a-z]?(?![0-9])/i);\n if (volMatch?.[1]) {\n const n = Number.parseInt(volMatch[1], 10);\n if (Number.isFinite(n)) volume = n;\n }\n\n // Group: legacy \"by <Group>\" pattern. The current MangaUpdates v1 RSS\n // feed places the scanlation group in `<description>`, not the title;\n // this branch is kept as a fallback so older / legacy feed shapes still\n // surface a group. Captured up to `(` or end-of-string so a trailing\n // `(en)` language tag doesn't bleed into the group name.\n let group: string | null = null;\n const groupMatch = trimmed.match(/\\bby\\s+(.+?)(?:\\s*\\([a-z]{2,3}\\)\\s*)?$/i);\n if (groupMatch?.[1]) {\n const candidate = groupMatch[1].trim();\n if (candidate.length > 0) group = candidate;\n }\n\n // Language: trailing parenthesized 2-3 letter code (e.g. (en), (es), (id), (por)).\n //\n // The current MangaUpdates v1 RSS endpoint (`/v1/series/{id}/rss`) ships\n // titles without a language tag \u2014 it's the English-localized release\n // stream by design. Default to `\"en\"` so items aren't dropped by the\n // client-side language gate; an explicit `(es)` / `(id)` / etc. still\n // wins when present, and the host's per-series language list remains\n // the authoritative gate downstream. The legacy `UNKNOWN_LANGUAGE`\n // sentinel is kept exported for backwards compatibility but no longer\n // produced by this parser.\n let language = \"en\";\n const langMatch = trimmed.match(/\\(([a-z]{2,3})\\)\\s*$/i);\n if (langMatch?.[1]) {\n language = langMatch[1].toLowerCase();\n }\n\n return { chapter, volume, group, language };\n}\n\n// -----------------------------------------------------------------------------\n// Item parsing\n// -----------------------------------------------------------------------------\n\n/**\n * Best-effort `pubDate` -> ISO-8601 conversion. MangaUpdates uses RFC-2822\n * style dates (`Mon, 04 May 2026 02:31:00 GMT`). Falls back to \"now\" on\n * invalid input \u2014 never throws, since one bad pubDate shouldn't drop the\n * whole feed.\n */\nfunction pubDateToIso(raw: string | null): string {\n if (raw) {\n const d = new Date(raw);\n if (!Number.isNaN(d.getTime())) return d.toISOString();\n }\n return new Date().toISOString();\n}\n\n/**\n * Derive a stable external_release_id.\n *\n * Priority:\n * 1. `<guid>` if present (richest legacy format).\n * 2. `<link>` if present (legacy format with per-item links).\n * 3. Deterministic hash of `(title + group + pubDate)` for the current\n * v1 RSS shape, which carries none of the above per-item fields.\n * Including the group in the hash is what lets multiple groups\n * releasing the same chapter (\"c.200\" by Asura, by FLAME-SCANS,\n * by LeviatanScans) hash to distinct IDs and become distinct\n * ledger rows. Same-group same-chapter re-polls collide on the\n * hash and dedupe, which is what the host expects.\n */\nfunction deriveExternalReleaseId(\n guid: string | null,\n link: string | null,\n title: string,\n group: string | null,\n pubDate: string | null,\n): string {\n if (guid && guid.trim().length > 0) return guid.trim();\n if (link && link.trim().length > 0) return link.trim();\n const fallback = `${title}|${group ?? \"\"}|${pubDate ?? \"\"}`;\n let h = 5381;\n for (let i = 0; i < fallback.length; i++) {\n h = ((h << 5) + h + fallback.charCodeAt(i)) | 0;\n }\n return `t:${(h >>> 0).toString(36)}`;\n}\n\n/**\n * Parse a single MangaUpdates `<item>` block into a `ParsedRssItem`. Returns\n * null when the item is unusable:\n * - missing `<title>` (truly malformed), or\n * - title carries neither chapter nor volume (announcements, oneshot\n * stubs, series-name-only entries \u2014 pure inbox noise).\n */\nexport function parseItem(itemXml: string): ParsedRssItem | null {\n const title = extractTagText(itemXml, \"title\");\n if (!title) return null;\n\n const link = extractTagText(itemXml, \"link\");\n const guid = extractTagText(itemXml, \"guid\");\n const pubDate = extractTagText(itemXml, \"pubDate\");\n const description = extractTagText(itemXml, \"description\");\n\n const { chapter, volume, group: groupFromTitle, language } = parseTitle(title);\n if (chapter === null && volume === null) return null;\n\n // The v1 RSS feed places the scanlation group in `<description>`. Prefer\n // it; fall back to the legacy \"by <Group>\" title pattern.\n const descTrimmed = description?.trim();\n const group = descTrimmed && descTrimmed.length > 0 ? descTrimmed : groupFromTitle;\n\n return {\n externalReleaseId: deriveExternalReleaseId(guid, link, title, group, pubDate),\n title,\n chapter,\n volume,\n group,\n language,\n link: link ?? \"\",\n observedAt: pubDateToIso(pubDate),\n };\n}\n\n/** Parsed feed: items plus the channel-level link (if any). */\nexport interface ParsedFeed {\n /** Channel-level `<link>` \u2014 the series page on mangaupdates.com. Used as\n * the `payloadUrl` for releases when no per-item link exists (the v1\n * RSS shape). `null` when the channel block is missing or malformed. */\n channelLink: string | null;\n items: ParsedRssItem[];\n}\n\n/**\n * Parse a full MangaUpdates per-series RSS feed body. Items that fail\n * `parseItem` (missing title, or no chapter/volume) are dropped silently \u2014\n * the feed parser is best-effort tolerant.\n */\nexport function parseFeed(xml: string): ParsedFeed {\n return {\n channelLink: extractChannelLink(xml),\n items: splitItems(xml)\n .map(parseItem)\n .filter((i): i is ParsedRssItem => i !== null),\n };\n}\n\n/**\n * Extract the channel-level `<link>` from a feed. The v1 RSS feed uses\n * `<channel><link>https://...</link></channel>` and that URL is the series\n * page on mangaupdates.com. We prefer the first `<link>` *outside* any\n * `<item>` block so per-item legacy links (which we don't expect at the\n * channel level anyway) can never bleed in.\n */\nfunction extractChannelLink(xml: string): string | null {\n // Strip every <item>...</item> block before searching \u2014 cheap way to\n // scope to the channel header.\n const stripped = xml.replace(/<item\\b[^>]*>[\\s\\S]*?<\\/item>/gi, \"\");\n const link = extractTagText(stripped, \"link\");\n if (!link) return null;\n const trimmed = link.trim();\n return trimmed.length > 0 ? trimmed : null;\n}\n", "/**\n * Filtering: language allowlist + group blocklist.\n *\n * Filters are applied client-side in the plugin (before recording) for two\n * reasons:\n * 1. Keeps the ledger small. Out-of-language items would be dropped by the\n * host anyway via the latest_known_* gate, but writing them to the\n * ledger pollutes the inbox and wastes write IO.\n * 2. Keeps the inbox clean. Users who configure `[\"en\"]` don't want to see\n * Spanish entries hidden behind a state flag \u2014 they want them gone.\n */\n\nimport { type ParsedRssItem, UNKNOWN_LANGUAGE } from \"./parser.js\";\n\n/**\n * Resolved, normalized filter inputs for a single series. Both lists are\n * lowercased + trimmed. Empty `languages` is interpreted as \"no filter\"\n * (everything passes), but the caller is expected to pass at least the\n * server-wide default to avoid that footgun.\n */\nexport interface ResolvedFilters {\n /** Lowercased ISO 639-1 codes; empty = no filter. */\n languages: string[];\n /** Lowercased group names; case-insensitive exact match against `group`. */\n blockedGroups: Set<string>;\n /**\n * Whether to include items whose language couldn't be detected\n * (`UNKNOWN_LANGUAGE` sentinel). Default false \u2014 be conservative.\n */\n includeUnknownLanguage: boolean;\n}\n\n/**\n * Build resolved filters from raw config strings + lists. Centralizes the\n * normalization so the poll handler doesn't have to care about casing or\n * whitespace.\n */\nexport function resolveFilters(input: {\n languages: string[];\n blockedGroups: string[];\n includeUnknownLanguage?: boolean;\n}): ResolvedFilters {\n const languages = dedupePreserveOrder(\n input.languages.map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0),\n );\n const blockedGroups = new Set(\n input.blockedGroups.map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0),\n );\n return {\n languages,\n blockedGroups,\n includeUnknownLanguage: input.includeUnknownLanguage ?? false,\n };\n}\n\n/**\n * Parse a comma-separated string into a clean list (trim, drop empties).\n * Helper for `blockedGroups` which is admin-config typed as a single string.\n */\nexport function parseCommaList(raw: unknown): string[] {\n if (typeof raw !== \"string\") return [];\n return raw\n .split(\",\")\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n}\n\n/**\n * Returns true if the item should be kept.\n *\n * Language filter:\n * - If `languages` is empty \u2192 pass.\n * - Otherwise, item.language must be in the list (case-insensitive).\n * - `unknown` language is rejected unless `includeUnknownLanguage` is true.\n *\n * Group filter:\n * - If `group` is null \u2192 pass (we have nothing to match against).\n * - Otherwise, group must NOT be in `blockedGroups`.\n */\nexport function passesFilters(item: ParsedRssItem, filters: ResolvedFilters): boolean {\n // Language gate.\n if (item.language === UNKNOWN_LANGUAGE) {\n if (!filters.includeUnknownLanguage) return false;\n } else if (filters.languages.length > 0) {\n if (!filters.languages.includes(item.language.toLowerCase())) return false;\n }\n\n // Group blocklist.\n if (item.group !== null && filters.blockedGroups.size > 0) {\n if (filters.blockedGroups.has(item.group.trim().toLowerCase())) return false;\n }\n\n return true;\n}\n\nfunction dedupePreserveOrder(xs: string[]): string[] {\n const seen = new Set<string>();\n const out: string[] = [];\n for (const x of xs) {\n if (!seen.has(x)) {\n seen.add(x);\n out.push(x);\n }\n }\n return out;\n}\n", "{\n \"name\": \"@ashdev/codex-plugin-release-mangaupdates\",\n \"version\": \"1.26.1\",\n \"description\": \"MangaUpdates RSS release-source plugin for Codex - announces new chapter releases for tracked series in user-configured languages\",\n \"main\": \"dist/index.js\",\n \"bin\": \"dist/index.js\",\n \"type\": \"module\",\n \"files\": [\n \"dist\",\n \"README.md\"\n ],\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/AshDevFr/codex.git\",\n \"directory\": \"plugins/release-mangaupdates\"\n },\n \"scripts\": {\n \"build\": \"esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'\",\n \"dev\": \"npm run build -- --watch\",\n \"clean\": \"rm -rf dist\",\n \"start\": \"node dist/index.js\",\n \"lint\": \"biome check .\",\n \"lint:fix\": \"biome check --write .\",\n \"typecheck\": \"tsc --noEmit\",\n \"test\": \"vitest run --passWithNoTests\",\n \"test:watch\": \"vitest\",\n \"prepublishOnly\": \"npm run lint && npm run build\"\n },\n \"keywords\": [\n \"codex\",\n \"plugin\",\n \"mangaupdates\",\n \"release-source\",\n \"manga\"\n ],\n \"author\": \"Codex\",\n \"license\": \"MIT\",\n \"engines\": {\n \"node\": \">=22.0.0\"\n },\n \"dependencies\": {\n \"@ashdev/codex-plugin-sdk\": \"^1.26.1\"\n },\n \"devDependencies\": {\n \"@biomejs/biome\": \"^2.4.4\",\n \"@types/node\": \"^22.0.0\",\n \"esbuild\": \"^0.27.3\",\n \"typescript\": \"^5.9.3\",\n \"vitest\": \"^4.0.18\"\n }\n}\n", "import type { PluginManifest } from \"@ashdev/codex-plugin-sdk\";\nimport packageJson from \"../package.json\" with { type: \"json\" };\n\n/**\n * External-ID source name for MangaUpdates.\n *\n * MangaUpdates IDs are populated by metadata-provider plugins (e.g.\n * MangaBaka cross-references) or pasted manually by the user via the series\n * tracking panel. The release plugin needs the bare source name (no\n * `plugin:` prefix) here to match the host's external-ID filter.\n */\nexport const EXTERNAL_ID_SOURCE_MANGAUPDATES = \"mangaupdates\" as const;\n\nexport const manifest = {\n name: \"release-mangaupdates\",\n displayName: \"MangaUpdates Releases\",\n version: packageJson.version,\n description:\n \"Announces new chapter releases for tracked series via MangaUpdates per-series RSS feeds. Filters by user-configured languages.\",\n author: \"Codex\",\n homepage: \"https://github.com/AshDevFr/codex\",\n protocolVersion: \"1.1\",\n capabilities: {\n releaseSource: {\n kinds: [\"rss-series\"],\n requiresAliases: false,\n requiresExternalIds: [EXTERNAL_ID_SOURCE_MANGAUPDATES],\n canAnnounceChapters: true,\n canAnnounceVolumes: true,\n },\n },\n configSchema: {\n description:\n \"MangaUpdates plugin configuration. Per-series language preferences live on each series' tracking config; the values here are server-wide defaults applied when a series doesn't override them.\",\n fields: [\n {\n key: \"blockedGroups\",\n label: \"Blocked Scanlation Groups\",\n description:\n \"Comma-separated list of scanlation group names to exclude from announcements (case-insensitive, exact match). Per-series overrides may further extend this list.\",\n type: \"string\" as const,\n required: false,\n default: \"\",\n example: \"LowQualityScans,MTL Group\",\n },\n {\n key: \"requestTimeoutMs\",\n label: \"Request Timeout (ms)\",\n description:\n \"How long to wait for a single RSS fetch before giving up. Defaults to 10000 (10 seconds).\",\n type: \"number\" as const,\n required: false,\n default: 10_000,\n },\n ],\n },\n userDescription:\n \"Announces new chapters for series you've tracked, using their MangaUpdates IDs. Filters releases to languages you can read. Notification-only \u2014 Codex does not download anything.\",\n adminSetupInstructions:\n \"1. No config is required to get started \u2014 saving the plugin is enough. The plugin auto-registers a single source row (`MangaUpdates Releases`) in **Settings \u2192 Release tracking** on first start, where you can disable it, change the poll interval, or hit *Poll now*. 2. To get announcements for a series, edit its tracking panel and either paste a `mangaupdates` external ID or let the metadata-refresh path populate it from MangaBaka cross-references. 3. Optional: set `blockedGroups` (CSV, case-insensitive) to filter noisy scanlators server-wide; per-series language preferences live on each series' tracking config and override the server default (`release_tracking.default_languages`). No credentials are needed; MangaUpdates RSS feeds are public.\",\n} as const satisfies PluginManifest & {\n capabilities: { releaseSource: { kinds: [\"rss-series\"] } };\n};\n", "/**\n * MangaUpdates RSS Release-Source Plugin for Codex.\n *\n * Polls per-series RSS feeds at MangaUpdates and announces new chapter /\n * volume releases for tracked series. The plugin is the first writer of\n * `release_ledger` rows in production \u2014 earlier phases build the\n * infrastructure, this one delivers the first real notification feed.\n *\n * Flow per `releases/poll`:\n * 1. Pull tracked-series scope from the host (`releases/list_tracked`).\n * Filtered server-side to series with a `mangaupdates` external ID.\n * 2. For each series, conditional GET the RSS feed.\n * 3. Parse the response into items, then filter by:\n * - per-series language list (admin / per-series config)\n * - admin-configured group blocklist\n * 4. Build `ReleaseCandidate` rows and stream them via\n * `releases/record`. The host's matcher applies the threshold and\n * ledger dedup.\n * 5. Pass the new ETag back via the poll response so the host updates\n * the source row.\n *\n * **Concurrency note:** The plugin host already serializes RPCs per plugin\n * process, so we don't need to throttle internally beyond an in-poll loop\n * that walks tracked series sequentially.\n */\n\nimport {\n createLogger,\n createReleaseSourcePlugin,\n type HostRpcClient,\n HostRpcError,\n type InitializeParams,\n RELEASES_METHODS,\n type ReleaseCandidate,\n type ReleasePollRequest,\n type ReleasePollResponse,\n type TrackedSeriesEntry,\n} from \"@ashdev/codex-plugin-sdk\";\nimport { fetchSeriesFeed } from \"./fetcher.js\";\nimport { parseCommaList, passesFilters, resolveFilters } from \"./filter.js\";\nimport { EXTERNAL_ID_SOURCE_MANGAUPDATES, manifest } from \"./manifest.js\";\nimport { type ParsedRssItem, parseFeed } from \"./parser.js\";\n\nconst logger = createLogger({ name: manifest.name, level: \"info\" });\n\n// =============================================================================\n// Plugin-level state (set during initialize)\n// =============================================================================\n\ninterface PluginState {\n hostRpc: HostRpcClient | null;\n /** Admin-configured group blocklist (lowercased exact match). */\n blockedGroupsCsv: string;\n /** Hard timeout for upstream fetches. */\n requestTimeoutMs: number;\n}\n\nconst state: PluginState = {\n hostRpc: null,\n blockedGroupsCsv: \"\",\n requestTimeoutMs: 10_000,\n};\n\n/** Reset state. Exported for tests; not part of the plugin contract. */\nexport function _resetState(): void {\n state.hostRpc = null;\n state.blockedGroupsCsv = \"\";\n state.requestTimeoutMs = 10_000;\n}\n\n// =============================================================================\n// Reverse-RPC wrappers (typed shorthands so the poll code reads cleanly)\n// =============================================================================\n\ninterface ListTrackedResponse {\n tracked: TrackedSeriesEntry[];\n nextOffset?: number;\n}\n\ninterface RecordResponse {\n ledgerId: string;\n deduped: boolean;\n}\n\ninterface CountTrackedResponse {\n total: number;\n}\n\nasync function listTracked(\n rpc: HostRpcClient,\n sourceId: string,\n offset: number,\n limit: number,\n): Promise<ListTrackedResponse> {\n return rpc.call<ListTrackedResponse>(RELEASES_METHODS.LIST_TRACKED, {\n sourceId,\n offset,\n limit,\n });\n}\n\n/**\n * Total tracked-series denominator for this source, scoped by the\n * plugin's `requires_external_ids` manifest declaration. Returns `null`\n * when the host doesn't know the method (older host build) \u2014 callers\n * fall back to progressive denominator emits in that case.\n */\nasync function countTracked(rpc: HostRpcClient, sourceId: string): Promise<number | null> {\n try {\n const r = await rpc.call<CountTrackedResponse>(RELEASES_METHODS.COUNT_TRACKED, {\n sourceId,\n });\n return r.total;\n } catch (err) {\n if (err instanceof HostRpcError && err.code === -32601) {\n // Host doesn't know `count_tracked` \u2014 older build. Degrade silently.\n return null;\n }\n const reason = err instanceof Error ? err.message : String(err);\n logger.warn(`count_tracked failed for ${sourceId}: ${reason}`);\n return null;\n }\n}\n\n/**\n * Best-effort progress emit. Failures are swallowed \u2014 progress is a\n * UX nice-to-have, never a reason to abort a poll.\n */\nasync function reportProgress(\n rpc: HostRpcClient,\n current: number,\n total: number,\n message?: string,\n): Promise<void> {\n try {\n await rpc.call(\n RELEASES_METHODS.REPORT_PROGRESS,\n message !== undefined ? { current, total, message } : { current, total },\n );\n } catch (err) {\n if (err instanceof HostRpcError && err.code === -32601) {\n // Older host without progress support \u2014 silently drop.\n return;\n }\n const reason = err instanceof Error ? err.message : String(err);\n logger.debug(`report_progress dropped: ${reason}`);\n }\n}\n\nasync function recordCandidate(\n rpc: HostRpcClient,\n sourceId: string,\n candidate: ReleaseCandidate,\n): Promise<RecordResponse | null> {\n try {\n return await rpc.call<RecordResponse>(RELEASES_METHODS.RECORD, {\n sourceId,\n candidate,\n });\n } catch (err) {\n if (err instanceof HostRpcError) {\n // Threshold rejection / validation error / unknown source. Log and\n // skip; the next poll will retry the still-eligible candidates.\n logger.warn(\n `record failed for ${candidate.externalReleaseId}: ${err.message} (code ${err.code})`,\n );\n } else {\n const msg = err instanceof Error ? err.message : \"unknown error\";\n logger.warn(`record failed for ${candidate.externalReleaseId}: ${msg}`);\n }\n return null;\n }\n}\n\n// =============================================================================\n// Iteration helpers\n// =============================================================================\n\n/**\n * Lazily walk all tracked-series pages from the host. Yields entries one\n * series at a time so the caller can interleave per-series fetches without\n * buffering the whole list (relevant for users tracking hundreds of series).\n */\nasync function* iterateTrackedSeries(\n rpc: HostRpcClient,\n sourceId: string,\n): AsyncGenerator<TrackedSeriesEntry> {\n const pageSize = 200;\n let offset = 0;\n while (true) {\n const page = await listTracked(rpc, sourceId, offset, pageSize);\n for (const entry of page.tracked) {\n yield entry;\n }\n if (page.nextOffset === undefined || page.tracked.length === 0) return;\n offset = page.nextOffset;\n }\n}\n\n/**\n * Per-series effective language list. We use the host's `latestKnown*`\n * exposure plus the `externalIds` map to scope the fetch, but the\n * languages config is owned by the host (set on `series_tracking.languages`\n * with fallback to the server-wide default).\n *\n * However, the current `releases/list_tracked` response shape doesn't\n * expose per-series `languages` \u2014 see plan doc for this design choice.\n * Currently the plugin reads its admin-level group blocklist and emits\n * candidates with the language tag from the parsed entry; the host's\n * `latest_known_*` advance gate enforces the per-series language list\n * authoritatively (see `services/release/languages.rs`).\n *\n * We *also* want to drop out-of-language candidates client-side to keep the\n * ledger small and the inbox clean. Without per-series languages on the\n * tracked-series payload, the client-side filter degrades to a no-op\n * pass-everything for known languages \u2014 leaving it to the host's gate. The\n * group blocklist still applies.\n *\n * If a future protocol revision exposes `effectiveLanguages` on the\n * tracked-series entry, swap this stub for the real list and the existing\n * `passesFilters` will do the right thing.\n */\nfunction effectiveLanguagesForSeries(_entry: TrackedSeriesEntry): string[] {\n return []; // empty = no client-side language gate; host gate is authoritative\n}\n\n/**\n * Map a `ParsedRssItem` to a `ReleaseCandidate`. Confidence is 1.0 because\n * the match is keyed by external ID \u2014 there's no fuzzy matching.\n *\n * `payloadUrl` priority: per-item link (legacy feed shape) \u2192 channel-level\n * series page link (current v1 RSS shape) \u2192 last-resort `urn:mu:` URN. The\n * URN fallback should never fire in practice; it exists so a malformed\n * feed without even a channel link doesn't break the host's non-empty\n * `payload_url` invariant.\n */\nfunction toCandidate(\n entry: TrackedSeriesEntry,\n item: ParsedRssItem,\n channelLink: string | null,\n): ReleaseCandidate {\n const payloadUrl =\n item.link.length > 0\n ? item.link\n : channelLink && channelLink.length > 0\n ? channelLink\n : `urn:mu:${item.externalReleaseId}`;\n const candidate: ReleaseCandidate = {\n seriesMatch: {\n codexSeriesId: entry.seriesId,\n confidence: 1.0,\n reason: `mangaupdates_id:${entry.externalIds?.[EXTERNAL_ID_SOURCE_MANGAUPDATES] ?? \"\"}`,\n },\n externalReleaseId: item.externalReleaseId,\n // MangaUpdates always reports a single chapter / volume per release.\n // Wrap as one-element span lists for the new SDK shape; `null` when\n // the parser didn't see a value at all.\n volumes: item.volume === null ? null : [{ start: item.volume, end: item.volume }],\n chapters: item.chapter === null ? null : [{ start: item.chapter, end: item.chapter }],\n language: item.language,\n groupOrUploader: item.group,\n payloadUrl,\n observedAt: item.observedAt,\n };\n return candidate;\n}\n\n// =============================================================================\n// Per-series poll\n// =============================================================================\n\n/** Outcome of a single per-series fetch+record cycle. */\nexport interface SeriesPollOutcome {\n seriesId: string;\n fetched: boolean;\n notModified: boolean;\n parsed: number;\n /** Of those parsed, how many passed client-side filters and were sent to record. */\n matched: number;\n recorded: number;\n /** Of those sent to record, how many the host deduped onto an existing row. */\n deduped: number;\n upstreamStatus: number;\n /** New ETag returned by upstream (only set when fetched=true). */\n etag: string | null;\n /** Error string if the per-series fetch failed; empty otherwise. */\n error: string;\n}\n\n/**\n * Poll a single series. Internal \u2014 exposed for testing.\n *\n * Aggregates the worst (highest) upstream status across the per-series\n * fetches at the call site so the host's per-host backoff layer sees real\n * 429/5xx signals.\n */\nexport async function pollSeries(\n rpc: HostRpcClient,\n sourceId: string,\n entry: TrackedSeriesEntry,\n options: {\n blockedGroups: string[];\n timeoutMs: number;\n fetchImpl?: typeof fetch;\n },\n): Promise<SeriesPollOutcome> {\n const muId = entry.externalIds?.[EXTERNAL_ID_SOURCE_MANGAUPDATES];\n if (!muId) {\n return {\n seriesId: entry.seriesId,\n fetched: false,\n notModified: false,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: 0,\n etag: null,\n error: \"missing mangaupdates external ID\",\n };\n }\n\n // We don't have per-series ETag here \u2014 that lives on the source row, not\n // the series. For a per-source feed (rss-uploader) ETags align cleanly;\n // for per-series feeds (this plugin) we'd need per-(source, series) state\n // to do conditional GETs per series. That's a future optimization; for\n // now we always do an unconditional GET. Daily polls + small per-series\n // bodies keep the bandwidth cost negligible.\n const result = await fetchSeriesFeed(muId, null, {\n fetchImpl: options.fetchImpl,\n timeoutMs: options.timeoutMs,\n });\n\n if (result.kind === \"notModified\") {\n return {\n seriesId: entry.seriesId,\n fetched: true,\n notModified: true,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: 304,\n etag: null,\n error: \"\",\n };\n }\n\n if (result.kind === \"error\") {\n return {\n seriesId: entry.seriesId,\n fetched: false,\n notModified: false,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: result.status,\n etag: null,\n error: result.message,\n };\n }\n\n // result.kind === \"ok\"\n const { items, channelLink } = parseFeed(result.body);\n const filters = resolveFilters({\n languages: effectiveLanguagesForSeries(entry),\n blockedGroups: options.blockedGroups,\n });\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n for (const item of items) {\n if (!passesFilters(item, filters)) continue;\n matched++;\n const candidate = toCandidate(entry, item, channelLink);\n const outcome = await recordCandidate(rpc, sourceId, candidate);\n if (!outcome) continue;\n if (outcome.deduped) {\n deduped++;\n } else {\n recorded++;\n }\n }\n return {\n seriesId: entry.seriesId,\n fetched: true,\n notModified: false,\n parsed: items.length,\n matched,\n recorded,\n deduped,\n upstreamStatus: 200,\n etag: result.etag,\n error: \"\",\n };\n}\n\n// =============================================================================\n// Top-level poll handler\n// =============================================================================\n\n/**\n * Top-level poll handler. Exported for tests (no underscore prefix because\n * it's actually a load-bearing function that just happens to live behind\n * the SDK plugin wrapper at module scope; `_resetState` is the\n * pattern for state-only test seams).\n */\nexport async function poll(\n params: ReleasePollRequest,\n rpc: HostRpcClient,\n): Promise<ReleasePollResponse> {\n const sourceId = params.sourceId;\n const blockedGroups = parseCommaList(state.blockedGroupsCsv);\n\n // Pre-count so progress emits can carry a stable denominator. Falls\n // back to progressive ('N polled' with no total) when the host doesn't\n // implement count_tracked, keeping us forward-compatible.\n const total = await countTracked(rpc, sourceId);\n\n let parsed = 0;\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n let worstStatus = 200;\n let lastEtag: string | null = null;\n let seenSeries = 0;\n // Series the host returned that lack a MangaUpdates external ID. A high\n // count here is the most common cause of an \"empty\" poll: the plugin\n // can't fetch a feed without an MU ID, so the user needs to populate\n // those (manual paste or metadata refresh from MangaBaka).\n let skippedNoMuId = 0;\n\n for await (const entry of iterateTrackedSeries(rpc, sourceId)) {\n seenSeries++;\n const outcome = await pollSeries(rpc, sourceId, entry, {\n blockedGroups,\n timeoutMs: state.requestTimeoutMs,\n });\n parsed += outcome.parsed;\n matched += outcome.matched;\n recorded += outcome.recorded;\n deduped += outcome.deduped;\n if (outcome.upstreamStatus > worstStatus) {\n worstStatus = outcome.upstreamStatus;\n }\n if (outcome.etag) lastEtag = outcome.etag;\n\n if (outcome.error === \"missing mangaupdates external ID\") {\n skippedNoMuId++;\n } else if (outcome.error) {\n logger.warn(`series ${entry.seriesId}: ${outcome.error} (status ${outcome.upstreamStatus})`);\n }\n\n // Progress: rate-limited host-side, so it's OK to fire every iteration.\n // When `total` is unknown, send seenSeries as both current and total\n // so the host emits the message without a useful percentage.\n await reportProgress(\n rpc,\n seenSeries,\n total ?? seenSeries,\n `Polled ${seenSeries}${total !== null ? `/${total}` : \"\"} series`,\n );\n }\n\n if (skippedNoMuId > 0) {\n logger.info(\n `skipped ${skippedNoMuId} of ${seenSeries} tracked series for source=${sourceId}: no mangaupdates external ID. Add one in the Tracking panel or run a metadata refresh.`,\n );\n }\n\n logger.info(\n `poll complete: source=${sourceId} series=${seenSeries} skipped=${skippedNoMuId} parsed=${parsed} matched=${matched} recorded=${recorded} deduped=${deduped} worst_status=${worstStatus}`,\n );\n\n // Report counters back to the host so the source's `last_summary` is\n // accurate. Without these the host only sees the (empty) `candidates`\n // payload \u2014 we record via reverse-RPC mid-poll \u2014 and the badge reads\n // \"Fetched 0 items\" no matter what actually happened.\n // Per-series ETags don't align with the per-source state slot, so we\n // intentionally leave `etag` undefined unless we actually saw one\n // (which today we won't, since we don't pass If-None-Match per series).\n return {\n notModified: false,\n upstreamStatus: worstStatus,\n parsed,\n matched,\n recorded,\n deduped,\n ...(lastEtag !== null ? { etag: lastEtag } : {}),\n };\n}\n\n// =============================================================================\n// Plugin Initialization\n// =============================================================================\n\n/**\n * Register a single static source row representing the MangaUpdates batch\n * feed. Unlike Nyaa (one row per uploader), MangaUpdates polls all tracked\n * series under one logical feed, so we always declare exactly one row keyed\n * `default`. Retries on `METHOD_NOT_FOUND` to handle the brief race where\n * the host has not yet installed the releases reverse-RPC handler.\n */\nexport async function registerSources(\n rpc: HostRpcClient,\n): Promise<{ registered: number; pruned: number } | null> {\n const sources = [\n {\n sourceKey: \"default\",\n displayName: \"MangaUpdates Releases\",\n kind: \"rss-series\" as const,\n config: null,\n },\n ];\n const maxAttempts = 5;\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n return await rpc.call<{ registered: number; pruned: number }>(\n RELEASES_METHODS.REGISTER_SOURCES,\n { sources },\n );\n } catch (err) {\n const isMethodNotFound = err instanceof HostRpcError && err.code === -32601;\n if (isMethodNotFound && attempt < maxAttempts) {\n await new Promise((r) => setTimeout(r, 50 * attempt));\n continue;\n }\n const reason = err instanceof Error ? err.message : String(err);\n logger.error(`register_sources failed: ${reason}`);\n return null;\n }\n }\n return null;\n}\n\ncreateReleaseSourcePlugin({\n manifest,\n provider: {\n async poll(params: ReleasePollRequest): Promise<ReleasePollResponse> {\n if (!state.hostRpc) {\n throw new Error(\"Plugin not initialized: hostRpc client missing\");\n }\n return poll(params, state.hostRpc);\n },\n },\n logLevel: \"info\",\n async onInitialize(params: InitializeParams) {\n state.hostRpc = params.hostRpc;\n const ac = params.adminConfig ?? {};\n if (typeof ac.blockedGroups === \"string\") {\n state.blockedGroupsCsv = ac.blockedGroups;\n }\n if (typeof ac.requestTimeoutMs === \"number\" && Number.isFinite(ac.requestTimeoutMs)) {\n state.requestTimeoutMs = Math.max(1_000, Math.min(ac.requestTimeoutMs, 60_000));\n }\n logger.info(\n `initialized: blockedGroups=${state.blockedGroupsCsv ? \"set\" : \"empty\"} timeoutMs=${state.requestTimeoutMs}`,\n );\n\n // Materialize the single static source row. Deferred to a microtask so\n // we run *after* the host installs the releases reverse-RPC handler.\n queueMicrotask(() => {\n void registerSources(params.hostRpc).then((result) => {\n if (result) {\n logger.info(`register_sources: registered=${result.registered} pruned=${result.pruned}`);\n }\n });\n });\n },\n});\n\nlogger.info(\"MangaUpdates release-source plugin started\");\n"],
4
+ "sourcesContent": [null, null, null, null, null, null, null, null, "/**\n * MangaUpdates per-series RSS fetcher.\n *\n * Wraps `fetch` with conditional GET (`If-None-Match` from a stored ETag) and\n * a hard timeout. Returns a discriminated result so the caller can:\n * - act on `200`: parse the body, persist the new ETag.\n * - skip parse on `304`: nothing changed since last poll.\n * - report `429` / `5xx` upstream-status codes back to the host so the\n * per-host backoff layer can react.\n *\n * Network is the only side effect; nothing in here touches storage, the host,\n * or process state. That keeps it trivially testable: pass a mocked `fetch`\n * implementation and assert.\n */\n\n/** Discriminated fetch result. */\nexport type FetchResult =\n | { kind: \"ok\"; body: string; etag: string | null; status: 200 }\n | { kind: \"notModified\"; status: 304 }\n | { kind: \"error\"; status: number; message: string };\n\nexport interface FetcherOptions {\n /** Custom `fetch` impl (for testing). Defaults to global `fetch`. */\n fetchImpl?: typeof fetch;\n /** Per-request timeout. Defaults to 10s. */\n timeoutMs?: number;\n}\n\n/** Public base URL for MangaUpdates' v1 RSS API. */\nexport const MANGAUPDATES_RSS_BASE = \"https://api.mangaupdates.com/v1/series\";\n\n/**\n * Normalize a MangaUpdates series ID to its numeric form for API calls.\n *\n * MangaUpdates uses two interchangeable representations of the same ID:\n *\n * - **Numeric** (e.g. `15180124327`) \u2014 the internal primary key. Every\n * `/v1/series/...` API endpoint requires this form.\n * - **Base36 slug** (e.g. `6z1uqw7`) \u2014 a base36 encoding of the numeric\n * ID, used in public URLs only (`mangaupdates.com/series/6z1uqw7/...`).\n * The API rejects this form with a 405.\n *\n * Metadata sources (MangaBaka, etc.) typically scrape the public URL and\n * store the slug, so the value we receive on `entry.externalIds.mangaupdates`\n * is whatever the source happened to grab. Decode here so callers don't\n * have to know.\n *\n * Returns the input unchanged when it's already an all-digit string;\n * `null` when the input contains characters outside the base36 alphabet\n * (caller should surface as a configuration error).\n */\nexport function normalizeMangaUpdatesId(raw: string): string | null {\n const trimmed = raw.trim();\n if (trimmed.length === 0) return null;\n if (/^\\d+$/.test(trimmed)) return trimmed;\n if (!/^[0-9a-z]+$/i.test(trimmed)) return null;\n // parseInt('6z1uqw7', 36) = 15180124327. JS numbers are precise for\n // integers up to 2^53; MangaUpdates IDs sit well below that.\n const decoded = Number.parseInt(trimmed, 36);\n if (!Number.isFinite(decoded) || decoded <= 0) return null;\n return String(decoded);\n}\n\n/**\n * Build the per-series RSS URL. Accepts either the numeric ID or the\n * base36 slug \u2014 see `normalizeMangaUpdatesId` for the rationale.\n */\nexport function feedUrl(mangaUpdatesId: string): string {\n const normalized = normalizeMangaUpdatesId(mangaUpdatesId) ?? mangaUpdatesId;\n return `${MANGAUPDATES_RSS_BASE}/${normalized}/rss`;\n}\n\n/**\n * Conditional GET against a per-series RSS feed.\n *\n * @param mangaUpdatesId - The MangaUpdates series ID.\n * @param previousEtag - The ETag from the previous successful poll (if any).\n * @param opts - Fetcher options (custom fetch, timeout).\n */\nexport async function fetchSeriesFeed(\n mangaUpdatesId: string,\n previousEtag: string | null,\n opts: FetcherOptions = {},\n): Promise<FetchResult> {\n const fetchImpl = opts.fetchImpl ?? globalThis.fetch;\n const timeoutMs = opts.timeoutMs ?? 10_000;\n\n const url = feedUrl(mangaUpdatesId);\n const headers: Record<string, string> = {\n Accept: \"application/rss+xml, application/xml;q=0.9, */*;q=0.5\",\n \"User-Agent\": \"Codex-ReleaseTracker/1.0 (+https://github.com/AshDevFr/codex)\",\n };\n if (previousEtag) {\n headers[\"If-None-Match\"] = previousEtag;\n }\n\n // AbortSignal.timeout is the cleanest path. Falling back to a manual\n // controller would add complexity without value (we already require Node\n // 22+).\n const signal = AbortSignal.timeout(timeoutMs);\n\n let resp: Response;\n try {\n resp = await fetchImpl(url, { method: \"GET\", headers, signal });\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"Unknown fetch error\";\n // Treat aborts and other transport-level failures as 0/unavailable so\n // the host's per-host backoff layer can detect \"this domain is sad\n // right now\" without us having to invent a fake HTTP status.\n return { kind: \"error\", status: 0, message: msg };\n }\n\n if (resp.status === 304) {\n return { kind: \"notModified\", status: 304 };\n }\n\n if (resp.status === 200) {\n const body = await resp.text();\n const etag = resp.headers.get(\"etag\");\n return { kind: \"ok\", body, etag, status: 200 };\n }\n\n // Pass through 429 / 5xx so the host's backoff layer sees the real status.\n return {\n kind: \"error\",\n status: resp.status,\n message: `upstream returned ${resp.status} ${resp.statusText}`,\n };\n}\n", "/**\n * RSS parser for MangaUpdates per-series feeds.\n *\n * Per-series feed: `https://api.mangaupdates.com/v1/series/{series_id}/rss`\n *\n * The v1 RSS feed is intentionally sparse:\n * - `<title>` carries `{Series Name} {v.N}? {c.N}` \u2014 chapter and/or volume\n * suffixed with optional letter (`c.113a`, `c.113b` for split chapters)\n * - `<description>` carries the scanlation group name\n * - per-item `<link>`, `<guid>`, `<pubDate>` are NOT present; only the\n * channel-level `<link>` (the series page on mangaupdates.com) exists\n *\n * Items that carry neither chapter nor volume info are dropped \u2014 they're\n * usually announcements (\"oneshot release\", series-name-only entries) and\n * have no place in an inbox.\n *\n * Implementation note: we do NOT pull in a heavy XML parser. The MangaUpdates\n * RSS format is simple, well-formed, and stable. A small targeted regex\n * pipeline avoids a 100kb dependency and CVE surface for marginal benefit.\n */\n\n/** Parsed item, pre-`ReleaseCandidate`. */\nexport interface ParsedRssItem {\n /** Stable per-source ID. Derived from the release URL or guid. */\n externalReleaseId: string;\n /** Original title string. Useful for debugging / fallback. */\n title: string;\n /** Chapter number (decimals supported, e.g. \"47.5\"). */\n chapter: number | null;\n /** Volume number. */\n volume: number | null;\n /**\n * Language tag (lowercased ISO 639-1). Defaults to `\"en\"` when the title\n * doesn't carry an explicit `(xx)` code, since the MangaUpdates v1 RSS\n * endpoint serves the English release stream. The legacy\n * `UNKNOWN_LANGUAGE` sentinel is still exported for callers that want\n * to surface \"no tag detected\" explicitly, but the parser no longer\n * produces it on its own.\n */\n language: string;\n /** Scanlation group name (best-effort; nullable). */\n group: string | null;\n /** Release page URL on MangaUpdates. Used as `payloadUrl`. */\n link: string;\n /** ISO-8601 string. Falls back to \"now\" when pubDate is missing/invalid. */\n observedAt: string;\n}\n\n/** Sentinel returned when the language tag can't be detected. */\nexport const UNKNOWN_LANGUAGE = \"unknown\" as const;\n\n// -----------------------------------------------------------------------------\n// XML helpers\n// -----------------------------------------------------------------------------\n\n/** Strip CDATA wrapper if present, unescape `&amp;` `&lt;` `&gt;` `&quot;`. */\nfunction decodeXmlText(raw: string): string {\n let s = raw.trim();\n const cdataMatch = s.match(/^<!\\[CDATA\\[([\\s\\S]*?)]]>$/);\n if (cdataMatch?.[1] !== undefined) {\n s = cdataMatch[1];\n }\n return s\n .replace(/&amp;/g, \"&\")\n .replace(/&lt;/g, \"<\")\n .replace(/&gt;/g, \">\")\n .replace(/&quot;/g, '\"')\n .replace(/&#39;/g, \"'\")\n .replace(/&apos;/g, \"'\");\n}\n\n/** Pull the first `<tag>` text content from an XML fragment, or null. */\nfunction extractTagText(xml: string, tag: string): string | null {\n const re = new RegExp(`<${tag}[^>]*>([\\\\s\\\\S]*?)</${tag}>`, \"i\");\n const m = xml.match(re);\n if (!m?.[1]) return null;\n return decodeXmlText(m[1]);\n}\n\n/** Pull all `<item>...</item>` blocks from a feed. */\nfunction splitItems(xml: string): string[] {\n const out: string[] = [];\n const re = /<item\\b[^>]*>([\\s\\S]*?)<\\/item>/gi;\n for (;;) {\n const match = re.exec(xml);\n if (match === null) break;\n if (match[1] !== undefined) out.push(match[1]);\n }\n return out;\n}\n\n// -----------------------------------------------------------------------------\n// Title parsing\n// -----------------------------------------------------------------------------\n\n/**\n * Extract chapter/volume/group/language from a MangaUpdates RSS title.\n *\n * Observed shapes:\n * \"Vol.2 c.14 by GroupName (en)\"\n * \"v.2 c.14.5 by GroupName (es)\"\n * \"c.143 by GroupName\" (language missing)\n * \"Vol.15 by GroupName (en)\" (volume-only bundle)\n * \"c.143 (en)\" (no group)\n *\n * Volume tokens: `v.N`, `vol.N`, `Vol.N` (case-insensitive).\n * Chapter tokens: `c.N`, `ch.N`, `Ch.N` (decimals allowed).\n * Group: text between `by ` and the next `(` or end-of-string.\n * Language: trailing `(xx)` two-letter code, lowercased.\n */\nexport function parseTitle(title: string): {\n chapter: number | null;\n volume: number | null;\n group: string | null;\n language: string;\n} {\n const trimmed = title.trim();\n\n // Chapter: c.N or ch.N. Decimals (`47.5`) and letter suffixes (`113a`,\n // `113b` for split chapters) are both supported; the letter suffix is\n // stripped so `c.113a` and `c.113b` map to chapter 113. Letter-suffix\n // variants get distinct externalReleaseIds via the group, so they remain\n // separate ledger rows even though they share an integer. The lookahead\n // (`(?![0-9])`) replaces the older `\\b` so the trailing letter doesn't\n // block the match the way `\\b` does between two word characters.\n let chapter: number | null = null;\n const chMatch = trimmed.match(/\\bc(?:h)?\\.?\\s*([0-9]+(?:\\.[0-9]+)?)[a-z]?(?![0-9])/i);\n if (chMatch?.[1]) {\n const n = Number.parseFloat(chMatch[1]);\n if (Number.isFinite(n)) chapter = n;\n }\n\n // Volume: v.N or vol.N. Letter suffixes accepted and discarded for the\n // same reason as chapters.\n let volume: number | null = null;\n const volMatch = trimmed.match(/\\bv(?:ol)?\\.?\\s*([0-9]+)[a-z]?(?![0-9])/i);\n if (volMatch?.[1]) {\n const n = Number.parseInt(volMatch[1], 10);\n if (Number.isFinite(n)) volume = n;\n }\n\n // Group: legacy \"by <Group>\" pattern. The current MangaUpdates v1 RSS\n // feed places the scanlation group in `<description>`, not the title;\n // this branch is kept as a fallback so older / legacy feed shapes still\n // surface a group. Captured up to `(` or end-of-string so a trailing\n // `(en)` language tag doesn't bleed into the group name.\n let group: string | null = null;\n const groupMatch = trimmed.match(/\\bby\\s+(.+?)(?:\\s*\\([a-z]{2,3}\\)\\s*)?$/i);\n if (groupMatch?.[1]) {\n const candidate = groupMatch[1].trim();\n if (candidate.length > 0) group = candidate;\n }\n\n // Language: trailing parenthesized 2-3 letter code (e.g. (en), (es), (id), (por)).\n //\n // The current MangaUpdates v1 RSS endpoint (`/v1/series/{id}/rss`) ships\n // titles without a language tag \u2014 it's the English-localized release\n // stream by design. Default to `\"en\"` so items aren't dropped by the\n // client-side language gate; an explicit `(es)` / `(id)` / etc. still\n // wins when present, and the host's per-series language list remains\n // the authoritative gate downstream. The legacy `UNKNOWN_LANGUAGE`\n // sentinel is kept exported for backwards compatibility but no longer\n // produced by this parser.\n let language = \"en\";\n const langMatch = trimmed.match(/\\(([a-z]{2,3})\\)\\s*$/i);\n if (langMatch?.[1]) {\n language = langMatch[1].toLowerCase();\n }\n\n return { chapter, volume, group, language };\n}\n\n// -----------------------------------------------------------------------------\n// Item parsing\n// -----------------------------------------------------------------------------\n\n/**\n * Best-effort `pubDate` -> ISO-8601 conversion. MangaUpdates uses RFC-2822\n * style dates (`Mon, 04 May 2026 02:31:00 GMT`). Falls back to \"now\" on\n * invalid input \u2014 never throws, since one bad pubDate shouldn't drop the\n * whole feed.\n */\nfunction pubDateToIso(raw: string | null): string {\n if (raw) {\n const d = new Date(raw);\n if (!Number.isNaN(d.getTime())) return d.toISOString();\n }\n return new Date().toISOString();\n}\n\n/**\n * Derive a stable external_release_id.\n *\n * Priority:\n * 1. `<guid>` if present (richest legacy format).\n * 2. `<link>` if present (legacy format with per-item links).\n * 3. Deterministic hash of `(title + group + pubDate)` for the current\n * v1 RSS shape, which carries none of the above per-item fields.\n * Including the group in the hash is what lets multiple groups\n * releasing the same chapter (\"c.200\" by Asura, by FLAME-SCANS,\n * by LeviatanScans) hash to distinct IDs and become distinct\n * ledger rows. Same-group same-chapter re-polls collide on the\n * hash and dedupe, which is what the host expects.\n */\nfunction deriveExternalReleaseId(\n guid: string | null,\n link: string | null,\n title: string,\n group: string | null,\n pubDate: string | null,\n): string {\n if (guid && guid.trim().length > 0) return guid.trim();\n if (link && link.trim().length > 0) return link.trim();\n const fallback = `${title}|${group ?? \"\"}|${pubDate ?? \"\"}`;\n let h = 5381;\n for (let i = 0; i < fallback.length; i++) {\n h = ((h << 5) + h + fallback.charCodeAt(i)) | 0;\n }\n return `t:${(h >>> 0).toString(36)}`;\n}\n\n/**\n * Parse a single MangaUpdates `<item>` block into a `ParsedRssItem`. Returns\n * null when the item is unusable:\n * - missing `<title>` (truly malformed), or\n * - title carries neither chapter nor volume (announcements, oneshot\n * stubs, series-name-only entries \u2014 pure inbox noise).\n */\nexport function parseItem(itemXml: string): ParsedRssItem | null {\n const title = extractTagText(itemXml, \"title\");\n if (!title) return null;\n\n const link = extractTagText(itemXml, \"link\");\n const guid = extractTagText(itemXml, \"guid\");\n const pubDate = extractTagText(itemXml, \"pubDate\");\n const description = extractTagText(itemXml, \"description\");\n\n const { chapter, volume, group: groupFromTitle, language } = parseTitle(title);\n if (chapter === null && volume === null) return null;\n\n // The v1 RSS feed places the scanlation group in `<description>`. Prefer\n // it; fall back to the legacy \"by <Group>\" title pattern.\n const descTrimmed = description?.trim();\n const group = descTrimmed && descTrimmed.length > 0 ? descTrimmed : groupFromTitle;\n\n return {\n externalReleaseId: deriveExternalReleaseId(guid, link, title, group, pubDate),\n title,\n chapter,\n volume,\n group,\n language,\n link: link ?? \"\",\n observedAt: pubDateToIso(pubDate),\n };\n}\n\n/** Parsed feed: items plus the channel-level link (if any). */\nexport interface ParsedFeed {\n /** Channel-level `<link>` \u2014 the series page on mangaupdates.com. Used as\n * the `payloadUrl` for releases when no per-item link exists (the v1\n * RSS shape). `null` when the channel block is missing or malformed. */\n channelLink: string | null;\n items: ParsedRssItem[];\n}\n\n/**\n * Parse a full MangaUpdates per-series RSS feed body. Items that fail\n * `parseItem` (missing title, or no chapter/volume) are dropped silently \u2014\n * the feed parser is best-effort tolerant.\n */\nexport function parseFeed(xml: string): ParsedFeed {\n return {\n channelLink: extractChannelLink(xml),\n items: splitItems(xml)\n .map(parseItem)\n .filter((i): i is ParsedRssItem => i !== null),\n };\n}\n\n/**\n * Extract the channel-level `<link>` from a feed. The v1 RSS feed uses\n * `<channel><link>https://...</link></channel>` and that URL is the series\n * page on mangaupdates.com. We prefer the first `<link>` *outside* any\n * `<item>` block so per-item legacy links (which we don't expect at the\n * channel level anyway) can never bleed in.\n */\nfunction extractChannelLink(xml: string): string | null {\n // Strip every <item>...</item> block before searching \u2014 cheap way to\n // scope to the channel header.\n const stripped = xml.replace(/<item\\b[^>]*>[\\s\\S]*?<\\/item>/gi, \"\");\n const link = extractTagText(stripped, \"link\");\n if (!link) return null;\n const trimmed = link.trim();\n return trimmed.length > 0 ? trimmed : null;\n}\n", "/**\n * Filtering: language allowlist + group blocklist.\n *\n * Filters are applied client-side in the plugin (before recording) for two\n * reasons:\n * 1. Keeps the ledger small. Out-of-language items would be dropped by the\n * host anyway via the latest_known_* gate, but writing them to the\n * ledger pollutes the inbox and wastes write IO.\n * 2. Keeps the inbox clean. Users who configure `[\"en\"]` don't want to see\n * Spanish entries hidden behind a state flag \u2014 they want them gone.\n */\n\nimport { type ParsedRssItem, UNKNOWN_LANGUAGE } from \"./parser.js\";\n\n/**\n * Resolved, normalized filter inputs for a single series. Both lists are\n * lowercased + trimmed. Empty `languages` is interpreted as \"no filter\"\n * (everything passes), but the caller is expected to pass at least the\n * server-wide default to avoid that footgun.\n */\nexport interface ResolvedFilters {\n /** Lowercased ISO 639-1 codes; empty = no filter. */\n languages: string[];\n /** Lowercased group names; case-insensitive exact match against `group`. */\n blockedGroups: Set<string>;\n /**\n * Whether to include items whose language couldn't be detected\n * (`UNKNOWN_LANGUAGE` sentinel). Default false \u2014 be conservative.\n */\n includeUnknownLanguage: boolean;\n}\n\n/**\n * Build resolved filters from raw config strings + lists. Centralizes the\n * normalization so the poll handler doesn't have to care about casing or\n * whitespace.\n */\nexport function resolveFilters(input: {\n languages: string[];\n blockedGroups: string[];\n includeUnknownLanguage?: boolean;\n}): ResolvedFilters {\n const languages = dedupePreserveOrder(\n input.languages.map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0),\n );\n const blockedGroups = new Set(\n input.blockedGroups.map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0),\n );\n return {\n languages,\n blockedGroups,\n includeUnknownLanguage: input.includeUnknownLanguage ?? false,\n };\n}\n\n/**\n * Parse a comma-separated string into a clean list (trim, drop empties).\n * Helper for `blockedGroups` which is admin-config typed as a single string.\n */\nexport function parseCommaList(raw: unknown): string[] {\n if (typeof raw !== \"string\") return [];\n return raw\n .split(\",\")\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n}\n\n/**\n * Returns true if the item should be kept.\n *\n * Language filter:\n * - If `languages` is empty \u2192 pass.\n * - Otherwise, item.language must be in the list (case-insensitive).\n * - `unknown` language is rejected unless `includeUnknownLanguage` is true.\n *\n * Group filter:\n * - If `group` is null \u2192 pass (we have nothing to match against).\n * - Otherwise, group must NOT be in `blockedGroups`.\n */\nexport function passesFilters(item: ParsedRssItem, filters: ResolvedFilters): boolean {\n // Language gate.\n if (item.language === UNKNOWN_LANGUAGE) {\n if (!filters.includeUnknownLanguage) return false;\n } else if (filters.languages.length > 0) {\n if (!filters.languages.includes(item.language.toLowerCase())) return false;\n }\n\n // Group blocklist.\n if (item.group !== null && filters.blockedGroups.size > 0) {\n if (filters.blockedGroups.has(item.group.trim().toLowerCase())) return false;\n }\n\n return true;\n}\n\nfunction dedupePreserveOrder(xs: string[]): string[] {\n const seen = new Set<string>();\n const out: string[] = [];\n for (const x of xs) {\n if (!seen.has(x)) {\n seen.add(x);\n out.push(x);\n }\n }\n return out;\n}\n", "{\n \"name\": \"@ashdev/codex-plugin-release-mangaupdates\",\n \"version\": \"1.27.1\",\n \"description\": \"MangaUpdates RSS release-source plugin for Codex - announces new chapter releases for tracked series in user-configured languages\",\n \"main\": \"dist/index.js\",\n \"bin\": \"dist/index.js\",\n \"type\": \"module\",\n \"files\": [\n \"dist\",\n \"README.md\"\n ],\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/AshDevFr/codex.git\",\n \"directory\": \"plugins/release-mangaupdates\"\n },\n \"scripts\": {\n \"build\": \"esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'\",\n \"dev\": \"npm run build -- --watch\",\n \"clean\": \"rm -rf dist\",\n \"start\": \"node dist/index.js\",\n \"lint\": \"biome check .\",\n \"lint:fix\": \"biome check --write .\",\n \"typecheck\": \"tsc --noEmit\",\n \"test\": \"vitest run --passWithNoTests\",\n \"test:watch\": \"vitest\",\n \"prepublishOnly\": \"npm run lint && npm run build\"\n },\n \"keywords\": [\n \"codex\",\n \"plugin\",\n \"mangaupdates\",\n \"release-source\",\n \"manga\"\n ],\n \"author\": \"Codex\",\n \"license\": \"MIT\",\n \"engines\": {\n \"node\": \">=22.0.0\"\n },\n \"dependencies\": {\n \"@ashdev/codex-plugin-sdk\": \"^1.27.1\"\n },\n \"devDependencies\": {\n \"@biomejs/biome\": \"^2.4.4\",\n \"@types/node\": \"^22.0.0\",\n \"esbuild\": \"^0.27.3\",\n \"typescript\": \"^5.9.3\",\n \"vitest\": \"^4.0.18\"\n }\n}\n", "import type { PluginManifest } from \"@ashdev/codex-plugin-sdk\";\nimport packageJson from \"../package.json\" with { type: \"json\" };\n\n/**\n * External-ID source name for MangaUpdates.\n *\n * MangaUpdates IDs are populated by metadata-provider plugins (e.g.\n * MangaBaka cross-references) or pasted manually by the user via the series\n * tracking panel. The release plugin needs the bare source name (no\n * `plugin:` prefix) here to match the host's external-ID filter.\n */\nexport const EXTERNAL_ID_SOURCE_MANGAUPDATES = \"mangaupdates\" as const;\n\nexport const manifest = {\n name: \"release-mangaupdates\",\n displayName: \"MangaUpdates Releases\",\n version: packageJson.version,\n description:\n \"Announces new chapter releases for tracked series via MangaUpdates per-series RSS feeds. Filters by user-configured languages.\",\n author: \"Codex\",\n homepage: \"https://github.com/AshDevFr/codex\",\n protocolVersion: \"1.1\",\n capabilities: {\n releaseSource: {\n kinds: [\"rss-series\"],\n requiresAliases: false,\n requiresExternalIds: [EXTERNAL_ID_SOURCE_MANGAUPDATES],\n canAnnounceChapters: true,\n canAnnounceVolumes: true,\n },\n },\n configSchema: {\n description:\n \"MangaUpdates plugin configuration. Per-series language preferences live on each series' tracking config; the values here are server-wide defaults applied when a series doesn't override them.\",\n fields: [\n {\n key: \"blockedGroups\",\n label: \"Blocked Scanlation Groups\",\n description:\n \"Comma-separated list of scanlation group names to exclude from announcements (case-insensitive, exact match). Per-series overrides may further extend this list.\",\n type: \"string\" as const,\n required: false,\n default: \"\",\n example: \"LowQualityScans,MTL Group\",\n },\n {\n key: \"requestTimeoutMs\",\n label: \"Request Timeout (ms)\",\n description:\n \"How long to wait for a single RSS fetch before giving up. Defaults to 10000 (10 seconds).\",\n type: \"number\" as const,\n required: false,\n default: 10_000,\n },\n ],\n },\n userDescription:\n \"Announces new chapters for series you've tracked, using their MangaUpdates IDs. Filters releases to languages you can read. Notification-only \u2014 Codex does not download anything.\",\n adminSetupInstructions:\n \"1. No config is required to get started \u2014 saving the plugin is enough. The plugin auto-registers a single source row (`MangaUpdates Releases`) in **Settings \u2192 Release tracking** on first start, where you can disable it, change the poll interval, or hit *Poll now*. 2. To get announcements for a series, edit its tracking panel and either paste a `mangaupdates` external ID or let the metadata-refresh path populate it from MangaBaka cross-references. 3. Optional: set `blockedGroups` (CSV, case-insensitive) to filter noisy scanlators server-wide; per-series language preferences live on each series' tracking config and override the server default (`release_tracking.default_languages`). No credentials are needed; MangaUpdates RSS feeds are public.\",\n} as const satisfies PluginManifest & {\n capabilities: { releaseSource: { kinds: [\"rss-series\"] } };\n};\n", "/**\n * MangaUpdates RSS Release-Source Plugin for Codex.\n *\n * Polls per-series RSS feeds at MangaUpdates and announces new chapter /\n * volume releases for tracked series. The plugin is the first writer of\n * `release_ledger` rows in production \u2014 earlier phases build the\n * infrastructure, this one delivers the first real notification feed.\n *\n * Flow per `releases/poll`:\n * 1. Pull tracked-series scope from the host (`releases/list_tracked`).\n * Filtered server-side to series with a `mangaupdates` external ID.\n * 2. For each series, conditional GET the RSS feed.\n * 3. Parse the response into items, then filter by:\n * - per-series language list (admin / per-series config)\n * - admin-configured group blocklist\n * 4. Build `ReleaseCandidate` rows and stream them via\n * `releases/record`. The host's matcher applies the threshold and\n * ledger dedup.\n * 5. Pass the new ETag back via the poll response so the host updates\n * the source row.\n *\n * **Concurrency note:** The plugin host already serializes RPCs per plugin\n * process, so we don't need to throttle internally beyond an in-poll loop\n * that walks tracked series sequentially.\n */\n\nimport {\n createLogger,\n createReleaseSourcePlugin,\n type HostRpcClient,\n HostRpcError,\n type InitializeParams,\n RELEASES_METHODS,\n type ReleaseCandidate,\n type ReleasePollRequest,\n type ReleasePollResponse,\n type TrackedSeriesEntry,\n} from \"@ashdev/codex-plugin-sdk\";\nimport { fetchSeriesFeed } from \"./fetcher.js\";\nimport { parseCommaList, passesFilters, resolveFilters } from \"./filter.js\";\nimport { EXTERNAL_ID_SOURCE_MANGAUPDATES, manifest } from \"./manifest.js\";\nimport { type ParsedRssItem, parseFeed } from \"./parser.js\";\n\nconst logger = createLogger({ name: manifest.name, level: \"info\" });\n\n// =============================================================================\n// Plugin-level state (set during initialize)\n// =============================================================================\n\ninterface PluginState {\n hostRpc: HostRpcClient | null;\n /** Admin-configured group blocklist (lowercased exact match). */\n blockedGroupsCsv: string;\n /** Hard timeout for upstream fetches. */\n requestTimeoutMs: number;\n}\n\nconst state: PluginState = {\n hostRpc: null,\n blockedGroupsCsv: \"\",\n requestTimeoutMs: 10_000,\n};\n\n/** Reset state. Exported for tests; not part of the plugin contract. */\nexport function _resetState(): void {\n state.hostRpc = null;\n state.blockedGroupsCsv = \"\";\n state.requestTimeoutMs = 10_000;\n}\n\n// =============================================================================\n// Reverse-RPC wrappers (typed shorthands so the poll code reads cleanly)\n// =============================================================================\n\ninterface ListTrackedResponse {\n tracked: TrackedSeriesEntry[];\n nextOffset?: number;\n}\n\ninterface RecordResponse {\n ledgerId: string;\n deduped: boolean;\n}\n\ninterface CountTrackedResponse {\n total: number;\n}\n\nasync function listTracked(\n rpc: HostRpcClient,\n sourceId: string,\n offset: number,\n limit: number,\n): Promise<ListTrackedResponse> {\n return rpc.call<ListTrackedResponse>(RELEASES_METHODS.LIST_TRACKED, {\n sourceId,\n offset,\n limit,\n });\n}\n\n/**\n * Total tracked-series denominator for this source, scoped by the\n * plugin's `requires_external_ids` manifest declaration. Returns `null`\n * when the host doesn't know the method (older host build) \u2014 callers\n * fall back to progressive denominator emits in that case.\n */\nasync function countTracked(rpc: HostRpcClient, sourceId: string): Promise<number | null> {\n try {\n const r = await rpc.call<CountTrackedResponse>(RELEASES_METHODS.COUNT_TRACKED, {\n sourceId,\n });\n return r.total;\n } catch (err) {\n if (err instanceof HostRpcError && err.code === -32601) {\n // Host doesn't know `count_tracked` \u2014 older build. Degrade silently.\n return null;\n }\n const reason = err instanceof Error ? err.message : String(err);\n logger.warn(`count_tracked failed for ${sourceId}: ${reason}`);\n return null;\n }\n}\n\n/**\n * Best-effort progress emit. Failures are swallowed \u2014 progress is a\n * UX nice-to-have, never a reason to abort a poll.\n */\nasync function reportProgress(\n rpc: HostRpcClient,\n current: number,\n total: number,\n message?: string,\n): Promise<void> {\n try {\n await rpc.call(\n RELEASES_METHODS.REPORT_PROGRESS,\n message !== undefined ? { current, total, message } : { current, total },\n );\n } catch (err) {\n if (err instanceof HostRpcError && err.code === -32601) {\n // Older host without progress support \u2014 silently drop.\n return;\n }\n const reason = err instanceof Error ? err.message : String(err);\n logger.debug(`report_progress dropped: ${reason}`);\n }\n}\n\nasync function recordCandidate(\n rpc: HostRpcClient,\n sourceId: string,\n candidate: ReleaseCandidate,\n): Promise<RecordResponse | null> {\n try {\n return await rpc.call<RecordResponse>(RELEASES_METHODS.RECORD, {\n sourceId,\n candidate,\n });\n } catch (err) {\n if (err instanceof HostRpcError) {\n // Threshold rejection / validation error / unknown source. Log and\n // skip; the next poll will retry the still-eligible candidates.\n logger.warn(\n `record failed for ${candidate.externalReleaseId}: ${err.message} (code ${err.code})`,\n );\n } else {\n const msg = err instanceof Error ? err.message : \"unknown error\";\n logger.warn(`record failed for ${candidate.externalReleaseId}: ${msg}`);\n }\n return null;\n }\n}\n\n// =============================================================================\n// Iteration helpers\n// =============================================================================\n\n/**\n * Lazily walk all tracked-series pages from the host. Yields entries one\n * series at a time so the caller can interleave per-series fetches without\n * buffering the whole list (relevant for users tracking hundreds of series).\n */\nasync function* iterateTrackedSeries(\n rpc: HostRpcClient,\n sourceId: string,\n): AsyncGenerator<TrackedSeriesEntry> {\n const pageSize = 200;\n let offset = 0;\n while (true) {\n const page = await listTracked(rpc, sourceId, offset, pageSize);\n for (const entry of page.tracked) {\n yield entry;\n }\n if (page.nextOffset === undefined || page.tracked.length === 0) return;\n offset = page.nextOffset;\n }\n}\n\n/**\n * Per-series effective language list. We use the host's `latestKnown*`\n * exposure plus the `externalIds` map to scope the fetch, but the\n * languages config is owned by the host (set on `series_tracking.languages`\n * with fallback to the server-wide default).\n *\n * However, the current `releases/list_tracked` response shape doesn't\n * expose per-series `languages` \u2014 see plan doc for this design choice.\n * Currently the plugin reads its admin-level group blocklist and emits\n * candidates with the language tag from the parsed entry; the host's\n * `latest_known_*` advance gate enforces the per-series language list\n * authoritatively (see `services/release/languages.rs`).\n *\n * We *also* want to drop out-of-language candidates client-side to keep the\n * ledger small and the inbox clean. Without per-series languages on the\n * tracked-series payload, the client-side filter degrades to a no-op\n * pass-everything for known languages \u2014 leaving it to the host's gate. The\n * group blocklist still applies.\n *\n * If a future protocol revision exposes `effectiveLanguages` on the\n * tracked-series entry, swap this stub for the real list and the existing\n * `passesFilters` will do the right thing.\n */\nfunction effectiveLanguagesForSeries(_entry: TrackedSeriesEntry): string[] {\n return []; // empty = no client-side language gate; host gate is authoritative\n}\n\n/**\n * Map a `ParsedRssItem` to a `ReleaseCandidate`. Confidence is 1.0 because\n * the match is keyed by external ID \u2014 there's no fuzzy matching.\n *\n * `payloadUrl` priority: per-item link (legacy feed shape) \u2192 channel-level\n * series page link (current v1 RSS shape) \u2192 last-resort `urn:mu:` URN. The\n * URN fallback should never fire in practice; it exists so a malformed\n * feed without even a channel link doesn't break the host's non-empty\n * `payload_url` invariant.\n */\nfunction toCandidate(\n entry: TrackedSeriesEntry,\n item: ParsedRssItem,\n channelLink: string | null,\n): ReleaseCandidate {\n const payloadUrl =\n item.link.length > 0\n ? item.link\n : channelLink && channelLink.length > 0\n ? channelLink\n : `urn:mu:${item.externalReleaseId}`;\n const candidate: ReleaseCandidate = {\n seriesMatch: {\n codexSeriesId: entry.seriesId,\n confidence: 1.0,\n reason: `mangaupdates_id:${entry.externalIds?.[EXTERNAL_ID_SOURCE_MANGAUPDATES] ?? \"\"}`,\n },\n externalReleaseId: item.externalReleaseId,\n // MangaUpdates always reports a single chapter / volume per release.\n // Wrap as one-element span lists for the new SDK shape; `null` when\n // the parser didn't see a value at all.\n volumes: item.volume === null ? null : [{ start: item.volume, end: item.volume }],\n chapters: item.chapter === null ? null : [{ start: item.chapter, end: item.chapter }],\n language: item.language,\n groupOrUploader: item.group,\n payloadUrl,\n observedAt: item.observedAt,\n };\n return candidate;\n}\n\n// =============================================================================\n// Per-series poll\n// =============================================================================\n\n/** Outcome of a single per-series fetch+record cycle. */\nexport interface SeriesPollOutcome {\n seriesId: string;\n fetched: boolean;\n notModified: boolean;\n parsed: number;\n /** Of those parsed, how many passed client-side filters and were sent to record. */\n matched: number;\n recorded: number;\n /** Of those sent to record, how many the host deduped onto an existing row. */\n deduped: number;\n upstreamStatus: number;\n /** New ETag returned by upstream (only set when fetched=true). */\n etag: string | null;\n /** Error string if the per-series fetch failed; empty otherwise. */\n error: string;\n}\n\n/**\n * Poll a single series. Internal \u2014 exposed for testing.\n *\n * Aggregates the worst (highest) upstream status across the per-series\n * fetches at the call site so the host's per-host backoff layer sees real\n * 429/5xx signals.\n */\nexport async function pollSeries(\n rpc: HostRpcClient,\n sourceId: string,\n entry: TrackedSeriesEntry,\n options: {\n blockedGroups: string[];\n timeoutMs: number;\n fetchImpl?: typeof fetch;\n },\n): Promise<SeriesPollOutcome> {\n const muId = entry.externalIds?.[EXTERNAL_ID_SOURCE_MANGAUPDATES];\n if (!muId) {\n return {\n seriesId: entry.seriesId,\n fetched: false,\n notModified: false,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: 0,\n etag: null,\n error: \"missing mangaupdates external ID\",\n };\n }\n\n // We don't have per-series ETag here \u2014 that lives on the source row, not\n // the series. For a per-source feed (rss-uploader) ETags align cleanly;\n // for per-series feeds (this plugin) we'd need per-(source, series) state\n // to do conditional GETs per series. That's a future optimization; for\n // now we always do an unconditional GET. Daily polls + small per-series\n // bodies keep the bandwidth cost negligible.\n const result = await fetchSeriesFeed(muId, null, {\n fetchImpl: options.fetchImpl,\n timeoutMs: options.timeoutMs,\n });\n\n if (result.kind === \"notModified\") {\n return {\n seriesId: entry.seriesId,\n fetched: true,\n notModified: true,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: 304,\n etag: null,\n error: \"\",\n };\n }\n\n if (result.kind === \"error\") {\n return {\n seriesId: entry.seriesId,\n fetched: false,\n notModified: false,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: result.status,\n etag: null,\n error: result.message,\n };\n }\n\n // result.kind === \"ok\"\n const { items, channelLink } = parseFeed(result.body);\n const filters = resolveFilters({\n languages: effectiveLanguagesForSeries(entry),\n blockedGroups: options.blockedGroups,\n });\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n for (const item of items) {\n if (!passesFilters(item, filters)) continue;\n matched++;\n const candidate = toCandidate(entry, item, channelLink);\n const outcome = await recordCandidate(rpc, sourceId, candidate);\n if (!outcome) continue;\n if (outcome.deduped) {\n deduped++;\n } else {\n recorded++;\n }\n }\n return {\n seriesId: entry.seriesId,\n fetched: true,\n notModified: false,\n parsed: items.length,\n matched,\n recorded,\n deduped,\n upstreamStatus: 200,\n etag: result.etag,\n error: \"\",\n };\n}\n\n// =============================================================================\n// Top-level poll handler\n// =============================================================================\n\n/**\n * Top-level poll handler. Exported for tests (no underscore prefix because\n * it's actually a load-bearing function that just happens to live behind\n * the SDK plugin wrapper at module scope; `_resetState` is the\n * pattern for state-only test seams).\n */\nexport async function poll(\n params: ReleasePollRequest,\n rpc: HostRpcClient,\n): Promise<ReleasePollResponse> {\n const sourceId = params.sourceId;\n const blockedGroups = parseCommaList(state.blockedGroupsCsv);\n\n // Pre-count so progress emits can carry a stable denominator. Falls\n // back to progressive ('N polled' with no total) when the host doesn't\n // implement count_tracked, keeping us forward-compatible.\n const total = await countTracked(rpc, sourceId);\n\n let parsed = 0;\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n let worstStatus = 200;\n let lastEtag: string | null = null;\n let seenSeries = 0;\n // Series the host returned that lack a MangaUpdates external ID. A high\n // count here is the most common cause of an \"empty\" poll: the plugin\n // can't fetch a feed without an MU ID, so the user needs to populate\n // those (manual paste or metadata refresh from MangaBaka).\n let skippedNoMuId = 0;\n\n for await (const entry of iterateTrackedSeries(rpc, sourceId)) {\n seenSeries++;\n const outcome = await pollSeries(rpc, sourceId, entry, {\n blockedGroups,\n timeoutMs: state.requestTimeoutMs,\n });\n parsed += outcome.parsed;\n matched += outcome.matched;\n recorded += outcome.recorded;\n deduped += outcome.deduped;\n if (outcome.upstreamStatus > worstStatus) {\n worstStatus = outcome.upstreamStatus;\n }\n if (outcome.etag) lastEtag = outcome.etag;\n\n if (outcome.error === \"missing mangaupdates external ID\") {\n skippedNoMuId++;\n } else if (outcome.error) {\n logger.warn(`series ${entry.seriesId}: ${outcome.error} (status ${outcome.upstreamStatus})`);\n }\n\n // Progress: rate-limited host-side, so it's OK to fire every iteration.\n // When `total` is unknown, send seenSeries as both current and total\n // so the host emits the message without a useful percentage.\n await reportProgress(\n rpc,\n seenSeries,\n total ?? seenSeries,\n `Polled ${seenSeries}${total !== null ? `/${total}` : \"\"} series`,\n );\n }\n\n if (skippedNoMuId > 0) {\n logger.info(\n `skipped ${skippedNoMuId} of ${seenSeries} tracked series for source=${sourceId}: no mangaupdates external ID. Add one in the Tracking panel or run a metadata refresh.`,\n );\n }\n\n logger.info(\n `poll complete: source=${sourceId} series=${seenSeries} skipped=${skippedNoMuId} parsed=${parsed} matched=${matched} recorded=${recorded} deduped=${deduped} worst_status=${worstStatus}`,\n );\n\n // Report counters back to the host so the source's `last_summary` is\n // accurate. Without these the host only sees the (empty) `candidates`\n // payload \u2014 we record via reverse-RPC mid-poll \u2014 and the badge reads\n // \"Fetched 0 items\" no matter what actually happened.\n // Per-series ETags don't align with the per-source state slot, so we\n // intentionally leave `etag` undefined unless we actually saw one\n // (which today we won't, since we don't pass If-None-Match per series).\n return {\n notModified: false,\n upstreamStatus: worstStatus,\n parsed,\n matched,\n recorded,\n deduped,\n ...(lastEtag !== null ? { etag: lastEtag } : {}),\n };\n}\n\n// =============================================================================\n// Plugin Initialization\n// =============================================================================\n\n/**\n * Register a single static source row representing the MangaUpdates batch\n * feed. Unlike Nyaa (one row per uploader), MangaUpdates polls all tracked\n * series under one logical feed, so we always declare exactly one row keyed\n * `default`. Retries on `METHOD_NOT_FOUND` to handle the brief race where\n * the host has not yet installed the releases reverse-RPC handler.\n */\nexport async function registerSources(\n rpc: HostRpcClient,\n): Promise<{ registered: number; pruned: number } | null> {\n const sources = [\n {\n sourceKey: \"default\",\n displayName: \"MangaUpdates Releases\",\n kind: \"rss-series\" as const,\n config: null,\n },\n ];\n const maxAttempts = 5;\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n return await rpc.call<{ registered: number; pruned: number }>(\n RELEASES_METHODS.REGISTER_SOURCES,\n { sources },\n );\n } catch (err) {\n const isMethodNotFound = err instanceof HostRpcError && err.code === -32601;\n if (isMethodNotFound && attempt < maxAttempts) {\n await new Promise((r) => setTimeout(r, 50 * attempt));\n continue;\n }\n const reason = err instanceof Error ? err.message : String(err);\n logger.error(`register_sources failed: ${reason}`);\n return null;\n }\n }\n return null;\n}\n\ncreateReleaseSourcePlugin({\n manifest,\n provider: {\n async poll(params: ReleasePollRequest): Promise<ReleasePollResponse> {\n if (!state.hostRpc) {\n throw new Error(\"Plugin not initialized: hostRpc client missing\");\n }\n return poll(params, state.hostRpc);\n },\n },\n logLevel: \"info\",\n async onInitialize(params: InitializeParams) {\n state.hostRpc = params.hostRpc;\n const ac = params.adminConfig ?? {};\n if (typeof ac.blockedGroups === \"string\") {\n state.blockedGroupsCsv = ac.blockedGroups;\n }\n if (typeof ac.requestTimeoutMs === \"number\" && Number.isFinite(ac.requestTimeoutMs)) {\n state.requestTimeoutMs = Math.max(1_000, Math.min(ac.requestTimeoutMs, 60_000));\n }\n logger.info(\n `initialized: blockedGroups=${state.blockedGroupsCsv ? \"set\" : \"empty\"} timeoutMs=${state.requestTimeoutMs}`,\n );\n\n // Materialize the single static source row. Deferred to a microtask so\n // we run *after* the host installs the releases reverse-RPC handler.\n queueMicrotask(() => {\n void registerSources(params.hostRpc).then((result) => {\n if (result) {\n logger.info(`register_sources: registered=${result.registered} pruned=${result.pruned}`);\n }\n });\n });\n },\n});\n\nlogger.info(\"MangaUpdates release-source plugin started\");\n"],
5
5
  "mappings": "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",
6
6
  "names": ["manifest", "logger", "response", "manifest"]
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashdev/codex-plugin-release-mangaupdates",
3
- "version": "1.26.1",
3
+ "version": "1.27.1",
4
4
  "description": "MangaUpdates RSS release-source plugin for Codex - announces new chapter releases for tracked series in user-configured languages",
5
5
  "main": "dist/index.js",
6
6
  "bin": "dist/index.js",
@@ -39,7 +39,7 @@
39
39
  "node": ">=22.0.0"
40
40
  },
41
41
  "dependencies": {
42
- "@ashdev/codex-plugin-sdk": "^1.26.1"
42
+ "@ashdev/codex-plugin-sdk": "^1.27.1"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@biomejs/biome": "^2.4.4",