@ashdev/codex-plugin-release-tsundoku 1.38.3 → 1.38.4
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 +2 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -703,7 +703,7 @@ function isFeedResponse(value) {
|
|
|
703
703
|
// package.json
|
|
704
704
|
var package_default = {
|
|
705
705
|
name: "@ashdev/codex-plugin-release-tsundoku",
|
|
706
|
-
version: "1.38.
|
|
706
|
+
version: "1.38.4",
|
|
707
707
|
description: "Tsundoku release-source plugin for Codex - announces new volume/chapter coverage for tracked series via the Tsundoku incremental series feed, matched by exact external IDs",
|
|
708
708
|
main: "dist/index.js",
|
|
709
709
|
bin: "dist/index.js",
|
|
@@ -742,7 +742,7 @@ var package_default = {
|
|
|
742
742
|
node: ">=22.0.0"
|
|
743
743
|
},
|
|
744
744
|
dependencies: {
|
|
745
|
-
"@ashdev/codex-plugin-sdk": "^1.38.
|
|
745
|
+
"@ashdev/codex-plugin-sdk": "^1.38.4"
|
|
746
746
|
},
|
|
747
747
|
devDependencies: {
|
|
748
748
|
"@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/candidate.ts", "../src/fetcher.ts", "../package.json", "../src/manifest.ts", "../src/matcher.ts", "../src/index.ts"],
|
|
4
|
-
"sourcesContent": [null, null, null, null, null, null, null, null, "/**\n * Map a matched Tsundoku feed item to a Codex `ReleaseCandidate`.\n *\n * The feed already carries merged, gap-preserving coverage spans that line up\n * with Codex's `NumericSpan` model, so the volume/chapter axes pass through\n * verbatim. The candidate's `externalReleaseId` is keyed on the coverage\n * high-water mark, so a new ledger row (and announcement) fires only when the\n * frontier advances \u2014 re-delivery of the same coverage dedups host-side, and\n * the host's auto-ignore + `latest_known_*` gate handle \"already owned\".\n */\n\nimport type { ReleaseCandidate } from \"@ashdev/codex-plugin-sdk\";\nimport type { FeedCoverageSpan, FeedItem } from \"./fetcher.js\";\nimport type { MatchResult } from \"./matcher.js\";\n\n// `FeedCoverageSpan` is structurally identical to the SDK's `NumericSpan`\n// (`{ start, end }`), so a span list assigns directly to a candidate's\n// `volumes` / `chapters` without a separate type or an SDK barrel export.\n\n/** Inputs the candidate mapping needs beyond the feed item + match. */\nexport interface CandidateOptions {\n /** Tsundoku base URL (trailing slash tolerated) for building the landing link. */\n baseUrl: string;\n /** ISO 639-1 language stamped on the candidate (the feed carries none). */\n language: string;\n /** Detection timestamp (ISO-8601). Defaults to now; injectable for tests. */\n observedAt?: string;\n}\n\n/**\n * Convert a feed coverage list to a `NumericSpan[]`, or `null` when empty.\n * Coverage is already merged + sorted upstream, so this is a structural copy.\n */\nexport function toSpans(coverage: FeedCoverageSpan[]): FeedCoverageSpan[] | null {\n if (coverage.length === 0) return null;\n return coverage.map((s) => ({ start: s.start, end: s.end }));\n}\n\n/** Format a high-water value for the dedup key (`null` -> `-`). */\nfunction fmtHighwater(value: number | null): string {\n return value === null ? \"-\" : String(value);\n}\n\n/**\n * Stable per-source dedup key. Keyed on the coverage high-water mark so the\n * same frontier re-delivers to the same `(sourceId, externalReleaseId)` ledger\n * row (a no-op dedup), while a genuine advance produces a new row.\n */\nexport function externalReleaseId(item: FeedItem): string {\n return `tsundoku:${item.seriesId}:v${fmtHighwater(item.highestVolume)}:c${fmtHighwater(item.highestChapter)}`;\n}\n\n/**\n * Build a `ReleaseCandidate` for a matched feed item. Confidence and `reason`\n * come from the weighted-vote match: confidence reflects the net agreement\n * score, and `reason` lists the providers that agreed (highest-weight first).\n */\nexport function feedItemToCandidate(\n item: FeedItem,\n match: MatchResult,\n opts: CandidateOptions,\n): ReleaseCandidate {\n const base = opts.baseUrl.replace(/\\/+$/, \"\");\n return {\n seriesMatch: {\n codexSeriesId: match.codexSeriesId,\n confidence: match.confidence,\n reason: `tsundoku:vote:${match.agreeingProviders.join(\"+\")}`,\n },\n externalReleaseId: externalReleaseId(item),\n volumes: toSpans(item.volumeCoverage),\n chapters: toSpans(item.chapterCoverage),\n language: opts.language,\n groupOrUploader: null,\n payloadUrl: `${base}/series/${item.seriesId}`,\n observedAt: opts.observedAt ?? new Date().toISOString(),\n // Tsundoku's `updatedAt` is epoch seconds; a coverage change is the closest\n // thing the feed has to a publish date. Not skew-checked host-side.\n releasedAt: new Date(item.updatedAt * 1000).toISOString(),\n metadata: {\n tsundokuSeriesId: item.seriesId,\n canonicalTitle: item.canonicalTitle,\n highestVolume: item.highestVolume,\n highestChapter: item.highestChapter,\n },\n };\n}\n", "/**\n * Tsundoku series-feed fetcher.\n *\n * Wraps `fetch` against `POST {baseUrl}/api/v1/series/feed` with a hard\n * timeout and JSON parsing, returning a discriminated result so the caller\n * can act on a parsed page (`ok`) or surface the upstream status back to the\n * host's per-host backoff layer (`error`).\n *\n * We use the filtered `POST` variant \u2014 the body carries the consumer's\n * `provider:externalId` set so the feed returns only the tracked series, not\n * the whole catalog. The response is keyset-paginated: walk while `hasMore` is\n * true, passing `nextCursor` back as `cursor`. That cursor paginates *within a\n * single poll* and is not persisted \u2014 each poll re-walks the tracked set's\n * current coverage and relies on host-side dedup. Network and parsing are the\n * only side effects, which keeps it trivially testable with a mocked `fetch`.\n */\n\n// =============================================================================\n// Wire types (mirror Tsundoku's SeriesFeedResponse / SeriesFeedItem)\n// =============================================================================\n\n/** One provider mapping on a feed item (e.g. `{ provider: \"mangabaka\", ... }`). */\nexport interface FeedExternalId {\n provider: string;\n externalId: string;\n /** Epoch seconds the mapping was last fetched upstream. */\n fetchedAt: number;\n}\n\n/** One inclusive `[start, end]` coverage range (single values are `start === end`). */\nexport interface FeedCoverageSpan {\n start: number;\n end: number;\n}\n\n/** One series in the incremental release feed. */\nexport interface FeedItem {\n seriesId: number;\n canonicalTitle: string;\n /** Provider mappings the consumer matches on. */\n externalIds: FeedExternalId[];\n /** Merged available volume ranges (sorted, gaps preserved). */\n volumeCoverage: FeedCoverageSpan[];\n /** Merged available chapter ranges (sorted, gaps preserved). */\n chapterCoverage: FeedCoverageSpan[];\n /** Max end of `volumeCoverage`, or null when there is none. */\n highestVolume: number | null;\n /** Max end of `chapterCoverage`, or null when there is none. */\n highestChapter: number | null;\n /** Epoch seconds this series' coverage last changed (the cursor key). */\n updatedAt: number;\n}\n\n/** One page of the feed. */\nexport interface FeedResponse {\n items: FeedItem[];\n /** `true` when more series remain after this page (fetch again now). */\n hasMore: boolean;\n /** Opaque cursor at the last item, or null/absent when the page is empty. */\n nextCursor?: string | null;\n}\n\n// =============================================================================\n// Fetch result + options\n// =============================================================================\n\n/** Discriminated fetch result. */\nexport type FeedFetchResult =\n | { kind: \"ok\"; data: FeedResponse; status: 200 }\n | { kind: \"error\"; status: number; message: string };\n\nexport interface FeedFetcherOptions {\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/** Feed endpoint path appended to the configured base URL. */\nexport const FEED_PATH = \"/api/v1/series/feed\";\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\n/** Body for one `POST /series/feed` page. */\nexport interface FeedRequest {\n /**\n * `provider:externalId` filter \u2014 the feed is narrowed to series carrying one\n * of these. Must be non-empty (an empty list means \"no filter\" upstream,\n * i.e. the whole catalog \u2014 callers guard against that).\n */\n externalIds: string[];\n /** Pagination cursor within this poll. `null` starts at the beginning. */\n cursor: string | null;\n /** Page size (the caller clamps to 1..=500). */\n limit: number;\n}\n\n/** Build the feed endpoint URL (trailing slashes on `baseUrl` tolerated). */\nexport function feedUrl(baseUrl: string): string {\n return `${baseUrl.replace(/\\/+$/, \"\")}${FEED_PATH}`;\n}\n\n/**\n * Fetch one page of the filtered Tsundoku series feed via `POST`.\n *\n * We post the tracked `externalIds` set so the feed returns only the\n * consumer's series (not the whole catalog). The `cursor` is for pagination\n * *within a single poll* \u2014 it is not persisted across polls; each poll walks\n * the current coverage of the tracked set and relies on host-side dedup to\n * suppress unchanged releases.\n *\n * @param baseUrl - Tsundoku instance base URL (trailing slash tolerated).\n * @param req - Filter set + pagination cursor + page size.\n * @param opts - Fetcher options (custom fetch, timeout).\n */\nexport async function fetchFeedPage(\n baseUrl: string,\n req: FeedRequest,\n opts: FeedFetcherOptions = {},\n): Promise<FeedFetchResult> {\n const fetchImpl = opts.fetchImpl ?? globalThis.fetch;\n const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n const url = feedUrl(baseUrl);\n const headers: Record<string, string> = {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n \"User-Agent\": \"Codex-ReleaseTracker/1.0 (+https://github.com/AshDevFr/codex)\",\n };\n const body = JSON.stringify({\n externalIds: req.externalIds,\n cursor: req.cursor,\n limit: req.limit,\n });\n\n // AbortSignal.timeout is the cleanest path; we already require Node 22+.\n const signal = AbortSignal.timeout(timeoutMs);\n\n let resp: Response;\n try {\n resp = await fetchImpl(url, { method: \"POST\", headers, body, signal });\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"Unknown fetch error\";\n // Aborts and transport-level failures map to 0/unavailable so the host's\n // per-host backoff can react without us inventing a fake HTTP status.\n return { kind: \"error\", status: 0, message: msg };\n }\n\n if (resp.status !== 200) {\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}`.trim(),\n };\n }\n\n let parsed: unknown;\n try {\n parsed = await resp.json();\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"invalid JSON\";\n return { kind: \"error\", status: 200, message: `failed to parse feed JSON: ${msg}` };\n }\n\n if (!isFeedResponse(parsed)) {\n return { kind: \"error\", status: 200, message: \"malformed feed response: missing items[]\" };\n }\n\n return { kind: \"ok\", data: parsed, status: 200 };\n}\n\n/**\n * Minimal structural guard: a valid page must carry an `items` array and a\n * boolean `hasMore`. We don't deep-validate each item \u2014 the matcher tolerates\n * missing fields per-item rather than failing the whole page.\n */\nfunction isFeedResponse(value: unknown): value is FeedResponse {\n if (value === null || typeof value !== \"object\") return false;\n const obj = value as Record<string, unknown>;\n return Array.isArray(obj.items) && typeof obj.hasMore === \"boolean\";\n}\n", "{\n \"name\": \"@ashdev/codex-plugin-release-tsundoku\",\n \"version\": \"1.38.3\",\n \"description\": \"Tsundoku release-source plugin for Codex - announces new volume/chapter coverage for tracked series via the Tsundoku incremental series feed, matched by exact external IDs\",\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-tsundoku\"\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 \"tsundoku\",\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.38.3\"\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 * Maps a Codex external-ID source name to the provider name the Tsundoku feed\n * uses. Codex stores some sources under different names than Tsundoku emits\n * (e.g. Codex `myanimelist` \u2194 Tsundoku `mal`), so we translate when building\n * the match index and the feed filter. Identity for names that already agree.\n *\n * The keys are the *bare* Codex source names \u2014 the host strips the stored\n * `api:` / `plugin:` prefix before matching `requiresExternalIds`, so a series\n * stored as `api:myanimelist` is delivered to us as `myanimelist`.\n */\nexport const CODEX_TO_TSUNDOKU_PROVIDER: Record<string, string> = {\n mangabaka: \"mangabaka\",\n anilist: \"anilist\",\n myanimelist: \"mal\",\n mangaupdates: \"mangaupdates\",\n kitsu: \"kitsu\",\n shikimori: \"shikimori\",\n animeplanet: \"anime_planet\",\n animenewsnetwork: \"anime_news_network\",\n};\n\n/**\n * The Codex source names the plugin asks the host for via\n * `requiresExternalIds`. These must be the names Codex *stores* (the map keys),\n * not Tsundoku's \u2014 the host filters `series_external_ids.source` against them.\n */\nexport const CODEX_EXTERNAL_ID_SOURCES = Object.keys(CODEX_TO_TSUNDOKU_PROVIDER);\n\nexport const manifest = {\n name: \"release-tsundoku\",\n displayName: \"Tsundoku Releases\",\n version: packageJson.version,\n description:\n \"Announces new volume/chapter coverage for tracked series via a Tsundoku instance's incremental series feed. Matches series by exact external IDs (no fuzzy matching) and walks the feed by cursor, persisting its position between polls.\",\n author: \"Codex\",\n homepage: \"https://github.com/AshDevFr/codex\",\n protocolVersion: \"1.1\",\n capabilities: {\n releaseSource: {\n kinds: [\"api-feed\"],\n requiresAliases: false,\n requiresExternalIds: [...CODEX_EXTERNAL_ID_SOURCES],\n canAnnounceChapters: true,\n canAnnounceVolumes: true,\n },\n },\n configSchema: {\n description:\n \"Tsundoku plugin configuration. Point `baseUrl` at your Tsundoku instance; the plugin polls its public `/api/v1/series/feed` endpoint and matches results to your tracked series by external ID.\",\n fields: [\n {\n key: \"baseUrl\",\n label: \"Tsundoku Base URL\",\n description:\n \"Base URL of the Tsundoku instance, e.g. `https://tsundoku.example.com`. The plugin appends `/api/v1/series/feed`. No trailing slash required.\",\n type: \"string\" as const,\n required: true,\n example: \"https://tsundoku.example.com\",\n },\n {\n key: \"defaultLanguage\",\n label: \"Default Language\",\n description:\n \"ISO 639-1 language tag stamped on every announcement. The Tsundoku feed tracks official release coverage and carries no language of its own, so a default is required. Per-series language preferences on each series' tracking config still gate the high-water mark host-side.\",\n type: \"string\" as const,\n required: false,\n default: \"en\",\n example: \"en\",\n },\n {\n key: \"pageLimit\",\n label: \"Feed Page Size\",\n description:\n \"Items requested per feed page (1\u2013500). Larger pages mean fewer round-trips when walking a long backlog. Defaults to 100.\",\n type: \"number\" as const,\n required: false,\n default: 100,\n },\n {\n key: \"requestTimeoutMs\",\n label: \"Request Timeout (ms)\",\n description:\n \"How long to wait for a single feed page 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 volumes and chapters for series you've tracked, using a Tsundoku instance as the source. Matches your series by external ID (MangaBaka, AniList, MAL, and more). Notification-only \u2014 Codex does not download anything.\",\n adminSetupInstructions:\n \"1. Set `baseUrl` to your Tsundoku instance URL (e.g. `https://tsundoku.example.com`) and save. The plugin auto-registers a single source row (`Tsundoku Releases`) in **Settings \u2192 Release tracking**, where you can disable it, change the poll interval, or hit *Poll now*. 2. To get announcements for a series, make sure it has at least one external ID Tsundoku also knows (MangaBaka, AniList, MAL, MangaUpdates, Kitsu, Shikimori, Anime-Planet, or Anime News Network) \u2014 populate these via a metadata refresh or by pasting them in the series tracking panel. 3. Optional: adjust `defaultLanguage` (default `en`), `pageLimit`, and `requestTimeoutMs`. The Tsundoku feed endpoint is public; no credentials are needed. Note: the feed is incremental, so newly tracked series only announce on their *next* Tsundoku coverage change.\",\n} as const satisfies PluginManifest & {\n capabilities: { releaseSource: { kinds: [\"api-feed\"] } };\n};\n", "/**\n * Match Tsundoku feed items to tracked Codex series by external ID \u2014 using\n * *weighted voting* across providers rather than trusting a single ID.\n *\n * Why voting: provider IDs vary in quality. MangaBaka is an aggregation hub\n * with reliably 1:1 IDs; others (MAL, MangaUpdates, \u2026) occasionally share or\n * merge IDs across distinct series, so a lone matching ID can be a false\n * positive. So for each candidate we tally the providers the feed item and the\n * Codex series *both* carry: a shared ID that agrees adds its weight, one that\n * disagrees subtracts it. A series matches only when agreement outweighs\n * disagreement \u2014 a trusted disagreement (e.g. different MangaBaka IDs) vetoes a\n * sloppy agreement (e.g. a shared MAL ID).\n *\n * Codex's `releases/record` keys on a `codexSeriesId`, so matching is done\n * here, plugin-side, over the full ID sets both the host and the feed expose.\n */\n\nimport type { TrackedSeriesEntry } from \"@ashdev/codex-plugin-sdk\";\nimport type { FeedItem } from \"./fetcher.js\";\nimport { CODEX_TO_TSUNDOKU_PROVIDER } from \"./manifest.js\";\n\n/**\n * Vote weight per provider \u2014 higher means more trusted as a match signal.\n * MangaBaka leads (its IDs are reliably 1:1), AniList next; the rest default\n * to 1. Tune here if real data shows a source is noisier than assumed.\n */\nexport const PROVIDER_WEIGHTS: Record<string, number> = {\n mangabaka: 3,\n anilist: 2,\n};\nconst DEFAULT_WEIGHT = 1;\n\nfunction weightOf(provider: string): number {\n return PROVIDER_WEIGHTS[provider] ?? DEFAULT_WEIGHT;\n}\n\n/** Result of resolving a feed item to a tracked Codex series. */\nexport interface MatchResult {\n /** The Codex series UUID the candidate should be recorded against. */\n codexSeriesId: string;\n /** Net vote score (agreeing weights minus disagreeing). Always `> 0`. */\n score: number;\n /** Host confidence in `[0.8, 1.0]`, derived from the score. */\n confidence: number;\n /** Providers that agreed, highest-weight first \u2014 used for the candidate `reason`. */\n agreeingProviders: string[];\n}\n\n/** Pre-computed lookup over the tracked series for matching. */\nexport interface MatchContext {\n /** `provider:id` -> codex series ids carrying it (usually one). */\n byKey: Map<string, string[]>;\n /** codex series id -> its `provider -> id` map (for the conflict tally). */\n series: Map<string, Map<string, string>>;\n}\n\n/** Compose the lookup key for a `(provider, externalId)` pair. */\nfunction indexKey(provider: string, externalId: string): string {\n return `${provider}:${externalId}`;\n}\n\n/**\n * Build the match context from the host's tracked-series rows. Entries without\n * external IDs contribute nothing.\n */\nexport function buildMatchContext(entries: TrackedSeriesEntry[]): MatchContext {\n const byKey = new Map<string, string[]>();\n const series = new Map<string, Map<string, string>>();\n\n for (const entry of entries) {\n const ids = entry.externalIds;\n if (!ids) continue;\n const map = new Map<string, string>();\n for (const [codexProvider, externalId] of Object.entries(ids)) {\n if (!externalId) continue;\n // Translate the Codex source name to Tsundoku's provider name so both\n // the index keys and the feed filter line up with what the feed emits\n // (e.g. Codex `myanimelist` -> Tsundoku `mal`).\n const provider = CODEX_TO_TSUNDOKU_PROVIDER[codexProvider] ?? codexProvider;\n map.set(provider, externalId);\n const key = indexKey(provider, externalId);\n const arr = byKey.get(key);\n if (arr) {\n arr.push(entry.seriesId);\n } else {\n byKey.set(key, [entry.seriesId]);\n }\n }\n if (map.size > 0) {\n series.set(entry.seriesId, map);\n }\n }\n\n return { byKey, series };\n}\n\n/**\n * The full set of `provider:id` keys across all tracked series. This is the\n * filter set posted to Tsundoku's `POST /series/feed` so the feed is narrowed\n * to the consumer's catalog.\n */\nexport function externalIdFilter(ctx: MatchContext): string[] {\n return [...ctx.byKey.keys()];\n}\n\n/** Map a net score to a host confidence in `[0.8, 1.0]` (gate is 0.7). */\nfunction confidenceForScore(score: number): number {\n return Math.min(1, Math.max(0.7, 0.7 + 0.1 * score));\n}\n\n/**\n * Resolve a feed item to the single best-matching tracked series, or `null`\n * when nothing matches net-positive or the top two candidates tie (ambiguous \u2014\n * the item's IDs point at two series equally well, so we can't safely pick).\n */\nexport function matchItem(item: FeedItem, ctx: MatchContext): MatchResult | null {\n const itemMap = new Map<string, string>();\n for (const ext of item.externalIds) {\n if (ext.externalId) {\n itemMap.set(ext.provider, ext.externalId);\n }\n }\n\n // Candidate Codex series: any that shares at least one id with the item.\n const candidates = new Set<string>();\n for (const [provider, id] of itemMap) {\n const arr = ctx.byKey.get(indexKey(provider, id));\n if (arr) {\n for (const sid of arr) candidates.add(sid);\n }\n }\n if (candidates.size === 0) return null;\n\n let best: MatchResult | null = null;\n let tiedAtBest = false;\n\n for (const cid of candidates) {\n const cSeries = ctx.series.get(cid);\n if (!cSeries) continue;\n\n let agree = 0;\n let disagree = 0;\n const agreeing: Array<{ provider: string; weight: number }> = [];\n for (const [provider, idVal] of itemMap) {\n const cVal = cSeries.get(provider);\n if (cVal === undefined) continue; // provider not shared by both\n const w = weightOf(provider);\n if (cVal === idVal) {\n agree += w;\n agreeing.push({ provider, weight: w });\n } else {\n disagree += w;\n }\n }\n\n const score = agree - disagree;\n if (score <= 0) continue; // disagreement outweighs (or ties) agreement\n\n if (!best || score > best.score) {\n agreeing.sort((a, b) => b.weight - a.weight || a.provider.localeCompare(b.provider));\n best = {\n codexSeriesId: cid,\n score,\n confidence: confidenceForScore(score),\n agreeingProviders: agreeing.map((a) => a.provider),\n };\n tiedAtBest = false;\n } else if (score === best.score) {\n tiedAtBest = true;\n }\n }\n\n // No net-positive candidate, or two series matched equally well \u2192 don't guess.\n if (!best || tiedAtBest) return null;\n return best;\n}\n", "/**\n * Tsundoku API-feed release-source plugin for Codex.\n *\n * Tsundoku exposes a series feed at `/api/v1/series/feed` carrying, per series,\n * the provider external IDs Codex matches on plus the merged volume/chapter\n * coverage. This plugin polls the **filtered** `POST` variant, matches each\n * returned series to a tracked Codex series by weighted external-ID voting, and\n * records release candidates.\n *\n * Each poll:\n * 1. Builds a match context from the host's `releases/list_tracked` rows\n * (scoped by `requiresExternalIds`) and derives the `provider:externalId`\n * filter set.\n * 2. `POST`s that filter to `/series/feed`, so the response contains only the\n * tracked series \u2014 not the whole catalog. There is no persisted cursor:\n * each poll re-walks the tracked set's current coverage and relies on\n * host-side dedup to suppress unchanged releases. This keeps newly\n * tracked series backfilled and untracked ones dropped, automatically.\n * 3. Matches each item (weighted voting), resolves cross-item (one feed entry\n * per Codex series), and records via `releases/record`.\n *\n * The fetch, matching, and candidate mapping live in dedicated modules\n * (`fetcher`, `matcher`, `candidate`); this entry point owns plugin lifecycle,\n * config, source registration, and the poll orchestration.\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 { feedItemToCandidate } from \"./candidate.js\";\nimport { type FeedItem, fetchFeedPage } from \"./fetcher.js\";\nimport { manifest } from \"./manifest.js\";\nimport { buildMatchContext, externalIdFilter, type MatchResult, matchItem } from \"./matcher.js\";\n\nconst logger = createLogger({ name: manifest.name, level: \"info\" });\n\n/** Default feed page size when config omits / mis-types `pageLimit`. */\nconst DEFAULT_PAGE_LIMIT = 100;\n/** Tsundoku caps the feed page size at 500. */\nconst MAX_PAGE_LIMIT = 500;\n/** Default per-request timeout when config omits / mis-types `requestTimeoutMs`. */\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst MIN_TIMEOUT_MS = 1_000;\nconst MAX_TIMEOUT_MS = 60_000;\nconst DEFAULT_LANGUAGE = \"en\";\n\n// =============================================================================\n// Plugin-level state (set during initialize)\n// =============================================================================\n\ninterface PluginState {\n hostRpc: HostRpcClient | null;\n /** Tsundoku instance base URL (no trailing slash), e.g. `https://t.example.com`. */\n baseUrl: string;\n /** ISO 639-1 tag stamped on every candidate (the feed carries none). */\n defaultLanguage: string;\n /** Feed page size (1..=MAX_PAGE_LIMIT). */\n pageLimit: number;\n /** Hard timeout for a single feed-page fetch. */\n requestTimeoutMs: number;\n}\n\nconst state: PluginState = {\n hostRpc: null,\n baseUrl: \"\",\n defaultLanguage: DEFAULT_LANGUAGE,\n pageLimit: DEFAULT_PAGE_LIMIT,\n requestTimeoutMs: DEFAULT_TIMEOUT_MS,\n};\n\n/** Reset state. Exported for tests; not part of the plugin contract. */\nexport function _resetState(): void {\n state.hostRpc = null;\n state.baseUrl = \"\";\n state.defaultLanguage = DEFAULT_LANGUAGE;\n state.pageLimit = DEFAULT_PAGE_LIMIT;\n state.requestTimeoutMs = DEFAULT_TIMEOUT_MS;\n}\n\n/** Strip a single trailing slash so URL building stays predictable. */\nexport function normalizeBaseUrl(raw: string): string {\n return raw.trim().replace(/\\/+$/, \"\");\n}\n\n// =============================================================================\n// Source registration\n// =============================================================================\n\n/**\n * Register the single static source row representing the Tsundoku feed. The\n * whole catalog is polled under one logical source keyed `default`.\n *\n * No retry needed: the host parks an early reverse-RPC on its readiness\n * barrier until the plugin's capabilities + handlers are installed, so this\n * single call resolves cleanly even when fired from `onInitialize`.\n */\nexport async function registerSources(\n rpc: HostRpcClient,\n): Promise<{ registered: number; pruned: number } | null> {\n const sources = [\n {\n sourceKey: \"default\",\n displayName: \"Tsundoku Releases\",\n kind: \"api-feed\" as const,\n config: null,\n },\n ];\n try {\n return await rpc.call<{ registered: number; pruned: number }>(\n RELEASES_METHODS.REGISTER_SOURCES,\n { sources },\n );\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n logger.error(`register_sources failed: ${reason}`);\n return null;\n }\n}\n\n// =============================================================================\n// Reverse-RPC wrappers\n// =============================================================================\n\ninterface ListTrackedResponse {\n tracked: TrackedSeriesEntry[];\n nextOffset?: number;\n}\n\ninterface RecordResponse {\n ledgerId: string;\n deduped: boolean;\n}\n\n/** Page size for the tracked-series sweep that builds the match index. */\nconst TRACKED_PAGE_SIZE = 200;\n\n/**\n * Lazily walk all tracked-series pages from the host. Yields one entry at a\n * time so the caller can build the reverse index without materializing every\n * page at once.\n */\nasync function* iterateTrackedSeries(\n rpc: HostRpcClient,\n sourceId: string,\n): AsyncGenerator<TrackedSeriesEntry> {\n let offset = 0;\n while (true) {\n const page = await rpc.call<ListTrackedResponse>(RELEASES_METHODS.LIST_TRACKED, {\n sourceId,\n offset,\n limit: TRACKED_PAGE_SIZE,\n });\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 * Submit one candidate to the host ledger. Per-candidate failures (threshold\n * rejection, validation, transient host error) are logged and swallowed so a\n * single bad item never aborts the walk; the next poll retries it.\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, { sourceId, candidate });\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n const code = err instanceof HostRpcError ? ` (code ${err.code})` : \"\";\n logger.warn(`record failed for ${candidate.externalReleaseId}: ${reason}${code}`);\n return null;\n }\n}\n\n/**\n * Best-effort progress emit. Failures (including older hosts without the\n * method) are swallowed \u2014 progress is a UX nicety, never a reason to abort.\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(RELEASES_METHODS.REPORT_PROGRESS, { current, total, message });\n } catch (err) {\n if (err instanceof HostRpcError && err.code === -32601) return;\n const reason = err instanceof Error ? err.message : String(err);\n logger.debug(`report_progress dropped: ${reason}`);\n }\n}\n\n// =============================================================================\n// Poll\n// =============================================================================\n\n/** Dependencies a poll needs, defaulted from plugin state at the call site. */\nexport interface PollDeps {\n /** Tsundoku base URL (no trailing slash). */\n baseUrl: string;\n /** Language stamped on every candidate. */\n language: string;\n /** Feed page size. */\n pageLimit: number;\n /** Per-page fetch timeout. */\n timeoutMs: number;\n /** Custom `fetch` impl (tests). */\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Top-level poll handler.\n *\n * Builds the match context from the host's tracked series and posts their\n * `provider:externalId` set to Tsundoku's filtered feed, so the response\n * contains only the tracked series (not the whole catalog). It walks every\n * page of that filtered feed each poll \u2014 there is no persisted cursor; the\n * in-poll cursor only paginates the current response, and host-side dedup\n * suppresses unchanged releases. Matched items are resolved cross-item (one\n * feed entry per Codex series) and recorded. Exported for tests.\n */\nexport async function poll(\n params: ReleasePollRequest,\n rpc: HostRpcClient,\n deps: PollDeps,\n): Promise<ReleasePollResponse> {\n const sourceId = params.sourceId;\n\n // 1. Build the match context from the user's tracked series, and derive the\n // `provider:externalId` filter we post to Tsundoku.\n const trackedEntries: TrackedSeriesEntry[] = [];\n for await (const entry of iterateTrackedSeries(rpc, sourceId)) {\n trackedEntries.push(entry);\n }\n const ctx = buildMatchContext(trackedEntries);\n const externalIds = externalIdFilter(ctx);\n\n // Diagnostics: the filter POSTed to Tsundoku is built from whatever external\n // IDs the host handed us. A large tracked count with a small filter means\n // most tracked series carry no Tsundoku-known external ID (host-side gap); a\n // large filter with few parsed items means Tsundoku itself only knows a\n // subset (upstream gap). Log the breakdown so the two can be told apart.\n const withIds = trackedEntries.filter(\n (e) => e.externalIds && Object.keys(e.externalIds).length > 0,\n ).length;\n const providerCounts: Record<string, number> = {};\n for (const key of externalIds) {\n const provider = key.slice(0, key.indexOf(\":\"));\n providerCounts[provider] = (providerCounts[provider] ?? 0) + 1;\n }\n logger.info(\n `poll: filter built \u2014 tracked=${trackedEntries.length} withExternalIds=${withIds} filterKeys=${externalIds.length} byProvider=${JSON.stringify(providerCounts)}`,\n );\n logger.debug(`poll: full external-id filter posted to Tsundoku: ${JSON.stringify(externalIds)}`);\n\n if (externalIds.length === 0) {\n // Nothing to query. Posting an empty filter would mean \"no filter\" upstream\n // (the whole catalog), so skip entirely instead.\n logger.info(\n `poll: no tracked series carry a Tsundoku-known external ID (source=${sourceId}); nothing to fetch`,\n );\n return {\n notModified: false,\n upstreamStatus: 200,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n };\n }\n\n // 2. Walk the filtered feed, collecting per-item matches. We resolve them\n // after the walk (cross-item) rather than recording inline, so that when\n // several feed entries map to the same Codex series we keep only the best\n // one instead of polluting the ledger. The cursor here is ephemeral \u2014 it\n // paginates this poll's response and is never persisted.\n let cursor: string | null = null;\n let parsed = 0;\n let worstStatus = 200;\n let pagesFetched = 0;\n const hits: Array<{ item: FeedItem; match: MatchResult }> = [];\n\n while (true) {\n const result = await fetchFeedPage(\n deps.baseUrl,\n { externalIds, cursor, limit: deps.pageLimit },\n { timeoutMs: deps.timeoutMs, fetchImpl: deps.fetchImpl },\n );\n\n if (result.kind === \"error\") {\n worstStatus = Math.max(worstStatus, result.status);\n // Couldn't fetch even the first page: surface a hard failure so the host\n // records `last_error` and the source shows it (e.g. an unreachable or\n // misconfigured `baseUrl`). A mid-walk failure, by contrast, keeps the\n // pages already processed and just stops.\n if (pagesFetched === 0) {\n throw new Error(`feed fetch failed (status ${result.status}): ${result.message}`);\n }\n logger.warn(`feed fetch failed (status ${result.status}): ${result.message}; stopping walk`);\n break;\n }\n\n pagesFetched++;\n const page = result.data;\n for (const item of page.items) {\n parsed++;\n const match = matchItem(item, ctx);\n if (match) {\n hits.push({ item, match });\n } else {\n // An item Tsundoku returned that we couldn't tie to a tracked series.\n // Usually means the provider/id Tsundoku emits for it differs from what\n // Codex stored. Trace the provider mappings so the mismatch is visible.\n logger.debug(\n `poll: feed item ${item.seriesId} (${item.canonicalTitle}) returned but matched no tracked series; ` +\n `itemExternalIds=${JSON.stringify(item.externalIds.map((e) => `${e.provider}:${e.externalId}`))}`,\n );\n }\n }\n\n await reportProgress(rpc, parsed, parsed, `Processed ${parsed} feed items`);\n\n const next = page.nextCursor ?? null;\n if (!page.hasMore) break;\n if (!next) {\n // hasMore with no advancing cursor would loop forever; stop defensively.\n logger.warn(\"feed reported hasMore but no nextCursor; stopping walk\");\n break;\n }\n if (page.items.length === 0) break;\n cursor = next;\n }\n\n // 3. Cross-item resolution: a Codex series should map to at most one feed\n // entry. Group hits by Codex series; keep the highest-scoring one. If the\n // top two tie (e.g. two entries match only via the same low-trust ID),\n // it's genuinely ambiguous \u2014 skip both rather than record the wrong one.\n const byCodex = new Map<string, Array<{ item: FeedItem; match: MatchResult }>>();\n for (const hit of hits) {\n const arr = byCodex.get(hit.match.codexSeriesId);\n if (arr) {\n arr.push(hit);\n } else {\n byCodex.set(hit.match.codexSeriesId, [hit]);\n }\n }\n\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n let ambiguous = 0;\n let superseded = 0;\n\n for (const [codexSeriesId, group] of byCodex) {\n // Best score first; for ties prefer the most recently updated entry (newest\n // coverage). The same Tsundoku series appearing twice in one walk is not a\n // conflict \u2014 only *different* series tying is.\n group.sort((a, b) => b.match.score - a.match.score || b.item.updatedAt - a.item.updatedAt);\n if (\n group.length > 1 &&\n group[0].match.score === group[1].match.score &&\n group[0].item.seriesId !== group[1].item.seriesId\n ) {\n ambiguous += group.length;\n logger.warn(\n `ambiguous: feed entries from different Tsundoku series match Codex series ${codexSeriesId} at score ${group[0].match.score}; skipping`,\n );\n continue;\n }\n superseded += group.length - 1;\n\n const { item, match } = group[0];\n matched++;\n const candidate = feedItemToCandidate(item, match, {\n baseUrl: deps.baseUrl,\n language: deps.language,\n });\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\n logger.info(\n `poll complete: source=${sourceId} tracked=${trackedEntries.length} parsed=${parsed} matched=${matched} recorded=${recorded} deduped=${deduped} ambiguous=${ambiguous} superseded=${superseded} worst_status=${worstStatus}`,\n );\n\n return {\n notModified: false,\n upstreamStatus: worstStatus,\n parsed,\n matched,\n recorded,\n deduped,\n };\n}\n\n// =============================================================================\n// Plugin Initialization\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: host RPC client missing\");\n }\n if (!state.baseUrl) {\n throw new Error(\"Plugin not configured: baseUrl is required\");\n }\n return poll(params, state.hostRpc, {\n baseUrl: state.baseUrl,\n language: state.defaultLanguage,\n pageLimit: state.pageLimit,\n timeoutMs: state.requestTimeoutMs,\n });\n },\n },\n logLevel: \"info\",\n async onInitialize(params: InitializeParams) {\n // Honor the host-supplied log level (Codex `plugins.log_level` config).\n if (params.logLevel) logger.setLevel(params.logLevel);\n state.hostRpc = params.hostRpc;\n\n const ac = params.adminConfig ?? {};\n if (typeof ac.baseUrl === \"string\") {\n state.baseUrl = normalizeBaseUrl(ac.baseUrl);\n }\n if (typeof ac.defaultLanguage === \"string\" && ac.defaultLanguage.trim().length > 0) {\n state.defaultLanguage = ac.defaultLanguage.trim().toLowerCase();\n }\n if (typeof ac.pageLimit === \"number\" && Number.isFinite(ac.pageLimit)) {\n state.pageLimit = Math.max(1, Math.min(Math.trunc(ac.pageLimit), MAX_PAGE_LIMIT));\n }\n if (typeof ac.requestTimeoutMs === \"number\" && Number.isFinite(ac.requestTimeoutMs)) {\n state.requestTimeoutMs = Math.max(\n MIN_TIMEOUT_MS,\n Math.min(ac.requestTimeoutMs, MAX_TIMEOUT_MS),\n );\n }\n\n if (!state.baseUrl) {\n logger.warn(\n \"initialized without a baseUrl \u2014 set it in the plugin config; polls will error until then\",\n );\n }\n logger.info(\n `initialized: baseUrl=${state.baseUrl || \"(unset)\"} defaultLanguage=${state.defaultLanguage} pageLimit=${state.pageLimit} timeoutMs=${state.requestTimeoutMs}`,\n );\n\n // Materialize the single static source row. Deferred to a microtask so we\n // 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(\"Tsundoku release-source plugin started\");\n"],
|
|
4
|
+
"sourcesContent": [null, null, null, null, null, null, null, null, "/**\n * Map a matched Tsundoku feed item to a Codex `ReleaseCandidate`.\n *\n * The feed already carries merged, gap-preserving coverage spans that line up\n * with Codex's `NumericSpan` model, so the volume/chapter axes pass through\n * verbatim. The candidate's `externalReleaseId` is keyed on the coverage\n * high-water mark, so a new ledger row (and announcement) fires only when the\n * frontier advances \u2014 re-delivery of the same coverage dedups host-side, and\n * the host's auto-ignore + `latest_known_*` gate handle \"already owned\".\n */\n\nimport type { ReleaseCandidate } from \"@ashdev/codex-plugin-sdk\";\nimport type { FeedCoverageSpan, FeedItem } from \"./fetcher.js\";\nimport type { MatchResult } from \"./matcher.js\";\n\n// `FeedCoverageSpan` is structurally identical to the SDK's `NumericSpan`\n// (`{ start, end }`), so a span list assigns directly to a candidate's\n// `volumes` / `chapters` without a separate type or an SDK barrel export.\n\n/** Inputs the candidate mapping needs beyond the feed item + match. */\nexport interface CandidateOptions {\n /** Tsundoku base URL (trailing slash tolerated) for building the landing link. */\n baseUrl: string;\n /** ISO 639-1 language stamped on the candidate (the feed carries none). */\n language: string;\n /** Detection timestamp (ISO-8601). Defaults to now; injectable for tests. */\n observedAt?: string;\n}\n\n/**\n * Convert a feed coverage list to a `NumericSpan[]`, or `null` when empty.\n * Coverage is already merged + sorted upstream, so this is a structural copy.\n */\nexport function toSpans(coverage: FeedCoverageSpan[]): FeedCoverageSpan[] | null {\n if (coverage.length === 0) return null;\n return coverage.map((s) => ({ start: s.start, end: s.end }));\n}\n\n/** Format a high-water value for the dedup key (`null` -> `-`). */\nfunction fmtHighwater(value: number | null): string {\n return value === null ? \"-\" : String(value);\n}\n\n/**\n * Stable per-source dedup key. Keyed on the coverage high-water mark so the\n * same frontier re-delivers to the same `(sourceId, externalReleaseId)` ledger\n * row (a no-op dedup), while a genuine advance produces a new row.\n */\nexport function externalReleaseId(item: FeedItem): string {\n return `tsundoku:${item.seriesId}:v${fmtHighwater(item.highestVolume)}:c${fmtHighwater(item.highestChapter)}`;\n}\n\n/**\n * Build a `ReleaseCandidate` for a matched feed item. Confidence and `reason`\n * come from the weighted-vote match: confidence reflects the net agreement\n * score, and `reason` lists the providers that agreed (highest-weight first).\n */\nexport function feedItemToCandidate(\n item: FeedItem,\n match: MatchResult,\n opts: CandidateOptions,\n): ReleaseCandidate {\n const base = opts.baseUrl.replace(/\\/+$/, \"\");\n return {\n seriesMatch: {\n codexSeriesId: match.codexSeriesId,\n confidence: match.confidence,\n reason: `tsundoku:vote:${match.agreeingProviders.join(\"+\")}`,\n },\n externalReleaseId: externalReleaseId(item),\n volumes: toSpans(item.volumeCoverage),\n chapters: toSpans(item.chapterCoverage),\n language: opts.language,\n groupOrUploader: null,\n payloadUrl: `${base}/series/${item.seriesId}`,\n observedAt: opts.observedAt ?? new Date().toISOString(),\n // Tsundoku's `updatedAt` is epoch seconds; a coverage change is the closest\n // thing the feed has to a publish date. Not skew-checked host-side.\n releasedAt: new Date(item.updatedAt * 1000).toISOString(),\n metadata: {\n tsundokuSeriesId: item.seriesId,\n canonicalTitle: item.canonicalTitle,\n highestVolume: item.highestVolume,\n highestChapter: item.highestChapter,\n },\n };\n}\n", "/**\n * Tsundoku series-feed fetcher.\n *\n * Wraps `fetch` against `POST {baseUrl}/api/v1/series/feed` with a hard\n * timeout and JSON parsing, returning a discriminated result so the caller\n * can act on a parsed page (`ok`) or surface the upstream status back to the\n * host's per-host backoff layer (`error`).\n *\n * We use the filtered `POST` variant \u2014 the body carries the consumer's\n * `provider:externalId` set so the feed returns only the tracked series, not\n * the whole catalog. The response is keyset-paginated: walk while `hasMore` is\n * true, passing `nextCursor` back as `cursor`. That cursor paginates *within a\n * single poll* and is not persisted \u2014 each poll re-walks the tracked set's\n * current coverage and relies on host-side dedup. Network and parsing are the\n * only side effects, which keeps it trivially testable with a mocked `fetch`.\n */\n\n// =============================================================================\n// Wire types (mirror Tsundoku's SeriesFeedResponse / SeriesFeedItem)\n// =============================================================================\n\n/** One provider mapping on a feed item (e.g. `{ provider: \"mangabaka\", ... }`). */\nexport interface FeedExternalId {\n provider: string;\n externalId: string;\n /** Epoch seconds the mapping was last fetched upstream. */\n fetchedAt: number;\n}\n\n/** One inclusive `[start, end]` coverage range (single values are `start === end`). */\nexport interface FeedCoverageSpan {\n start: number;\n end: number;\n}\n\n/** One series in the incremental release feed. */\nexport interface FeedItem {\n seriesId: number;\n canonicalTitle: string;\n /** Provider mappings the consumer matches on. */\n externalIds: FeedExternalId[];\n /** Merged available volume ranges (sorted, gaps preserved). */\n volumeCoverage: FeedCoverageSpan[];\n /** Merged available chapter ranges (sorted, gaps preserved). */\n chapterCoverage: FeedCoverageSpan[];\n /** Max end of `volumeCoverage`, or null when there is none. */\n highestVolume: number | null;\n /** Max end of `chapterCoverage`, or null when there is none. */\n highestChapter: number | null;\n /** Epoch seconds this series' coverage last changed (the cursor key). */\n updatedAt: number;\n}\n\n/** One page of the feed. */\nexport interface FeedResponse {\n items: FeedItem[];\n /** `true` when more series remain after this page (fetch again now). */\n hasMore: boolean;\n /** Opaque cursor at the last item, or null/absent when the page is empty. */\n nextCursor?: string | null;\n}\n\n// =============================================================================\n// Fetch result + options\n// =============================================================================\n\n/** Discriminated fetch result. */\nexport type FeedFetchResult =\n | { kind: \"ok\"; data: FeedResponse; status: 200 }\n | { kind: \"error\"; status: number; message: string };\n\nexport interface FeedFetcherOptions {\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/** Feed endpoint path appended to the configured base URL. */\nexport const FEED_PATH = \"/api/v1/series/feed\";\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\n/** Body for one `POST /series/feed` page. */\nexport interface FeedRequest {\n /**\n * `provider:externalId` filter \u2014 the feed is narrowed to series carrying one\n * of these. Must be non-empty (an empty list means \"no filter\" upstream,\n * i.e. the whole catalog \u2014 callers guard against that).\n */\n externalIds: string[];\n /** Pagination cursor within this poll. `null` starts at the beginning. */\n cursor: string | null;\n /** Page size (the caller clamps to 1..=500). */\n limit: number;\n}\n\n/** Build the feed endpoint URL (trailing slashes on `baseUrl` tolerated). */\nexport function feedUrl(baseUrl: string): string {\n return `${baseUrl.replace(/\\/+$/, \"\")}${FEED_PATH}`;\n}\n\n/**\n * Fetch one page of the filtered Tsundoku series feed via `POST`.\n *\n * We post the tracked `externalIds` set so the feed returns only the\n * consumer's series (not the whole catalog). The `cursor` is for pagination\n * *within a single poll* \u2014 it is not persisted across polls; each poll walks\n * the current coverage of the tracked set and relies on host-side dedup to\n * suppress unchanged releases.\n *\n * @param baseUrl - Tsundoku instance base URL (trailing slash tolerated).\n * @param req - Filter set + pagination cursor + page size.\n * @param opts - Fetcher options (custom fetch, timeout).\n */\nexport async function fetchFeedPage(\n baseUrl: string,\n req: FeedRequest,\n opts: FeedFetcherOptions = {},\n): Promise<FeedFetchResult> {\n const fetchImpl = opts.fetchImpl ?? globalThis.fetch;\n const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n const url = feedUrl(baseUrl);\n const headers: Record<string, string> = {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n \"User-Agent\": \"Codex-ReleaseTracker/1.0 (+https://github.com/AshDevFr/codex)\",\n };\n const body = JSON.stringify({\n externalIds: req.externalIds,\n cursor: req.cursor,\n limit: req.limit,\n });\n\n // AbortSignal.timeout is the cleanest path; we already require Node 22+.\n const signal = AbortSignal.timeout(timeoutMs);\n\n let resp: Response;\n try {\n resp = await fetchImpl(url, { method: \"POST\", headers, body, signal });\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"Unknown fetch error\";\n // Aborts and transport-level failures map to 0/unavailable so the host's\n // per-host backoff can react without us inventing a fake HTTP status.\n return { kind: \"error\", status: 0, message: msg };\n }\n\n if (resp.status !== 200) {\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}`.trim(),\n };\n }\n\n let parsed: unknown;\n try {\n parsed = await resp.json();\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"invalid JSON\";\n return { kind: \"error\", status: 200, message: `failed to parse feed JSON: ${msg}` };\n }\n\n if (!isFeedResponse(parsed)) {\n return { kind: \"error\", status: 200, message: \"malformed feed response: missing items[]\" };\n }\n\n return { kind: \"ok\", data: parsed, status: 200 };\n}\n\n/**\n * Minimal structural guard: a valid page must carry an `items` array and a\n * boolean `hasMore`. We don't deep-validate each item \u2014 the matcher tolerates\n * missing fields per-item rather than failing the whole page.\n */\nfunction isFeedResponse(value: unknown): value is FeedResponse {\n if (value === null || typeof value !== \"object\") return false;\n const obj = value as Record<string, unknown>;\n return Array.isArray(obj.items) && typeof obj.hasMore === \"boolean\";\n}\n", "{\n \"name\": \"@ashdev/codex-plugin-release-tsundoku\",\n \"version\": \"1.38.4\",\n \"description\": \"Tsundoku release-source plugin for Codex - announces new volume/chapter coverage for tracked series via the Tsundoku incremental series feed, matched by exact external IDs\",\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-tsundoku\"\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 \"tsundoku\",\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.38.4\"\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 * Maps a Codex external-ID source name to the provider name the Tsundoku feed\n * uses. Codex stores some sources under different names than Tsundoku emits\n * (e.g. Codex `myanimelist` \u2194 Tsundoku `mal`), so we translate when building\n * the match index and the feed filter. Identity for names that already agree.\n *\n * The keys are the *bare* Codex source names \u2014 the host strips the stored\n * `api:` / `plugin:` prefix before matching `requiresExternalIds`, so a series\n * stored as `api:myanimelist` is delivered to us as `myanimelist`.\n */\nexport const CODEX_TO_TSUNDOKU_PROVIDER: Record<string, string> = {\n mangabaka: \"mangabaka\",\n anilist: \"anilist\",\n myanimelist: \"mal\",\n mangaupdates: \"mangaupdates\",\n kitsu: \"kitsu\",\n shikimori: \"shikimori\",\n animeplanet: \"anime_planet\",\n animenewsnetwork: \"anime_news_network\",\n};\n\n/**\n * The Codex source names the plugin asks the host for via\n * `requiresExternalIds`. These must be the names Codex *stores* (the map keys),\n * not Tsundoku's \u2014 the host filters `series_external_ids.source` against them.\n */\nexport const CODEX_EXTERNAL_ID_SOURCES = Object.keys(CODEX_TO_TSUNDOKU_PROVIDER);\n\nexport const manifest = {\n name: \"release-tsundoku\",\n displayName: \"Tsundoku Releases\",\n version: packageJson.version,\n description:\n \"Announces new volume/chapter coverage for tracked series via a Tsundoku instance's incremental series feed. Matches series by exact external IDs (no fuzzy matching) and walks the feed by cursor, persisting its position between polls.\",\n author: \"Codex\",\n homepage: \"https://github.com/AshDevFr/codex\",\n protocolVersion: \"1.1\",\n capabilities: {\n releaseSource: {\n kinds: [\"api-feed\"],\n requiresAliases: false,\n requiresExternalIds: [...CODEX_EXTERNAL_ID_SOURCES],\n canAnnounceChapters: true,\n canAnnounceVolumes: true,\n },\n },\n configSchema: {\n description:\n \"Tsundoku plugin configuration. Point `baseUrl` at your Tsundoku instance; the plugin polls its public `/api/v1/series/feed` endpoint and matches results to your tracked series by external ID.\",\n fields: [\n {\n key: \"baseUrl\",\n label: \"Tsundoku Base URL\",\n description:\n \"Base URL of the Tsundoku instance, e.g. `https://tsundoku.example.com`. The plugin appends `/api/v1/series/feed`. No trailing slash required.\",\n type: \"string\" as const,\n required: true,\n example: \"https://tsundoku.example.com\",\n },\n {\n key: \"defaultLanguage\",\n label: \"Default Language\",\n description:\n \"ISO 639-1 language tag stamped on every announcement. The Tsundoku feed tracks official release coverage and carries no language of its own, so a default is required. Per-series language preferences on each series' tracking config still gate the high-water mark host-side.\",\n type: \"string\" as const,\n required: false,\n default: \"en\",\n example: \"en\",\n },\n {\n key: \"pageLimit\",\n label: \"Feed Page Size\",\n description:\n \"Items requested per feed page (1\u2013500). Larger pages mean fewer round-trips when walking a long backlog. Defaults to 100.\",\n type: \"number\" as const,\n required: false,\n default: 100,\n },\n {\n key: \"requestTimeoutMs\",\n label: \"Request Timeout (ms)\",\n description:\n \"How long to wait for a single feed page 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 volumes and chapters for series you've tracked, using a Tsundoku instance as the source. Matches your series by external ID (MangaBaka, AniList, MAL, and more). Notification-only \u2014 Codex does not download anything.\",\n adminSetupInstructions:\n \"1. Set `baseUrl` to your Tsundoku instance URL (e.g. `https://tsundoku.example.com`) and save. The plugin auto-registers a single source row (`Tsundoku Releases`) in **Settings \u2192 Release tracking**, where you can disable it, change the poll interval, or hit *Poll now*. 2. To get announcements for a series, make sure it has at least one external ID Tsundoku also knows (MangaBaka, AniList, MAL, MangaUpdates, Kitsu, Shikimori, Anime-Planet, or Anime News Network) \u2014 populate these via a metadata refresh or by pasting them in the series tracking panel. 3. Optional: adjust `defaultLanguage` (default `en`), `pageLimit`, and `requestTimeoutMs`. The Tsundoku feed endpoint is public; no credentials are needed. Note: the feed is incremental, so newly tracked series only announce on their *next* Tsundoku coverage change.\",\n} as const satisfies PluginManifest & {\n capabilities: { releaseSource: { kinds: [\"api-feed\"] } };\n};\n", "/**\n * Match Tsundoku feed items to tracked Codex series by external ID \u2014 using\n * *weighted voting* across providers rather than trusting a single ID.\n *\n * Why voting: provider IDs vary in quality. MangaBaka is an aggregation hub\n * with reliably 1:1 IDs; others (MAL, MangaUpdates, \u2026) occasionally share or\n * merge IDs across distinct series, so a lone matching ID can be a false\n * positive. So for each candidate we tally the providers the feed item and the\n * Codex series *both* carry: a shared ID that agrees adds its weight, one that\n * disagrees subtracts it. A series matches only when agreement outweighs\n * disagreement \u2014 a trusted disagreement (e.g. different MangaBaka IDs) vetoes a\n * sloppy agreement (e.g. a shared MAL ID).\n *\n * Codex's `releases/record` keys on a `codexSeriesId`, so matching is done\n * here, plugin-side, over the full ID sets both the host and the feed expose.\n */\n\nimport type { TrackedSeriesEntry } from \"@ashdev/codex-plugin-sdk\";\nimport type { FeedItem } from \"./fetcher.js\";\nimport { CODEX_TO_TSUNDOKU_PROVIDER } from \"./manifest.js\";\n\n/**\n * Vote weight per provider \u2014 higher means more trusted as a match signal.\n * MangaBaka leads (its IDs are reliably 1:1), AniList next; the rest default\n * to 1. Tune here if real data shows a source is noisier than assumed.\n */\nexport const PROVIDER_WEIGHTS: Record<string, number> = {\n mangabaka: 3,\n anilist: 2,\n};\nconst DEFAULT_WEIGHT = 1;\n\nfunction weightOf(provider: string): number {\n return PROVIDER_WEIGHTS[provider] ?? DEFAULT_WEIGHT;\n}\n\n/** Result of resolving a feed item to a tracked Codex series. */\nexport interface MatchResult {\n /** The Codex series UUID the candidate should be recorded against. */\n codexSeriesId: string;\n /** Net vote score (agreeing weights minus disagreeing). Always `> 0`. */\n score: number;\n /** Host confidence in `[0.8, 1.0]`, derived from the score. */\n confidence: number;\n /** Providers that agreed, highest-weight first \u2014 used for the candidate `reason`. */\n agreeingProviders: string[];\n}\n\n/** Pre-computed lookup over the tracked series for matching. */\nexport interface MatchContext {\n /** `provider:id` -> codex series ids carrying it (usually one). */\n byKey: Map<string, string[]>;\n /** codex series id -> its `provider -> id` map (for the conflict tally). */\n series: Map<string, Map<string, string>>;\n}\n\n/** Compose the lookup key for a `(provider, externalId)` pair. */\nfunction indexKey(provider: string, externalId: string): string {\n return `${provider}:${externalId}`;\n}\n\n/**\n * Build the match context from the host's tracked-series rows. Entries without\n * external IDs contribute nothing.\n */\nexport function buildMatchContext(entries: TrackedSeriesEntry[]): MatchContext {\n const byKey = new Map<string, string[]>();\n const series = new Map<string, Map<string, string>>();\n\n for (const entry of entries) {\n const ids = entry.externalIds;\n if (!ids) continue;\n const map = new Map<string, string>();\n for (const [codexProvider, externalId] of Object.entries(ids)) {\n if (!externalId) continue;\n // Translate the Codex source name to Tsundoku's provider name so both\n // the index keys and the feed filter line up with what the feed emits\n // (e.g. Codex `myanimelist` -> Tsundoku `mal`).\n const provider = CODEX_TO_TSUNDOKU_PROVIDER[codexProvider] ?? codexProvider;\n map.set(provider, externalId);\n const key = indexKey(provider, externalId);\n const arr = byKey.get(key);\n if (arr) {\n arr.push(entry.seriesId);\n } else {\n byKey.set(key, [entry.seriesId]);\n }\n }\n if (map.size > 0) {\n series.set(entry.seriesId, map);\n }\n }\n\n return { byKey, series };\n}\n\n/**\n * The full set of `provider:id` keys across all tracked series. This is the\n * filter set posted to Tsundoku's `POST /series/feed` so the feed is narrowed\n * to the consumer's catalog.\n */\nexport function externalIdFilter(ctx: MatchContext): string[] {\n return [...ctx.byKey.keys()];\n}\n\n/** Map a net score to a host confidence in `[0.8, 1.0]` (gate is 0.7). */\nfunction confidenceForScore(score: number): number {\n return Math.min(1, Math.max(0.7, 0.7 + 0.1 * score));\n}\n\n/**\n * Resolve a feed item to the single best-matching tracked series, or `null`\n * when nothing matches net-positive or the top two candidates tie (ambiguous \u2014\n * the item's IDs point at two series equally well, so we can't safely pick).\n */\nexport function matchItem(item: FeedItem, ctx: MatchContext): MatchResult | null {\n const itemMap = new Map<string, string>();\n for (const ext of item.externalIds) {\n if (ext.externalId) {\n itemMap.set(ext.provider, ext.externalId);\n }\n }\n\n // Candidate Codex series: any that shares at least one id with the item.\n const candidates = new Set<string>();\n for (const [provider, id] of itemMap) {\n const arr = ctx.byKey.get(indexKey(provider, id));\n if (arr) {\n for (const sid of arr) candidates.add(sid);\n }\n }\n if (candidates.size === 0) return null;\n\n let best: MatchResult | null = null;\n let tiedAtBest = false;\n\n for (const cid of candidates) {\n const cSeries = ctx.series.get(cid);\n if (!cSeries) continue;\n\n let agree = 0;\n let disagree = 0;\n const agreeing: Array<{ provider: string; weight: number }> = [];\n for (const [provider, idVal] of itemMap) {\n const cVal = cSeries.get(provider);\n if (cVal === undefined) continue; // provider not shared by both\n const w = weightOf(provider);\n if (cVal === idVal) {\n agree += w;\n agreeing.push({ provider, weight: w });\n } else {\n disagree += w;\n }\n }\n\n const score = agree - disagree;\n if (score <= 0) continue; // disagreement outweighs (or ties) agreement\n\n if (!best || score > best.score) {\n agreeing.sort((a, b) => b.weight - a.weight || a.provider.localeCompare(b.provider));\n best = {\n codexSeriesId: cid,\n score,\n confidence: confidenceForScore(score),\n agreeingProviders: agreeing.map((a) => a.provider),\n };\n tiedAtBest = false;\n } else if (score === best.score) {\n tiedAtBest = true;\n }\n }\n\n // No net-positive candidate, or two series matched equally well \u2192 don't guess.\n if (!best || tiedAtBest) return null;\n return best;\n}\n", "/**\n * Tsundoku API-feed release-source plugin for Codex.\n *\n * Tsundoku exposes a series feed at `/api/v1/series/feed` carrying, per series,\n * the provider external IDs Codex matches on plus the merged volume/chapter\n * coverage. This plugin polls the **filtered** `POST` variant, matches each\n * returned series to a tracked Codex series by weighted external-ID voting, and\n * records release candidates.\n *\n * Each poll:\n * 1. Builds a match context from the host's `releases/list_tracked` rows\n * (scoped by `requiresExternalIds`) and derives the `provider:externalId`\n * filter set.\n * 2. `POST`s that filter to `/series/feed`, so the response contains only the\n * tracked series \u2014 not the whole catalog. There is no persisted cursor:\n * each poll re-walks the tracked set's current coverage and relies on\n * host-side dedup to suppress unchanged releases. This keeps newly\n * tracked series backfilled and untracked ones dropped, automatically.\n * 3. Matches each item (weighted voting), resolves cross-item (one feed entry\n * per Codex series), and records via `releases/record`.\n *\n * The fetch, matching, and candidate mapping live in dedicated modules\n * (`fetcher`, `matcher`, `candidate`); this entry point owns plugin lifecycle,\n * config, source registration, and the poll orchestration.\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 { feedItemToCandidate } from \"./candidate.js\";\nimport { type FeedItem, fetchFeedPage } from \"./fetcher.js\";\nimport { manifest } from \"./manifest.js\";\nimport { buildMatchContext, externalIdFilter, type MatchResult, matchItem } from \"./matcher.js\";\n\nconst logger = createLogger({ name: manifest.name, level: \"info\" });\n\n/** Default feed page size when config omits / mis-types `pageLimit`. */\nconst DEFAULT_PAGE_LIMIT = 100;\n/** Tsundoku caps the feed page size at 500. */\nconst MAX_PAGE_LIMIT = 500;\n/** Default per-request timeout when config omits / mis-types `requestTimeoutMs`. */\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst MIN_TIMEOUT_MS = 1_000;\nconst MAX_TIMEOUT_MS = 60_000;\nconst DEFAULT_LANGUAGE = \"en\";\n\n// =============================================================================\n// Plugin-level state (set during initialize)\n// =============================================================================\n\ninterface PluginState {\n hostRpc: HostRpcClient | null;\n /** Tsundoku instance base URL (no trailing slash), e.g. `https://t.example.com`. */\n baseUrl: string;\n /** ISO 639-1 tag stamped on every candidate (the feed carries none). */\n defaultLanguage: string;\n /** Feed page size (1..=MAX_PAGE_LIMIT). */\n pageLimit: number;\n /** Hard timeout for a single feed-page fetch. */\n requestTimeoutMs: number;\n}\n\nconst state: PluginState = {\n hostRpc: null,\n baseUrl: \"\",\n defaultLanguage: DEFAULT_LANGUAGE,\n pageLimit: DEFAULT_PAGE_LIMIT,\n requestTimeoutMs: DEFAULT_TIMEOUT_MS,\n};\n\n/** Reset state. Exported for tests; not part of the plugin contract. */\nexport function _resetState(): void {\n state.hostRpc = null;\n state.baseUrl = \"\";\n state.defaultLanguage = DEFAULT_LANGUAGE;\n state.pageLimit = DEFAULT_PAGE_LIMIT;\n state.requestTimeoutMs = DEFAULT_TIMEOUT_MS;\n}\n\n/** Strip a single trailing slash so URL building stays predictable. */\nexport function normalizeBaseUrl(raw: string): string {\n return raw.trim().replace(/\\/+$/, \"\");\n}\n\n// =============================================================================\n// Source registration\n// =============================================================================\n\n/**\n * Register the single static source row representing the Tsundoku feed. The\n * whole catalog is polled under one logical source keyed `default`.\n *\n * No retry needed: the host parks an early reverse-RPC on its readiness\n * barrier until the plugin's capabilities + handlers are installed, so this\n * single call resolves cleanly even when fired from `onInitialize`.\n */\nexport async function registerSources(\n rpc: HostRpcClient,\n): Promise<{ registered: number; pruned: number } | null> {\n const sources = [\n {\n sourceKey: \"default\",\n displayName: \"Tsundoku Releases\",\n kind: \"api-feed\" as const,\n config: null,\n },\n ];\n try {\n return await rpc.call<{ registered: number; pruned: number }>(\n RELEASES_METHODS.REGISTER_SOURCES,\n { sources },\n );\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n logger.error(`register_sources failed: ${reason}`);\n return null;\n }\n}\n\n// =============================================================================\n// Reverse-RPC wrappers\n// =============================================================================\n\ninterface ListTrackedResponse {\n tracked: TrackedSeriesEntry[];\n nextOffset?: number;\n}\n\ninterface RecordResponse {\n ledgerId: string;\n deduped: boolean;\n}\n\n/** Page size for the tracked-series sweep that builds the match index. */\nconst TRACKED_PAGE_SIZE = 200;\n\n/**\n * Lazily walk all tracked-series pages from the host. Yields one entry at a\n * time so the caller can build the reverse index without materializing every\n * page at once.\n */\nasync function* iterateTrackedSeries(\n rpc: HostRpcClient,\n sourceId: string,\n): AsyncGenerator<TrackedSeriesEntry> {\n let offset = 0;\n while (true) {\n const page = await rpc.call<ListTrackedResponse>(RELEASES_METHODS.LIST_TRACKED, {\n sourceId,\n offset,\n limit: TRACKED_PAGE_SIZE,\n });\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 * Submit one candidate to the host ledger. Per-candidate failures (threshold\n * rejection, validation, transient host error) are logged and swallowed so a\n * single bad item never aborts the walk; the next poll retries it.\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, { sourceId, candidate });\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n const code = err instanceof HostRpcError ? ` (code ${err.code})` : \"\";\n logger.warn(`record failed for ${candidate.externalReleaseId}: ${reason}${code}`);\n return null;\n }\n}\n\n/**\n * Best-effort progress emit. Failures (including older hosts without the\n * method) are swallowed \u2014 progress is a UX nicety, never a reason to abort.\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(RELEASES_METHODS.REPORT_PROGRESS, { current, total, message });\n } catch (err) {\n if (err instanceof HostRpcError && err.code === -32601) return;\n const reason = err instanceof Error ? err.message : String(err);\n logger.debug(`report_progress dropped: ${reason}`);\n }\n}\n\n// =============================================================================\n// Poll\n// =============================================================================\n\n/** Dependencies a poll needs, defaulted from plugin state at the call site. */\nexport interface PollDeps {\n /** Tsundoku base URL (no trailing slash). */\n baseUrl: string;\n /** Language stamped on every candidate. */\n language: string;\n /** Feed page size. */\n pageLimit: number;\n /** Per-page fetch timeout. */\n timeoutMs: number;\n /** Custom `fetch` impl (tests). */\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Top-level poll handler.\n *\n * Builds the match context from the host's tracked series and posts their\n * `provider:externalId` set to Tsundoku's filtered feed, so the response\n * contains only the tracked series (not the whole catalog). It walks every\n * page of that filtered feed each poll \u2014 there is no persisted cursor; the\n * in-poll cursor only paginates the current response, and host-side dedup\n * suppresses unchanged releases. Matched items are resolved cross-item (one\n * feed entry per Codex series) and recorded. Exported for tests.\n */\nexport async function poll(\n params: ReleasePollRequest,\n rpc: HostRpcClient,\n deps: PollDeps,\n): Promise<ReleasePollResponse> {\n const sourceId = params.sourceId;\n\n // 1. Build the match context from the user's tracked series, and derive the\n // `provider:externalId` filter we post to Tsundoku.\n const trackedEntries: TrackedSeriesEntry[] = [];\n for await (const entry of iterateTrackedSeries(rpc, sourceId)) {\n trackedEntries.push(entry);\n }\n const ctx = buildMatchContext(trackedEntries);\n const externalIds = externalIdFilter(ctx);\n\n // Diagnostics: the filter POSTed to Tsundoku is built from whatever external\n // IDs the host handed us. A large tracked count with a small filter means\n // most tracked series carry no Tsundoku-known external ID (host-side gap); a\n // large filter with few parsed items means Tsundoku itself only knows a\n // subset (upstream gap). Log the breakdown so the two can be told apart.\n const withIds = trackedEntries.filter(\n (e) => e.externalIds && Object.keys(e.externalIds).length > 0,\n ).length;\n const providerCounts: Record<string, number> = {};\n for (const key of externalIds) {\n const provider = key.slice(0, key.indexOf(\":\"));\n providerCounts[provider] = (providerCounts[provider] ?? 0) + 1;\n }\n logger.info(\n `poll: filter built \u2014 tracked=${trackedEntries.length} withExternalIds=${withIds} filterKeys=${externalIds.length} byProvider=${JSON.stringify(providerCounts)}`,\n );\n logger.debug(`poll: full external-id filter posted to Tsundoku: ${JSON.stringify(externalIds)}`);\n\n if (externalIds.length === 0) {\n // Nothing to query. Posting an empty filter would mean \"no filter\" upstream\n // (the whole catalog), so skip entirely instead.\n logger.info(\n `poll: no tracked series carry a Tsundoku-known external ID (source=${sourceId}); nothing to fetch`,\n );\n return {\n notModified: false,\n upstreamStatus: 200,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n };\n }\n\n // 2. Walk the filtered feed, collecting per-item matches. We resolve them\n // after the walk (cross-item) rather than recording inline, so that when\n // several feed entries map to the same Codex series we keep only the best\n // one instead of polluting the ledger. The cursor here is ephemeral \u2014 it\n // paginates this poll's response and is never persisted.\n let cursor: string | null = null;\n let parsed = 0;\n let worstStatus = 200;\n let pagesFetched = 0;\n const hits: Array<{ item: FeedItem; match: MatchResult }> = [];\n\n while (true) {\n const result = await fetchFeedPage(\n deps.baseUrl,\n { externalIds, cursor, limit: deps.pageLimit },\n { timeoutMs: deps.timeoutMs, fetchImpl: deps.fetchImpl },\n );\n\n if (result.kind === \"error\") {\n worstStatus = Math.max(worstStatus, result.status);\n // Couldn't fetch even the first page: surface a hard failure so the host\n // records `last_error` and the source shows it (e.g. an unreachable or\n // misconfigured `baseUrl`). A mid-walk failure, by contrast, keeps the\n // pages already processed and just stops.\n if (pagesFetched === 0) {\n throw new Error(`feed fetch failed (status ${result.status}): ${result.message}`);\n }\n logger.warn(`feed fetch failed (status ${result.status}): ${result.message}; stopping walk`);\n break;\n }\n\n pagesFetched++;\n const page = result.data;\n for (const item of page.items) {\n parsed++;\n const match = matchItem(item, ctx);\n if (match) {\n hits.push({ item, match });\n } else {\n // An item Tsundoku returned that we couldn't tie to a tracked series.\n // Usually means the provider/id Tsundoku emits for it differs from what\n // Codex stored. Trace the provider mappings so the mismatch is visible.\n logger.debug(\n `poll: feed item ${item.seriesId} (${item.canonicalTitle}) returned but matched no tracked series; ` +\n `itemExternalIds=${JSON.stringify(item.externalIds.map((e) => `${e.provider}:${e.externalId}`))}`,\n );\n }\n }\n\n await reportProgress(rpc, parsed, parsed, `Processed ${parsed} feed items`);\n\n const next = page.nextCursor ?? null;\n if (!page.hasMore) break;\n if (!next) {\n // hasMore with no advancing cursor would loop forever; stop defensively.\n logger.warn(\"feed reported hasMore but no nextCursor; stopping walk\");\n break;\n }\n if (page.items.length === 0) break;\n cursor = next;\n }\n\n // 3. Cross-item resolution: a Codex series should map to at most one feed\n // entry. Group hits by Codex series; keep the highest-scoring one. If the\n // top two tie (e.g. two entries match only via the same low-trust ID),\n // it's genuinely ambiguous \u2014 skip both rather than record the wrong one.\n const byCodex = new Map<string, Array<{ item: FeedItem; match: MatchResult }>>();\n for (const hit of hits) {\n const arr = byCodex.get(hit.match.codexSeriesId);\n if (arr) {\n arr.push(hit);\n } else {\n byCodex.set(hit.match.codexSeriesId, [hit]);\n }\n }\n\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n let ambiguous = 0;\n let superseded = 0;\n\n for (const [codexSeriesId, group] of byCodex) {\n // Best score first; for ties prefer the most recently updated entry (newest\n // coverage). The same Tsundoku series appearing twice in one walk is not a\n // conflict \u2014 only *different* series tying is.\n group.sort((a, b) => b.match.score - a.match.score || b.item.updatedAt - a.item.updatedAt);\n if (\n group.length > 1 &&\n group[0].match.score === group[1].match.score &&\n group[0].item.seriesId !== group[1].item.seriesId\n ) {\n ambiguous += group.length;\n logger.warn(\n `ambiguous: feed entries from different Tsundoku series match Codex series ${codexSeriesId} at score ${group[0].match.score}; skipping`,\n );\n continue;\n }\n superseded += group.length - 1;\n\n const { item, match } = group[0];\n matched++;\n const candidate = feedItemToCandidate(item, match, {\n baseUrl: deps.baseUrl,\n language: deps.language,\n });\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\n logger.info(\n `poll complete: source=${sourceId} tracked=${trackedEntries.length} parsed=${parsed} matched=${matched} recorded=${recorded} deduped=${deduped} ambiguous=${ambiguous} superseded=${superseded} worst_status=${worstStatus}`,\n );\n\n return {\n notModified: false,\n upstreamStatus: worstStatus,\n parsed,\n matched,\n recorded,\n deduped,\n };\n}\n\n// =============================================================================\n// Plugin Initialization\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: host RPC client missing\");\n }\n if (!state.baseUrl) {\n throw new Error(\"Plugin not configured: baseUrl is required\");\n }\n return poll(params, state.hostRpc, {\n baseUrl: state.baseUrl,\n language: state.defaultLanguage,\n pageLimit: state.pageLimit,\n timeoutMs: state.requestTimeoutMs,\n });\n },\n },\n logLevel: \"info\",\n async onInitialize(params: InitializeParams) {\n // Honor the host-supplied log level (Codex `plugins.log_level` config).\n if (params.logLevel) logger.setLevel(params.logLevel);\n state.hostRpc = params.hostRpc;\n\n const ac = params.adminConfig ?? {};\n if (typeof ac.baseUrl === \"string\") {\n state.baseUrl = normalizeBaseUrl(ac.baseUrl);\n }\n if (typeof ac.defaultLanguage === \"string\" && ac.defaultLanguage.trim().length > 0) {\n state.defaultLanguage = ac.defaultLanguage.trim().toLowerCase();\n }\n if (typeof ac.pageLimit === \"number\" && Number.isFinite(ac.pageLimit)) {\n state.pageLimit = Math.max(1, Math.min(Math.trunc(ac.pageLimit), MAX_PAGE_LIMIT));\n }\n if (typeof ac.requestTimeoutMs === \"number\" && Number.isFinite(ac.requestTimeoutMs)) {\n state.requestTimeoutMs = Math.max(\n MIN_TIMEOUT_MS,\n Math.min(ac.requestTimeoutMs, MAX_TIMEOUT_MS),\n );\n }\n\n if (!state.baseUrl) {\n logger.warn(\n \"initialized without a baseUrl \u2014 set it in the plugin config; polls will error until then\",\n );\n }\n logger.info(\n `initialized: baseUrl=${state.baseUrl || \"(unset)\"} defaultLanguage=${state.defaultLanguage} pageLimit=${state.pageLimit} timeoutMs=${state.requestTimeoutMs}`,\n );\n\n // Materialize the single static source row. Deferred to a microtask so we\n // 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(\"Tsundoku release-source plugin started\");\n"],
|
|
5
5
|
"mappings": "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",
|
|
6
6
|
"names": ["manifest", "logger", "response", "manifest", "DEFAULT_TIMEOUT_MS"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashdev/codex-plugin-release-tsundoku",
|
|
3
|
-
"version": "1.38.
|
|
3
|
+
"version": "1.38.4",
|
|
4
4
|
"description": "Tsundoku release-source plugin for Codex - announces new volume/chapter coverage for tracked series via the Tsundoku incremental series feed, matched by exact external IDs",
|
|
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.38.
|
|
42
|
+
"@ashdev/codex-plugin-sdk": "^1.38.4"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@biomejs/biome": "^2.4.4",
|