@ashdev/codex-plugin-release-tsundoku 1.36.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 ADDED
@@ -0,0 +1,1161 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../sdk-typescript/dist/types/rpc.js
4
+ var JSON_RPC_ERROR_CODES = {
5
+ /** Invalid JSON was received */
6
+ PARSE_ERROR: -32700,
7
+ /** The JSON sent is not a valid Request object */
8
+ INVALID_REQUEST: -32600,
9
+ /** The method does not exist / is not available */
10
+ METHOD_NOT_FOUND: -32601,
11
+ /** Invalid method parameter(s) */
12
+ INVALID_PARAMS: -32602,
13
+ /** Internal JSON-RPC error */
14
+ INTERNAL_ERROR: -32603
15
+ };
16
+
17
+ // ../sdk-typescript/dist/errors.js
18
+ var PluginError = class extends Error {
19
+ data;
20
+ constructor(message, data) {
21
+ super(message);
22
+ this.name = this.constructor.name;
23
+ this.data = data;
24
+ }
25
+ /**
26
+ * Convert to JSON-RPC error format
27
+ */
28
+ toJsonRpcError() {
29
+ return {
30
+ code: this.code,
31
+ message: this.message,
32
+ data: this.data
33
+ };
34
+ }
35
+ };
36
+
37
+ // ../sdk-typescript/dist/request-context.js
38
+ import { AsyncLocalStorage } from "node:async_hooks";
39
+ var store = new AsyncLocalStorage();
40
+ function runWithParentRequestId(forwardRequestId, fn) {
41
+ return store.run(forwardRequestId, fn);
42
+ }
43
+ function currentParentRequestId() {
44
+ return store.getStore();
45
+ }
46
+
47
+ // ../sdk-typescript/dist/host-rpc.js
48
+ var HostRpcError = class extends Error {
49
+ code;
50
+ data;
51
+ constructor(message, code, data) {
52
+ super(message);
53
+ this.code = code;
54
+ this.data = data;
55
+ this.name = "HostRpcError";
56
+ }
57
+ };
58
+ var HostRpcClient = class {
59
+ // Start the counter high so it can't collide with PluginStorage's id space.
60
+ // `Number.MAX_SAFE_INTEGER` is far above this, so we have plenty of room
61
+ // before wrapping (and we never expect a single plugin lifetime to issue
62
+ // more than ~9 quintillion calls).
63
+ nextId = 1e9;
64
+ pendingRequests = /* @__PURE__ */ new Map();
65
+ writeFn;
66
+ /**
67
+ * @param writeFn - Optional custom write function (defaults to
68
+ * `process.stdout.write`). Useful for testing.
69
+ */
70
+ constructor(writeFn) {
71
+ this.writeFn = writeFn ?? ((line) => {
72
+ process.stdout.write(line);
73
+ });
74
+ }
75
+ /**
76
+ * Send a JSON-RPC request to the host and resolve with the result.
77
+ *
78
+ * @param method - JSON-RPC method name (e.g. `"releases/list_tracked"`).
79
+ * @param params - Method-specific params. Pass `undefined` when the method
80
+ * takes no params.
81
+ */
82
+ async call(method, params) {
83
+ const id = this.nextId++;
84
+ const parent = currentParentRequestId();
85
+ const request = {
86
+ jsonrpc: "2.0",
87
+ id,
88
+ method,
89
+ params,
90
+ ...parent !== void 0 ? { parentRequestId: parent } : {}
91
+ };
92
+ return new Promise((resolve, reject) => {
93
+ this.pendingRequests.set(id, {
94
+ resolve: (v) => resolve(v),
95
+ reject
96
+ });
97
+ try {
98
+ this.writeFn(`${JSON.stringify(request)}
99
+ `);
100
+ } catch (err) {
101
+ this.pendingRequests.delete(id);
102
+ const message = err instanceof Error ? err.message : "Unknown write error";
103
+ reject(new HostRpcError(`Failed to send request: ${message}`, -1));
104
+ }
105
+ });
106
+ }
107
+ /**
108
+ * Process an incoming JSON-RPC response line. Returns `true` if this
109
+ * client owned the response id and resolved it, `false` otherwise (so
110
+ * other clients can try).
111
+ *
112
+ * Called by the plugin server's main loop on every response.
113
+ */
114
+ handleResponse(line) {
115
+ const trimmed = line.trim();
116
+ if (!trimmed)
117
+ return false;
118
+ let parsed;
119
+ try {
120
+ parsed = JSON.parse(trimmed);
121
+ } catch {
122
+ return false;
123
+ }
124
+ const obj = parsed;
125
+ if (obj.method !== void 0)
126
+ return false;
127
+ const rawId = obj.id;
128
+ if (typeof rawId !== "number")
129
+ return false;
130
+ if (!this.pendingRequests.has(rawId))
131
+ return false;
132
+ const pending = this.pendingRequests.get(rawId);
133
+ if (!pending)
134
+ return false;
135
+ this.pendingRequests.delete(rawId);
136
+ if ("error" in obj && obj.error) {
137
+ const err = obj.error;
138
+ pending.reject(new HostRpcError(err.message, err.code, err.data));
139
+ } else {
140
+ pending.resolve(obj.result);
141
+ }
142
+ return true;
143
+ }
144
+ /** Reject all pending requests (e.g. on shutdown). */
145
+ cancelAll() {
146
+ for (const [, pending] of this.pendingRequests) {
147
+ pending.reject(new HostRpcError("Host RPC client stopped", -1));
148
+ }
149
+ this.pendingRequests.clear();
150
+ }
151
+ };
152
+
153
+ // ../sdk-typescript/dist/logger.js
154
+ var LOG_LEVELS = {
155
+ debug: 0,
156
+ info: 1,
157
+ warn: 2,
158
+ error: 3
159
+ };
160
+ var Logger = class {
161
+ name;
162
+ minLevel;
163
+ timestamps;
164
+ constructor(options) {
165
+ this.name = options.name;
166
+ this.minLevel = LOG_LEVELS[options.level ?? "info"];
167
+ this.timestamps = options.timestamps ?? true;
168
+ }
169
+ shouldLog(level) {
170
+ return LOG_LEVELS[level] >= this.minLevel;
171
+ }
172
+ format(level, message, data) {
173
+ const parts = [];
174
+ if (this.timestamps) {
175
+ parts.push((/* @__PURE__ */ new Date()).toISOString());
176
+ }
177
+ parts.push(`[${level.toUpperCase()}]`);
178
+ parts.push(`[${this.name}]`);
179
+ parts.push(message);
180
+ if (data !== void 0) {
181
+ if (data instanceof Error) {
182
+ parts.push(`- ${data.message}`);
183
+ if (data.stack) {
184
+ parts.push(`
185
+ ${data.stack}`);
186
+ }
187
+ } else if (typeof data === "object") {
188
+ parts.push(`- ${JSON.stringify(data)}`);
189
+ } else {
190
+ parts.push(`- ${String(data)}`);
191
+ }
192
+ }
193
+ return parts.join(" ");
194
+ }
195
+ log(level, message, data) {
196
+ if (this.shouldLog(level)) {
197
+ process.stderr.write(`${this.format(level, message, data)}
198
+ `);
199
+ }
200
+ }
201
+ debug(message, data) {
202
+ this.log("debug", message, data);
203
+ }
204
+ info(message, data) {
205
+ this.log("info", message, data);
206
+ }
207
+ warn(message, data) {
208
+ this.log("warn", message, data);
209
+ }
210
+ error(message, data) {
211
+ this.log("error", message, data);
212
+ }
213
+ };
214
+ function createLogger(options) {
215
+ return new Logger(options);
216
+ }
217
+
218
+ // ../sdk-typescript/dist/server.js
219
+ import { createInterface } from "node:readline";
220
+
221
+ // ../sdk-typescript/dist/storage.js
222
+ var StorageError = class extends Error {
223
+ code;
224
+ data;
225
+ constructor(message, code, data) {
226
+ super(message);
227
+ this.code = code;
228
+ this.data = data;
229
+ this.name = "StorageError";
230
+ }
231
+ };
232
+ var PluginStorage = class {
233
+ nextId = 1;
234
+ pendingRequests = /* @__PURE__ */ new Map();
235
+ writeFn;
236
+ /**
237
+ * Create a new storage client.
238
+ *
239
+ * @param writeFn - Optional custom write function (defaults to process.stdout.write).
240
+ * Useful for testing or custom transport layers.
241
+ */
242
+ constructor(writeFn) {
243
+ this.writeFn = writeFn ?? ((line) => {
244
+ process.stdout.write(line);
245
+ });
246
+ }
247
+ /**
248
+ * Get a value by key
249
+ *
250
+ * @param key - Storage key to retrieve
251
+ * @returns The stored data and optional expiration, or null data if key doesn't exist
252
+ */
253
+ async get(key) {
254
+ return await this.sendRequest("storage/get", { key });
255
+ }
256
+ /**
257
+ * Set a value by key (upsert - creates or updates)
258
+ *
259
+ * @param key - Storage key
260
+ * @param data - JSON-serializable data to store
261
+ * @param expiresAt - Optional expiration timestamp (ISO 8601)
262
+ * @returns Success indicator
263
+ */
264
+ async set(key, data, expiresAt) {
265
+ const params = { key, data };
266
+ if (expiresAt !== void 0) {
267
+ params.expiresAt = expiresAt;
268
+ }
269
+ return await this.sendRequest("storage/set", params);
270
+ }
271
+ /**
272
+ * Delete a value by key
273
+ *
274
+ * @param key - Storage key to delete
275
+ * @returns Whether the key existed and was deleted
276
+ */
277
+ async delete(key) {
278
+ return await this.sendRequest("storage/delete", { key });
279
+ }
280
+ /**
281
+ * List all keys for this plugin instance (excluding expired)
282
+ *
283
+ * @returns List of key entries with metadata
284
+ */
285
+ async list() {
286
+ return await this.sendRequest("storage/list", {});
287
+ }
288
+ /**
289
+ * Clear all data for this plugin instance
290
+ *
291
+ * @returns Number of entries deleted
292
+ */
293
+ async clear() {
294
+ return await this.sendRequest("storage/clear", {});
295
+ }
296
+ /**
297
+ * Handle an incoming JSON-RPC response line from the host.
298
+ *
299
+ * Call this method from your readline handler to deliver responses
300
+ * back to pending storage requests.
301
+ */
302
+ handleResponse(line) {
303
+ const trimmed = line.trim();
304
+ if (!trimmed)
305
+ return;
306
+ let parsed;
307
+ try {
308
+ parsed = JSON.parse(trimmed);
309
+ } catch {
310
+ return;
311
+ }
312
+ const obj = parsed;
313
+ if (obj.method !== void 0) {
314
+ return;
315
+ }
316
+ const id = obj.id;
317
+ if (id === void 0 || id === null)
318
+ return;
319
+ const pending = this.pendingRequests.get(id);
320
+ if (!pending)
321
+ return;
322
+ this.pendingRequests.delete(id);
323
+ if ("error" in obj && obj.error) {
324
+ const err = obj.error;
325
+ pending.reject(new StorageError(err.message, err.code, err.data));
326
+ } else {
327
+ pending.resolve(obj.result);
328
+ }
329
+ }
330
+ /**
331
+ * Cancel all pending requests (e.g. on shutdown).
332
+ */
333
+ cancelAll() {
334
+ for (const [, pending] of this.pendingRequests) {
335
+ pending.reject(new StorageError("Storage client stopped", -1));
336
+ }
337
+ this.pendingRequests.clear();
338
+ }
339
+ // ===========================================================================
340
+ // Internal
341
+ // ===========================================================================
342
+ sendRequest(method, params) {
343
+ const id = this.nextId++;
344
+ const request = {
345
+ jsonrpc: "2.0",
346
+ id,
347
+ method,
348
+ params
349
+ };
350
+ return new Promise((resolve, reject) => {
351
+ this.pendingRequests.set(id, { resolve, reject });
352
+ try {
353
+ this.writeFn(`${JSON.stringify(request)}
354
+ `);
355
+ } catch (err) {
356
+ this.pendingRequests.delete(id);
357
+ const message = err instanceof Error ? err.message : "Unknown write error";
358
+ reject(new StorageError(`Failed to send request: ${message}`, -1));
359
+ }
360
+ });
361
+ }
362
+ };
363
+
364
+ // ../sdk-typescript/dist/server.js
365
+ function validateStringFields(params, fields) {
366
+ if (params === null || params === void 0) {
367
+ return { field: "params", message: "params is required" };
368
+ }
369
+ if (typeof params !== "object") {
370
+ return { field: "params", message: "params must be an object" };
371
+ }
372
+ const obj = params;
373
+ for (const field of fields) {
374
+ const value = obj[field];
375
+ if (value === void 0 || value === null) {
376
+ return { field, message: `${field} is required` };
377
+ }
378
+ if (typeof value !== "string") {
379
+ return { field, message: `${field} must be a string` };
380
+ }
381
+ if (value.trim() === "") {
382
+ return { field, message: `${field} cannot be empty` };
383
+ }
384
+ }
385
+ return null;
386
+ }
387
+ function invalidParamsError(id, error) {
388
+ return {
389
+ jsonrpc: "2.0",
390
+ id,
391
+ error: {
392
+ code: JSON_RPC_ERROR_CODES.INVALID_PARAMS,
393
+ message: `Invalid params: ${error.message}`,
394
+ data: { field: error.field }
395
+ }
396
+ };
397
+ }
398
+ function createPluginServer(options) {
399
+ const { manifest: manifest2, onInitialize, logLevel = "info", label, router } = options;
400
+ const logger2 = createLogger({ name: manifest2.name, level: logLevel });
401
+ const prefix = label ? `${label} plugin` : "plugin";
402
+ const storage = new PluginStorage();
403
+ const hostRpc = new HostRpcClient();
404
+ logger2.info(`Starting ${prefix}: ${manifest2.displayName} v${manifest2.version}`);
405
+ const rl = createInterface({
406
+ input: process.stdin,
407
+ terminal: false
408
+ });
409
+ rl.on("line", (line) => {
410
+ void handleLine(line, manifest2, onInitialize, router, logger2, storage, hostRpc);
411
+ });
412
+ rl.on("close", () => {
413
+ logger2.info("stdin closed, shutting down");
414
+ storage.cancelAll();
415
+ hostRpc.cancelAll();
416
+ process.exit(0);
417
+ });
418
+ process.on("uncaughtException", (error) => {
419
+ logger2.error("Uncaught exception", error);
420
+ process.exit(1);
421
+ });
422
+ process.on("unhandledRejection", (reason) => {
423
+ logger2.error("Unhandled rejection", reason);
424
+ });
425
+ }
426
+ function isJsonRpcResponse(obj) {
427
+ if (obj.method !== void 0)
428
+ return false;
429
+ if (obj.id === void 0 || obj.id === null)
430
+ return false;
431
+ return "result" in obj || "error" in obj;
432
+ }
433
+ async function handleLine(line, manifest2, onInitialize, router, logger2, storage, hostRpc) {
434
+ const trimmed = line.trim();
435
+ if (!trimmed)
436
+ return;
437
+ let parsed;
438
+ try {
439
+ parsed = JSON.parse(trimmed);
440
+ } catch {
441
+ }
442
+ if (parsed && isJsonRpcResponse(parsed)) {
443
+ logger2.debug("Routing reverse-RPC response", { id: parsed.id });
444
+ if (!hostRpc.handleResponse(trimmed)) {
445
+ storage.handleResponse(trimmed);
446
+ }
447
+ return;
448
+ }
449
+ let id = null;
450
+ try {
451
+ const request = parsed ?? JSON.parse(trimmed);
452
+ id = request.id;
453
+ logger2.debug(`Received request: ${request.method}`, { id: request.id });
454
+ const response = await runWithParentRequestId(request.id, () => handleRequest(request, manifest2, onInitialize, router, logger2, storage, hostRpc));
455
+ if (response !== null) {
456
+ writeResponse(response);
457
+ }
458
+ } catch (error) {
459
+ if (error instanceof SyntaxError) {
460
+ writeResponse({
461
+ jsonrpc: "2.0",
462
+ id: null,
463
+ error: {
464
+ code: JSON_RPC_ERROR_CODES.PARSE_ERROR,
465
+ message: "Parse error: invalid JSON"
466
+ }
467
+ });
468
+ } else if (error instanceof PluginError) {
469
+ writeResponse({
470
+ jsonrpc: "2.0",
471
+ id,
472
+ error: error.toJsonRpcError()
473
+ });
474
+ } else {
475
+ const message = error instanceof Error ? error.message : "Unknown error";
476
+ logger2.error("Request failed", error);
477
+ writeResponse({
478
+ jsonrpc: "2.0",
479
+ id,
480
+ error: {
481
+ code: JSON_RPC_ERROR_CODES.INTERNAL_ERROR,
482
+ message
483
+ }
484
+ });
485
+ }
486
+ }
487
+ }
488
+ async function handleRequest(request, manifest2, onInitialize, router, logger2, storage, hostRpc) {
489
+ const { method, params, id } = request;
490
+ switch (method) {
491
+ case "initialize": {
492
+ const initParams = params ?? {};
493
+ initParams.storage = storage;
494
+ initParams.hostRpc = hostRpc;
495
+ if (onInitialize) {
496
+ await onInitialize(initParams);
497
+ }
498
+ return { jsonrpc: "2.0", id, result: manifest2 };
499
+ }
500
+ case "ping":
501
+ return { jsonrpc: "2.0", id, result: "pong" };
502
+ case "shutdown": {
503
+ logger2.info("Shutdown requested");
504
+ storage.cancelAll();
505
+ hostRpc.cancelAll();
506
+ const response2 = { jsonrpc: "2.0", id, result: null };
507
+ process.stdout.write(`${JSON.stringify(response2)}
508
+ `, () => {
509
+ process.exit(0);
510
+ });
511
+ return null;
512
+ }
513
+ }
514
+ const response = await router(method, params, id);
515
+ if (response !== null) {
516
+ return response;
517
+ }
518
+ return {
519
+ jsonrpc: "2.0",
520
+ id,
521
+ error: {
522
+ code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
523
+ message: `Method not found: ${method}`
524
+ }
525
+ };
526
+ }
527
+ function writeResponse(response) {
528
+ process.stdout.write(`${JSON.stringify(response)}
529
+ `);
530
+ }
531
+ function success(id, result) {
532
+ return { jsonrpc: "2.0", id, result };
533
+ }
534
+ function validateReleasePollParams(params) {
535
+ return validateStringFields(params, ["sourceId"]);
536
+ }
537
+ function createReleaseSourcePlugin(options) {
538
+ const { manifest: manifest2, provider, onInitialize, logLevel } = options;
539
+ if (!manifest2.capabilities.releaseSource) {
540
+ throw new Error("manifest.capabilities.releaseSource is required for createReleaseSourcePlugin");
541
+ }
542
+ const router = async (method, params, id) => {
543
+ switch (method) {
544
+ case "releases/poll": {
545
+ const err = validateReleasePollParams(params);
546
+ if (err)
547
+ return invalidParamsError(id, err);
548
+ return success(id, await provider.poll(params));
549
+ }
550
+ default:
551
+ return null;
552
+ }
553
+ };
554
+ createPluginServer({ manifest: manifest2, onInitialize, logLevel, label: "release-source", router });
555
+ }
556
+
557
+ // ../sdk-typescript/dist/types/releases.js
558
+ var RELEASES_METHODS = {
559
+ /** List tracked series, scoped to what the plugin's manifest declared. */
560
+ LIST_TRACKED: "releases/list_tracked",
561
+ /**
562
+ * Count tracked series scoped to the plugin's `requiresExternalIds`.
563
+ *
564
+ * Plugins call this once at the start of a poll to learn the total
565
+ * denominator before iterating, so subsequent `REPORT_PROGRESS` calls
566
+ * carry a stable `current/total` ratio. Cheap (one batched DB lookup);
567
+ * safe to call from `poll`.
568
+ */
569
+ COUNT_TRACKED: "releases/count_tracked",
570
+ /**
571
+ * Report intra-poll progress to the host. The host translates this into
572
+ * a `TaskProgressEvent` on the active task's broadcaster; the inbox
573
+ * progress bar updates live. Best-effort — calls outside an active
574
+ * task scope are silently dropped, and rapid back-to-back calls are
575
+ * rate-limited (~10/sec) by the host. Plugins SHOULD call this after
576
+ * each unit of work (e.g. after each polled series) with `current` set
577
+ * to the count of completed units and `total` from `COUNT_TRACKED`.
578
+ */
579
+ REPORT_PROGRESS: "releases/report_progress",
580
+ /** Submit a candidate to the host's release ledger. */
581
+ RECORD: "releases/record",
582
+ /** Get persisted per-source state (etag, last_polled_at, last_error). */
583
+ SOURCE_STATE_GET: "releases/source_state/get",
584
+ /** Set persisted per-source state (etag only — other fields are host-owned). */
585
+ SOURCE_STATE_SET: "releases/source_state/set",
586
+ /**
587
+ * Replace the set of `release_sources` rows owned by this plugin.
588
+ *
589
+ * Plugins call this from `onInitialize` (and after any config change, which
590
+ * triggers a process restart that re-runs `onInitialize`). Each call carries
591
+ * the plugin's full desired-state list; the host upserts every entry on
592
+ * `(plugin_id, source_key)` and prunes rows whose `source_key` is not in
593
+ * the request. User-managed fields (`enabled`, `pollIntervalS`) are
594
+ * preserved across re-registrations so an admin's overrides aren't
595
+ * trampled by a plugin restart.
596
+ */
597
+ REGISTER_SOURCES: "releases/register_sources"
598
+ };
599
+
600
+ // src/candidate.ts
601
+ function toSpans(coverage) {
602
+ if (coverage.length === 0) return null;
603
+ return coverage.map((s) => ({ start: s.start, end: s.end }));
604
+ }
605
+ function fmtHighwater(value) {
606
+ return value === null ? "-" : String(value);
607
+ }
608
+ function externalReleaseId(item) {
609
+ return `tsundoku:${item.seriesId}:v${fmtHighwater(item.highestVolume)}:c${fmtHighwater(item.highestChapter)}`;
610
+ }
611
+ function feedItemToCandidate(item, match, opts) {
612
+ const base = opts.baseUrl.replace(/\/+$/, "");
613
+ return {
614
+ seriesMatch: {
615
+ codexSeriesId: match.codexSeriesId,
616
+ confidence: match.confidence,
617
+ reason: `tsundoku:vote:${match.agreeingProviders.join("+")}`
618
+ },
619
+ externalReleaseId: externalReleaseId(item),
620
+ volumes: toSpans(item.volumeCoverage),
621
+ chapters: toSpans(item.chapterCoverage),
622
+ language: opts.language,
623
+ groupOrUploader: null,
624
+ payloadUrl: `${base}/series/${item.seriesId}`,
625
+ observedAt: opts.observedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
626
+ // Tsundoku's `updatedAt` is epoch seconds; a coverage change is the closest
627
+ // thing the feed has to a publish date. Not skew-checked host-side.
628
+ releasedAt: new Date(item.updatedAt * 1e3).toISOString(),
629
+ metadata: {
630
+ tsundokuSeriesId: item.seriesId,
631
+ canonicalTitle: item.canonicalTitle,
632
+ highestVolume: item.highestVolume,
633
+ highestChapter: item.highestChapter
634
+ }
635
+ };
636
+ }
637
+
638
+ // src/fetcher.ts
639
+ var FEED_PATH = "/api/v1/series/feed";
640
+ var DEFAULT_TIMEOUT_MS = 1e4;
641
+ function feedUrl(baseUrl) {
642
+ return `${baseUrl.replace(/\/+$/, "")}${FEED_PATH}`;
643
+ }
644
+ async function fetchFeedPage(baseUrl, req, opts = {}) {
645
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
646
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
647
+ const url = feedUrl(baseUrl);
648
+ const headers = {
649
+ Accept: "application/json",
650
+ "Content-Type": "application/json",
651
+ "User-Agent": "Codex-ReleaseTracker/1.0 (+https://github.com/AshDevFr/codex)"
652
+ };
653
+ const body = JSON.stringify({
654
+ externalIds: req.externalIds,
655
+ cursor: req.cursor,
656
+ limit: req.limit
657
+ });
658
+ const signal = AbortSignal.timeout(timeoutMs);
659
+ let resp;
660
+ try {
661
+ resp = await fetchImpl(url, { method: "POST", headers, body, signal });
662
+ } catch (err) {
663
+ const msg = err instanceof Error ? err.message : "Unknown fetch error";
664
+ return { kind: "error", status: 0, message: msg };
665
+ }
666
+ if (resp.status !== 200) {
667
+ return {
668
+ kind: "error",
669
+ status: resp.status,
670
+ message: `upstream returned ${resp.status} ${resp.statusText}`.trim()
671
+ };
672
+ }
673
+ let parsed;
674
+ try {
675
+ parsed = await resp.json();
676
+ } catch (err) {
677
+ const msg = err instanceof Error ? err.message : "invalid JSON";
678
+ return { kind: "error", status: 200, message: `failed to parse feed JSON: ${msg}` };
679
+ }
680
+ if (!isFeedResponse(parsed)) {
681
+ return { kind: "error", status: 200, message: "malformed feed response: missing items[]" };
682
+ }
683
+ return { kind: "ok", data: parsed, status: 200 };
684
+ }
685
+ function isFeedResponse(value) {
686
+ if (value === null || typeof value !== "object") return false;
687
+ const obj = value;
688
+ return Array.isArray(obj.items) && typeof obj.hasMore === "boolean";
689
+ }
690
+
691
+ // package.json
692
+ var package_default = {
693
+ name: "@ashdev/codex-plugin-release-tsundoku",
694
+ version: "1.36.1",
695
+ 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",
696
+ main: "dist/index.js",
697
+ bin: "dist/index.js",
698
+ type: "module",
699
+ files: [
700
+ "dist",
701
+ "README.md"
702
+ ],
703
+ repository: {
704
+ type: "git",
705
+ url: "https://github.com/AshDevFr/codex.git",
706
+ directory: "plugins/release-tsundoku"
707
+ },
708
+ scripts: {
709
+ build: "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'",
710
+ dev: "npm run build -- --watch",
711
+ clean: "rm -rf dist",
712
+ start: "node dist/index.js",
713
+ lint: "biome check .",
714
+ "lint:fix": "biome check --write .",
715
+ typecheck: "tsc --noEmit",
716
+ test: "vitest run --passWithNoTests",
717
+ "test:watch": "vitest",
718
+ prepublishOnly: "npm run lint && npm run build"
719
+ },
720
+ keywords: [
721
+ "codex",
722
+ "plugin",
723
+ "tsundoku",
724
+ "release-source",
725
+ "manga"
726
+ ],
727
+ author: "Codex",
728
+ license: "MIT",
729
+ engines: {
730
+ node: ">=22.0.0"
731
+ },
732
+ dependencies: {
733
+ "@ashdev/codex-plugin-sdk": "file:../sdk-typescript"
734
+ },
735
+ devDependencies: {
736
+ "@biomejs/biome": "^2.4.4",
737
+ "@types/node": "^22.0.0",
738
+ esbuild: "^0.27.3",
739
+ typescript: "^5.9.3",
740
+ vitest: "^4.0.18"
741
+ }
742
+ };
743
+
744
+ // src/manifest.ts
745
+ var CODEX_TO_TSUNDOKU_PROVIDER = {
746
+ mangabaka: "mangabaka",
747
+ anilist: "anilist",
748
+ myanimelist: "mal",
749
+ mangaupdates: "mangaupdates",
750
+ kitsu: "kitsu",
751
+ shikimori: "shikimori",
752
+ animeplanet: "anime_planet",
753
+ animenewsnetwork: "anime_news_network"
754
+ };
755
+ var CODEX_EXTERNAL_ID_SOURCES = Object.keys(CODEX_TO_TSUNDOKU_PROVIDER);
756
+ var manifest = {
757
+ name: "release-tsundoku",
758
+ displayName: "Tsundoku Releases",
759
+ version: package_default.version,
760
+ description: "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.",
761
+ author: "Codex",
762
+ homepage: "https://github.com/AshDevFr/codex",
763
+ protocolVersion: "1.1",
764
+ capabilities: {
765
+ releaseSource: {
766
+ kinds: ["api-feed"],
767
+ requiresAliases: false,
768
+ requiresExternalIds: [...CODEX_EXTERNAL_ID_SOURCES],
769
+ canAnnounceChapters: true,
770
+ canAnnounceVolumes: true
771
+ }
772
+ },
773
+ configSchema: {
774
+ description: "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.",
775
+ fields: [
776
+ {
777
+ key: "baseUrl",
778
+ label: "Tsundoku Base URL",
779
+ description: "Base URL of the Tsundoku instance, e.g. `https://tsundoku.example.com`. The plugin appends `/api/v1/series/feed`. No trailing slash required.",
780
+ type: "string",
781
+ required: true,
782
+ example: "https://tsundoku.example.com"
783
+ },
784
+ {
785
+ key: "defaultLanguage",
786
+ label: "Default Language",
787
+ description: "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.",
788
+ type: "string",
789
+ required: false,
790
+ default: "en",
791
+ example: "en"
792
+ },
793
+ {
794
+ key: "pageLimit",
795
+ label: "Feed Page Size",
796
+ description: "Items requested per feed page (1\u2013500). Larger pages mean fewer round-trips when walking a long backlog. Defaults to 100.",
797
+ type: "number",
798
+ required: false,
799
+ default: 100
800
+ },
801
+ {
802
+ key: "requestTimeoutMs",
803
+ label: "Request Timeout (ms)",
804
+ description: "How long to wait for a single feed page before giving up. Defaults to 10000 (10 seconds).",
805
+ type: "number",
806
+ required: false,
807
+ default: 1e4
808
+ }
809
+ ]
810
+ },
811
+ userDescription: "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.",
812
+ adminSetupInstructions: "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."
813
+ };
814
+
815
+ // src/matcher.ts
816
+ var PROVIDER_WEIGHTS = {
817
+ mangabaka: 3,
818
+ anilist: 2
819
+ };
820
+ var DEFAULT_WEIGHT = 1;
821
+ function weightOf(provider) {
822
+ return PROVIDER_WEIGHTS[provider] ?? DEFAULT_WEIGHT;
823
+ }
824
+ function indexKey(provider, externalId) {
825
+ return `${provider}:${externalId}`;
826
+ }
827
+ function buildMatchContext(entries) {
828
+ const byKey = /* @__PURE__ */ new Map();
829
+ const series = /* @__PURE__ */ new Map();
830
+ for (const entry of entries) {
831
+ const ids = entry.externalIds;
832
+ if (!ids) continue;
833
+ const map = /* @__PURE__ */ new Map();
834
+ for (const [codexProvider, externalId] of Object.entries(ids)) {
835
+ if (!externalId) continue;
836
+ const provider = CODEX_TO_TSUNDOKU_PROVIDER[codexProvider] ?? codexProvider;
837
+ map.set(provider, externalId);
838
+ const key = indexKey(provider, externalId);
839
+ const arr = byKey.get(key);
840
+ if (arr) {
841
+ arr.push(entry.seriesId);
842
+ } else {
843
+ byKey.set(key, [entry.seriesId]);
844
+ }
845
+ }
846
+ if (map.size > 0) {
847
+ series.set(entry.seriesId, map);
848
+ }
849
+ }
850
+ return { byKey, series };
851
+ }
852
+ function externalIdFilter(ctx) {
853
+ return [...ctx.byKey.keys()];
854
+ }
855
+ function confidenceForScore(score) {
856
+ return Math.min(1, Math.max(0.7, 0.7 + 0.1 * score));
857
+ }
858
+ function matchItem(item, ctx) {
859
+ const itemMap = /* @__PURE__ */ new Map();
860
+ for (const ext of item.externalIds) {
861
+ if (ext.externalId) {
862
+ itemMap.set(ext.provider, ext.externalId);
863
+ }
864
+ }
865
+ const candidates = /* @__PURE__ */ new Set();
866
+ for (const [provider, id] of itemMap) {
867
+ const arr = ctx.byKey.get(indexKey(provider, id));
868
+ if (arr) {
869
+ for (const sid of arr) candidates.add(sid);
870
+ }
871
+ }
872
+ if (candidates.size === 0) return null;
873
+ let best = null;
874
+ let tiedAtBest = false;
875
+ for (const cid of candidates) {
876
+ const cSeries = ctx.series.get(cid);
877
+ if (!cSeries) continue;
878
+ let agree = 0;
879
+ let disagree = 0;
880
+ const agreeing = [];
881
+ for (const [provider, idVal] of itemMap) {
882
+ const cVal = cSeries.get(provider);
883
+ if (cVal === void 0) continue;
884
+ const w = weightOf(provider);
885
+ if (cVal === idVal) {
886
+ agree += w;
887
+ agreeing.push({ provider, weight: w });
888
+ } else {
889
+ disagree += w;
890
+ }
891
+ }
892
+ const score = agree - disagree;
893
+ if (score <= 0) continue;
894
+ if (!best || score > best.score) {
895
+ agreeing.sort((a, b) => b.weight - a.weight || a.provider.localeCompare(b.provider));
896
+ best = {
897
+ codexSeriesId: cid,
898
+ score,
899
+ confidence: confidenceForScore(score),
900
+ agreeingProviders: agreeing.map((a) => a.provider)
901
+ };
902
+ tiedAtBest = false;
903
+ } else if (score === best.score) {
904
+ tiedAtBest = true;
905
+ }
906
+ }
907
+ if (!best || tiedAtBest) return null;
908
+ return best;
909
+ }
910
+
911
+ // src/index.ts
912
+ var logger = createLogger({ name: manifest.name, level: "info" });
913
+ var DEFAULT_PAGE_LIMIT = 100;
914
+ var MAX_PAGE_LIMIT = 500;
915
+ var DEFAULT_TIMEOUT_MS2 = 1e4;
916
+ var MIN_TIMEOUT_MS = 1e3;
917
+ var MAX_TIMEOUT_MS = 6e4;
918
+ var DEFAULT_LANGUAGE = "en";
919
+ var state = {
920
+ hostRpc: null,
921
+ baseUrl: "",
922
+ defaultLanguage: DEFAULT_LANGUAGE,
923
+ pageLimit: DEFAULT_PAGE_LIMIT,
924
+ requestTimeoutMs: DEFAULT_TIMEOUT_MS2
925
+ };
926
+ function _resetState() {
927
+ state.hostRpc = null;
928
+ state.baseUrl = "";
929
+ state.defaultLanguage = DEFAULT_LANGUAGE;
930
+ state.pageLimit = DEFAULT_PAGE_LIMIT;
931
+ state.requestTimeoutMs = DEFAULT_TIMEOUT_MS2;
932
+ }
933
+ function normalizeBaseUrl(raw) {
934
+ return raw.trim().replace(/\/+$/, "");
935
+ }
936
+ async function registerSources(rpc) {
937
+ const sources = [
938
+ {
939
+ sourceKey: "default",
940
+ displayName: "Tsundoku Releases",
941
+ kind: "api-feed",
942
+ config: null
943
+ }
944
+ ];
945
+ try {
946
+ return await rpc.call(
947
+ RELEASES_METHODS.REGISTER_SOURCES,
948
+ { sources }
949
+ );
950
+ } catch (err) {
951
+ const reason = err instanceof Error ? err.message : String(err);
952
+ logger.error(`register_sources failed: ${reason}`);
953
+ return null;
954
+ }
955
+ }
956
+ var TRACKED_PAGE_SIZE = 200;
957
+ async function* iterateTrackedSeries(rpc, sourceId) {
958
+ let offset = 0;
959
+ while (true) {
960
+ const page = await rpc.call(RELEASES_METHODS.LIST_TRACKED, {
961
+ sourceId,
962
+ offset,
963
+ limit: TRACKED_PAGE_SIZE
964
+ });
965
+ for (const entry of page.tracked) {
966
+ yield entry;
967
+ }
968
+ if (page.nextOffset === void 0 || page.tracked.length === 0) return;
969
+ offset = page.nextOffset;
970
+ }
971
+ }
972
+ async function recordCandidate(rpc, sourceId, candidate) {
973
+ try {
974
+ return await rpc.call(RELEASES_METHODS.RECORD, { sourceId, candidate });
975
+ } catch (err) {
976
+ const reason = err instanceof Error ? err.message : String(err);
977
+ const code = err instanceof HostRpcError ? ` (code ${err.code})` : "";
978
+ logger.warn(`record failed for ${candidate.externalReleaseId}: ${reason}${code}`);
979
+ return null;
980
+ }
981
+ }
982
+ async function reportProgress(rpc, current, total, message) {
983
+ try {
984
+ await rpc.call(RELEASES_METHODS.REPORT_PROGRESS, { current, total, message });
985
+ } catch (err) {
986
+ if (err instanceof HostRpcError && err.code === -32601) return;
987
+ const reason = err instanceof Error ? err.message : String(err);
988
+ logger.debug(`report_progress dropped: ${reason}`);
989
+ }
990
+ }
991
+ async function poll(params, rpc, deps) {
992
+ const sourceId = params.sourceId;
993
+ const trackedEntries = [];
994
+ for await (const entry of iterateTrackedSeries(rpc, sourceId)) {
995
+ trackedEntries.push(entry);
996
+ }
997
+ const ctx = buildMatchContext(trackedEntries);
998
+ const externalIds = externalIdFilter(ctx);
999
+ if (externalIds.length === 0) {
1000
+ logger.info(
1001
+ `poll: no tracked series carry a Tsundoku-known external ID (source=${sourceId}); nothing to fetch`
1002
+ );
1003
+ return {
1004
+ notModified: false,
1005
+ upstreamStatus: 200,
1006
+ parsed: 0,
1007
+ matched: 0,
1008
+ recorded: 0,
1009
+ deduped: 0
1010
+ };
1011
+ }
1012
+ let cursor = null;
1013
+ let parsed = 0;
1014
+ let worstStatus = 200;
1015
+ let pagesFetched = 0;
1016
+ const hits = [];
1017
+ while (true) {
1018
+ const result = await fetchFeedPage(
1019
+ deps.baseUrl,
1020
+ { externalIds, cursor, limit: deps.pageLimit },
1021
+ { timeoutMs: deps.timeoutMs, fetchImpl: deps.fetchImpl }
1022
+ );
1023
+ if (result.kind === "error") {
1024
+ worstStatus = Math.max(worstStatus, result.status);
1025
+ if (pagesFetched === 0) {
1026
+ throw new Error(`feed fetch failed (status ${result.status}): ${result.message}`);
1027
+ }
1028
+ logger.warn(`feed fetch failed (status ${result.status}): ${result.message}; stopping walk`);
1029
+ break;
1030
+ }
1031
+ pagesFetched++;
1032
+ const page = result.data;
1033
+ for (const item of page.items) {
1034
+ parsed++;
1035
+ const match = matchItem(item, ctx);
1036
+ if (match) {
1037
+ hits.push({ item, match });
1038
+ }
1039
+ }
1040
+ await reportProgress(rpc, parsed, parsed, `Processed ${parsed} feed items`);
1041
+ const next = page.nextCursor ?? null;
1042
+ if (!page.hasMore) break;
1043
+ if (!next) {
1044
+ logger.warn("feed reported hasMore but no nextCursor; stopping walk");
1045
+ break;
1046
+ }
1047
+ if (page.items.length === 0) break;
1048
+ cursor = next;
1049
+ }
1050
+ const byCodex = /* @__PURE__ */ new Map();
1051
+ for (const hit of hits) {
1052
+ const arr = byCodex.get(hit.match.codexSeriesId);
1053
+ if (arr) {
1054
+ arr.push(hit);
1055
+ } else {
1056
+ byCodex.set(hit.match.codexSeriesId, [hit]);
1057
+ }
1058
+ }
1059
+ let matched = 0;
1060
+ let recorded = 0;
1061
+ let deduped = 0;
1062
+ let ambiguous = 0;
1063
+ let superseded = 0;
1064
+ for (const [codexSeriesId, group] of byCodex) {
1065
+ group.sort((a, b) => b.match.score - a.match.score || b.item.updatedAt - a.item.updatedAt);
1066
+ if (group.length > 1 && group[0].match.score === group[1].match.score && group[0].item.seriesId !== group[1].item.seriesId) {
1067
+ ambiguous += group.length;
1068
+ logger.warn(
1069
+ `ambiguous: feed entries from different Tsundoku series match Codex series ${codexSeriesId} at score ${group[0].match.score}; skipping`
1070
+ );
1071
+ continue;
1072
+ }
1073
+ superseded += group.length - 1;
1074
+ const { item, match } = group[0];
1075
+ matched++;
1076
+ const candidate = feedItemToCandidate(item, match, {
1077
+ baseUrl: deps.baseUrl,
1078
+ language: deps.language
1079
+ });
1080
+ const outcome = await recordCandidate(rpc, sourceId, candidate);
1081
+ if (!outcome) continue;
1082
+ if (outcome.deduped) {
1083
+ deduped++;
1084
+ } else {
1085
+ recorded++;
1086
+ }
1087
+ }
1088
+ logger.info(
1089
+ `poll complete: source=${sourceId} tracked=${trackedEntries.length} parsed=${parsed} matched=${matched} recorded=${recorded} deduped=${deduped} ambiguous=${ambiguous} superseded=${superseded} worst_status=${worstStatus}`
1090
+ );
1091
+ return {
1092
+ notModified: false,
1093
+ upstreamStatus: worstStatus,
1094
+ parsed,
1095
+ matched,
1096
+ recorded,
1097
+ deduped
1098
+ };
1099
+ }
1100
+ createReleaseSourcePlugin({
1101
+ manifest,
1102
+ provider: {
1103
+ async poll(params) {
1104
+ if (!state.hostRpc) {
1105
+ throw new Error("Plugin not initialized: host RPC client missing");
1106
+ }
1107
+ if (!state.baseUrl) {
1108
+ throw new Error("Plugin not configured: baseUrl is required");
1109
+ }
1110
+ return poll(params, state.hostRpc, {
1111
+ baseUrl: state.baseUrl,
1112
+ language: state.defaultLanguage,
1113
+ pageLimit: state.pageLimit,
1114
+ timeoutMs: state.requestTimeoutMs
1115
+ });
1116
+ }
1117
+ },
1118
+ logLevel: "info",
1119
+ async onInitialize(params) {
1120
+ state.hostRpc = params.hostRpc;
1121
+ const ac = params.adminConfig ?? {};
1122
+ if (typeof ac.baseUrl === "string") {
1123
+ state.baseUrl = normalizeBaseUrl(ac.baseUrl);
1124
+ }
1125
+ if (typeof ac.defaultLanguage === "string" && ac.defaultLanguage.trim().length > 0) {
1126
+ state.defaultLanguage = ac.defaultLanguage.trim().toLowerCase();
1127
+ }
1128
+ if (typeof ac.pageLimit === "number" && Number.isFinite(ac.pageLimit)) {
1129
+ state.pageLimit = Math.max(1, Math.min(Math.trunc(ac.pageLimit), MAX_PAGE_LIMIT));
1130
+ }
1131
+ if (typeof ac.requestTimeoutMs === "number" && Number.isFinite(ac.requestTimeoutMs)) {
1132
+ state.requestTimeoutMs = Math.max(
1133
+ MIN_TIMEOUT_MS,
1134
+ Math.min(ac.requestTimeoutMs, MAX_TIMEOUT_MS)
1135
+ );
1136
+ }
1137
+ if (!state.baseUrl) {
1138
+ logger.warn(
1139
+ "initialized without a baseUrl \u2014 set it in the plugin config; polls will error until then"
1140
+ );
1141
+ }
1142
+ logger.info(
1143
+ `initialized: baseUrl=${state.baseUrl || "(unset)"} defaultLanguage=${state.defaultLanguage} pageLimit=${state.pageLimit} timeoutMs=${state.requestTimeoutMs}`
1144
+ );
1145
+ queueMicrotask(() => {
1146
+ void registerSources(params.hostRpc).then((result) => {
1147
+ if (result) {
1148
+ logger.info(`register_sources: registered=${result.registered} pruned=${result.pruned}`);
1149
+ }
1150
+ });
1151
+ });
1152
+ }
1153
+ });
1154
+ logger.info("Tsundoku release-source plugin started");
1155
+ export {
1156
+ _resetState,
1157
+ normalizeBaseUrl,
1158
+ poll,
1159
+ registerSources
1160
+ };
1161
+ //# sourceMappingURL=index.js.map