@ashdev/codex-plugin-release-nyaa 1.18.0

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,1433 @@
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
+ /** Submit a candidate to the host's release ledger. */
562
+ RECORD: "releases/record",
563
+ /** Get persisted per-source state (etag, last_polled_at, last_error). */
564
+ SOURCE_STATE_GET: "releases/source_state/get",
565
+ /** Set persisted per-source state (etag only — other fields are host-owned). */
566
+ SOURCE_STATE_SET: "releases/source_state/set",
567
+ /**
568
+ * Replace the set of `release_sources` rows owned by this plugin.
569
+ *
570
+ * Plugins call this from `onInitialize` (and after any config change, which
571
+ * triggers a process restart that re-runs `onInitialize`). Each call carries
572
+ * the plugin's full desired-state list; the host upserts every entry on
573
+ * `(plugin_id, source_key)` and prunes rows whose `source_key` is not in
574
+ * the request. User-managed fields (`enabled`, `pollIntervalS`) are
575
+ * preserved across re-registrations so an admin's overrides aren't
576
+ * trampled by a plugin restart.
577
+ */
578
+ REGISTER_SOURCES: "releases/register_sources"
579
+ };
580
+
581
+ // src/fetcher.ts
582
+ var NYAA_BASE_URL = "https://nyaa.si";
583
+ var PARAMS_ALLOWLIST = /* @__PURE__ */ new Set(["q", "c", "f", "u"]);
584
+ function parseUrlParams(body) {
585
+ const params = new URLSearchParams(body);
586
+ const kept = [];
587
+ for (const [rawKey, rawValue] of params.entries()) {
588
+ const key = rawKey.toLowerCase();
589
+ if (!PARAMS_ALLOWLIST.has(key)) continue;
590
+ const value = rawValue.trim();
591
+ if (value.length === 0) continue;
592
+ kept.push([key, value]);
593
+ }
594
+ if (kept.length === 0) return null;
595
+ if (kept.length === 1 && kept[0]?.[0] === "u") {
596
+ return { kind: "user", identifier: kept[0][1] };
597
+ }
598
+ kept.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
599
+ const normalized = new URLSearchParams(kept).toString();
600
+ return { kind: "params", identifier: normalized };
601
+ }
602
+ function parseSubscriptionToken(raw) {
603
+ const trimmed = raw.trim();
604
+ if (trimmed.length === 0) return null;
605
+ const prefixMatch = trimmed.match(/^(q|query):(.*)$/i);
606
+ if (prefixMatch) {
607
+ const body = (prefixMatch[2] ?? "").trim();
608
+ if (body.length === 0) return null;
609
+ if (body.startsWith("?")) {
610
+ return parseUrlParams(body.slice(1));
611
+ }
612
+ return { kind: "query", identifier: body };
613
+ }
614
+ return { kind: "user", identifier: trimmed };
615
+ }
616
+ function subscriptionToSourceKey(sub) {
617
+ return `${sub.kind}:${sub.identifier.toLowerCase()}`;
618
+ }
619
+ function sourceKeyToSubscription(key) {
620
+ const idx = key.indexOf(":");
621
+ if (idx <= 0 || idx === key.length - 1) return null;
622
+ const kind = key.slice(0, idx);
623
+ const identifier = key.slice(idx + 1);
624
+ if (kind === "user" || kind === "query" || kind === "params") {
625
+ return { kind, identifier };
626
+ }
627
+ return null;
628
+ }
629
+ function parseSubscriptionList(raw) {
630
+ let tokens;
631
+ if (Array.isArray(raw)) {
632
+ tokens = raw.filter((t) => typeof t === "string");
633
+ } else if (typeof raw === "string") {
634
+ tokens = raw.split(",");
635
+ } else {
636
+ return [];
637
+ }
638
+ const seen = /* @__PURE__ */ new Set();
639
+ const out = [];
640
+ for (const token of tokens) {
641
+ const sub = parseSubscriptionToken(token);
642
+ if (sub === null) continue;
643
+ const key = subscriptionToSourceKey(sub);
644
+ if (seen.has(key)) continue;
645
+ seen.add(key);
646
+ out.push(sub);
647
+ }
648
+ return out;
649
+ }
650
+ function feedUrl(subscription, baseUrl = NYAA_BASE_URL) {
651
+ const base = baseUrl.replace(/\/+$/, "");
652
+ if (subscription.kind === "user") {
653
+ return `${base}/?page=rss&u=${encodeURIComponent(subscription.identifier)}`;
654
+ }
655
+ if (subscription.kind === "query") {
656
+ return `${base}/?page=rss&q=${encodeURIComponent(subscription.identifier)}`;
657
+ }
658
+ return `${base}/?page=rss&${subscription.identifier}`;
659
+ }
660
+ async function fetchSubscriptionFeed(subscription, previousEtag, previousLastModified, opts = {}) {
661
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
662
+ const timeoutMs = opts.timeoutMs ?? 1e4;
663
+ const baseUrl = opts.baseUrl ?? NYAA_BASE_URL;
664
+ const url = feedUrl(subscription, baseUrl);
665
+ const headers = {
666
+ Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.5",
667
+ "User-Agent": "Codex-ReleaseTracker/1.0 (+https://github.com/AshDevFr/codex)"
668
+ };
669
+ if (previousEtag) {
670
+ headers["If-None-Match"] = previousEtag;
671
+ }
672
+ if (previousLastModified) {
673
+ headers["If-Modified-Since"] = previousLastModified;
674
+ }
675
+ const signal = AbortSignal.timeout(timeoutMs);
676
+ let resp;
677
+ try {
678
+ resp = await fetchImpl(url, { method: "GET", headers, signal });
679
+ } catch (err) {
680
+ const msg = err instanceof Error ? err.message : "Unknown fetch error";
681
+ return { kind: "error", status: 0, message: msg };
682
+ }
683
+ if (resp.status === 304) {
684
+ return { kind: "notModified", status: 304 };
685
+ }
686
+ if (resp.status === 200) {
687
+ const body = await resp.text();
688
+ const etag = resp.headers.get("etag");
689
+ const lastModified = resp.headers.get("last-modified");
690
+ return { kind: "ok", body, etag, lastModified, status: 200 };
691
+ }
692
+ return {
693
+ kind: "error",
694
+ status: resp.status,
695
+ message: `upstream returned ${resp.status} ${resp.statusText}`
696
+ };
697
+ }
698
+
699
+ // package.json
700
+ var package_default = {
701
+ name: "@ashdev/codex-plugin-release-nyaa",
702
+ version: "1.18.0",
703
+ description: "Nyaa.si uploader-feed release-source plugin for Codex - announces torrent releases for tracked series, filtered by an admin allowlist of trusted uploaders",
704
+ main: "dist/index.js",
705
+ bin: "dist/index.js",
706
+ type: "module",
707
+ files: [
708
+ "dist",
709
+ "README.md"
710
+ ],
711
+ repository: {
712
+ type: "git",
713
+ url: "https://github.com/AshDevFr/codex.git",
714
+ directory: "plugins/release-nyaa"
715
+ },
716
+ scripts: {
717
+ build: "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'",
718
+ dev: "npm run build -- --watch",
719
+ clean: "rm -rf dist",
720
+ start: "node dist/index.js",
721
+ lint: "biome check .",
722
+ "lint:fix": "biome check --write .",
723
+ typecheck: "tsc --noEmit",
724
+ test: "vitest run --passWithNoTests",
725
+ "test:watch": "vitest",
726
+ prepublishOnly: "npm run lint && npm run build"
727
+ },
728
+ keywords: [
729
+ "codex",
730
+ "plugin",
731
+ "nyaa",
732
+ "release-source",
733
+ "manga",
734
+ "torrent"
735
+ ],
736
+ author: "Codex",
737
+ license: "MIT",
738
+ engines: {
739
+ node: ">=22.0.0"
740
+ },
741
+ dependencies: {
742
+ "@ashdev/codex-plugin-sdk": "file:../sdk-typescript"
743
+ },
744
+ devDependencies: {
745
+ "@biomejs/biome": "^2.4.4",
746
+ "@types/node": "^22.0.0",
747
+ esbuild: "^0.27.3",
748
+ typescript: "^5.9.3",
749
+ vitest: "^4.0.18"
750
+ }
751
+ };
752
+
753
+ // src/manifest.ts
754
+ var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
755
+ var DEFAULT_MIN_CONFIDENCE = 0.7;
756
+ var manifest = {
757
+ name: "release-nyaa",
758
+ displayName: "Nyaa Releases",
759
+ version: package_default.version,
760
+ description: "Announces new chapter / volume torrents for tracked series via Nyaa.si uploader RSS feeds. Limited to an admin-configured uploader allowlist; matches via title aliases.",
761
+ author: "Codex",
762
+ homepage: "https://github.com/AshDevFr/codex",
763
+ protocolVersion: "1.1",
764
+ capabilities: {
765
+ releaseSource: {
766
+ kinds: ["rss-uploader"],
767
+ requiresAliases: true,
768
+ canAnnounceChapters: true,
769
+ canAnnounceVolumes: true
770
+ }
771
+ },
772
+ configSchema: {
773
+ description: "Nyaa plugin configuration. The plugin polls the listed uploaders' RSS feeds (or, for groups without a Nyaa account, a fallback search query) and emits release candidates only for tracked series whose aliases match the parsed title. Notification-only: Codex never downloads torrents.",
774
+ fields: [
775
+ {
776
+ key: "uploaders",
777
+ label: "Uploader Subscriptions",
778
+ description: "List of trusted uploader handles or queries. Each entry is one of: `username` (a Nyaa user feed); `q:<query>` (a plain site-wide search); or `q:?<params>` (URL-style allowlisted params: `q`, `c`, `f`, `u` \u2014 e.g. `q:?c=3_1&q=Berserk` to search the Literature \u2192 English-translated category). Accepts a JSON array (preferred) or a legacy comma-separated string. Confidence stays above the rejection threshold only for entries that match a tracked series alias.",
779
+ type: "string-array",
780
+ required: false,
781
+ default: [],
782
+ example: ["1r0n", "TankobonBlur", "q:LuminousScans", "q:?c=3_1&q=Berserk"]
783
+ },
784
+ {
785
+ key: "requestTimeoutMs",
786
+ label: "Request Timeout (ms)",
787
+ description: "How long to wait for a single Nyaa RSS fetch before giving up. Defaults to 10000 (10 seconds).",
788
+ type: "number",
789
+ required: false,
790
+ default: DEFAULT_REQUEST_TIMEOUT_MS
791
+ },
792
+ {
793
+ key: "baseUrl",
794
+ label: "Nyaa Base URL",
795
+ description: "Override the Nyaa base URL. Useful for mirrors or for tests. Defaults to https://nyaa.si.",
796
+ type: "string",
797
+ required: false,
798
+ default: "https://nyaa.si",
799
+ example: "https://nyaa.si"
800
+ }
801
+ ]
802
+ },
803
+ userDescription: "Watches Nyaa.si uploader feeds for new releases of tracked series. Matches by title alias \u2014 make sure your series' aliases (auto-populated from metadata or added manually in the Tracking panel) cover the way the uploader names them. Notification-only \u2014 Codex never downloads anything.",
804
+ adminSetupInstructions: "1. Set the **Uploaders** config field to a JSON array of entries (a comma-separated string is still accepted for backwards compatibility). Each entry is one of: `username` (a Nyaa user feed, e.g. `tsuna69`), `q:<query>` (a plain site-wide search, e.g. `q:LuminousScans`), or `q:?<params>` (URL-style search with allowlisted keys `q`, `c`, `f`, `u`, e.g. `q:?c=3_1&q=Berserk` for the English-translated Literature category). 2. Save. The plugin restarts and the host materializes one row per entry in **Settings \u2192 Release tracking** \u2014 that's where you flip rows on/off, override the poll interval, or hit *Poll now*. 3. Make sure tracked series have aliases that match how the uploader names releases (alternate spellings, romanizations, volume-range tags). The plugin auto-prunes rows when you remove an entry from the list and re-save, so the Release tracking table stays in sync with this list."
805
+ };
806
+
807
+ // src/matcher.ts
808
+ var CONFIDENCE_EXACT = 0.95;
809
+ var DEFAULT_FUZZY_FLOOR = 0.7;
810
+ var MIN_DICE_RATIO = 0.85;
811
+ function normalizeAlias(input) {
812
+ let out = "";
813
+ let lastWasSpace = false;
814
+ for (const ch of input) {
815
+ if (/[\p{L}\p{N}]/u.test(ch)) {
816
+ out += ch.toLowerCase();
817
+ lastWasSpace = false;
818
+ } else if (/\s/.test(ch) && out.length > 0 && !lastWasSpace) {
819
+ out += " ";
820
+ lastWasSpace = true;
821
+ }
822
+ }
823
+ return out.endsWith(" ") ? out.slice(0, -1) : out;
824
+ }
825
+ function diceRatio(a, b) {
826
+ if (a.length === 0 || b.length === 0) return 0;
827
+ if (a === b) return 1;
828
+ const bigramsA = bigrams(a);
829
+ const bigramsB = bigrams(b);
830
+ if (bigramsA.size === 0 || bigramsB.size === 0) return 0;
831
+ let intersection = 0;
832
+ for (const bg of bigramsA) {
833
+ if (bigramsB.has(bg)) intersection++;
834
+ }
835
+ return 2 * intersection / (bigramsA.size + bigramsB.size);
836
+ }
837
+ function bigrams(s) {
838
+ const out = /* @__PURE__ */ new Set();
839
+ const words = s.split(/\s+/).filter((w) => w.length > 0);
840
+ if (words.length >= 2) {
841
+ for (let i = 0; i < words.length - 1; i++) {
842
+ out.add(`${words[i]} ${words[i + 1]}`);
843
+ }
844
+ }
845
+ const flat = s.replace(/\s+/g, "");
846
+ if (flat.length >= 2) {
847
+ for (let i = 0; i < flat.length - 1; i++) {
848
+ out.add(`#${flat.slice(i, i + 2)}`);
849
+ }
850
+ } else if (flat.length === 1) {
851
+ out.add(`#${flat}`);
852
+ }
853
+ return out;
854
+ }
855
+ function matchSeries(seriesGuess, candidates, opts = {}) {
856
+ const floor = opts.fuzzyFloor ?? DEFAULT_FUZZY_FLOOR;
857
+ const target = normalizeAlias(seriesGuess);
858
+ if (target.length === 0 || candidates.length === 0) return null;
859
+ for (const c of candidates) {
860
+ for (const alias of c.aliases) {
861
+ if (normalizeAlias(alias) === target) {
862
+ return {
863
+ seriesId: c.seriesId,
864
+ confidence: CONFIDENCE_EXACT,
865
+ reason: "alias-exact",
866
+ matchedAlias: alias
867
+ };
868
+ }
869
+ }
870
+ }
871
+ let best = null;
872
+ let bestRatio = 0;
873
+ for (const c of candidates) {
874
+ for (const alias of c.aliases) {
875
+ const ratio = diceRatio(target, normalizeAlias(alias));
876
+ if (ratio > bestRatio) {
877
+ bestRatio = ratio;
878
+ best = {
879
+ seriesId: c.seriesId,
880
+ confidence: 0,
881
+ reason: "alias-fuzzy",
882
+ matchedAlias: alias
883
+ };
884
+ }
885
+ }
886
+ }
887
+ if (best === null || bestRatio < MIN_DICE_RATIO) return null;
888
+ const ceiling = 0.85;
889
+ const span = 1 - MIN_DICE_RATIO;
890
+ const t = (bestRatio - MIN_DICE_RATIO) / span;
891
+ const confidence = floor + t * (ceiling - floor);
892
+ if (confidence < floor) return null;
893
+ best.confidence = Number(confidence.toFixed(4));
894
+ return best;
895
+ }
896
+ function matchSeriesAny(seriesGuesses, candidates, opts = {}) {
897
+ if (seriesGuesses.length === 0) return null;
898
+ let best = null;
899
+ for (const guess of seriesGuesses) {
900
+ const m = matchSeries(guess, candidates, opts);
901
+ if (m === null) continue;
902
+ if (best === null || m.confidence > best.confidence) {
903
+ best = m;
904
+ }
905
+ }
906
+ return best;
907
+ }
908
+
909
+ // src/parser.ts
910
+ function decodeXmlText(raw) {
911
+ let s = raw.trim();
912
+ const cdataMatch = s.match(/^<!\[CDATA\[([\s\S]*?)]]>$/);
913
+ if (cdataMatch?.[1] !== void 0) {
914
+ s = cdataMatch[1];
915
+ }
916
+ return s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'");
917
+ }
918
+ function extractTagText(xml, tag) {
919
+ const safeTag = tag.replace(/:/g, "\\:");
920
+ const re = new RegExp(`<${safeTag}[^>]*>([\\s\\S]*?)</${safeTag}>`, "i");
921
+ const m = xml.match(re);
922
+ if (!m?.[1]) return null;
923
+ return decodeXmlText(m[1]);
924
+ }
925
+ function splitItems(xml) {
926
+ const out = [];
927
+ const re = /<item\b[^>]*>([\s\S]*?)<\/item>/gi;
928
+ for (; ; ) {
929
+ const match = re.exec(xml);
930
+ if (match === null) break;
931
+ if (match[1] !== void 0) out.push(match[1]);
932
+ }
933
+ return out;
934
+ }
935
+ function extractLeadingGroup(title) {
936
+ const m = title.match(/^\s*\[([^\]]+)\]\s*(.*)$/);
937
+ if (!m?.[1]) return { rest: title, group: null };
938
+ const group = m[1].trim();
939
+ const rest = m[2] ?? "";
940
+ return { rest, group: group.length > 0 ? group : null };
941
+ }
942
+ function stripParens(s) {
943
+ return s.replace(/\([^)]*\)/g, " ");
944
+ }
945
+ function findReleaseInfoStart(s) {
946
+ const anchors = [
947
+ /\b(?:v|vol|volume)\.?\s*[0-9]+/i,
948
+ /\b[0-9]{3,4}\s*[-–]\s*[0-9]{3,4}\b/,
949
+ /\b(?:c|ch|chapter)\.?\s*[0-9]+/i
950
+ ];
951
+ let best = -1;
952
+ for (const re of anchors) {
953
+ const m = s.match(re);
954
+ if (m && m.index !== void 0 && (best === -1 || m.index < best)) {
955
+ best = m.index;
956
+ }
957
+ }
958
+ return best;
959
+ }
960
+ function tokenizeReleaseInfo(s) {
961
+ const tokens = [];
962
+ const tokenRe = new RegExp(
963
+ [
964
+ "\\b(?<vrs>v|vol|volume)\\.?\\s*([0-9]+)\\s*[-\u2013]\\s*([0-9]+)\\b",
965
+ "\\b(?<vss>v|vol|volume)\\.?\\s*([0-9]+)\\b",
966
+ "\\b(?<crs>c|ch|chapter)\\.?\\s*([0-9]+(?:\\.[0-9]+)?)\\s*[-\u2013]\\s*([0-9]+(?:\\.[0-9]+)?)\\b",
967
+ "\\b(?<css>c|ch|chapter)\\.?\\s*([0-9]+(?:\\.[0-9]+)?)\\b",
968
+ "\\b(?<brs>)([0-9]{3,4})\\s*[-\u2013]\\s*([0-9]{3,4})\\b",
969
+ "\\b(?<bss>)([0-9]{1,4})\\b"
970
+ ].join("|"),
971
+ "gi"
972
+ );
973
+ for (; ; ) {
974
+ const m = tokenRe.exec(s);
975
+ if (m === null) break;
976
+ const groups = m.groups ?? {};
977
+ if (groups.vrs !== void 0) {
978
+ const start = Number.parseInt(m[2] ?? "", 10);
979
+ const end = Number.parseInt(m[3] ?? "", 10);
980
+ if (Number.isFinite(start) && Number.isFinite(end)) {
981
+ tokens.push({ kind: "volRange", start, end });
982
+ }
983
+ continue;
984
+ }
985
+ if (groups.vss !== void 0) {
986
+ const value = Number.parseInt(m[5] ?? "", 10);
987
+ if (Number.isFinite(value)) tokens.push({ kind: "volume", value });
988
+ continue;
989
+ }
990
+ if (groups.crs !== void 0) {
991
+ const start = Number.parseFloat(m[7] ?? "");
992
+ const end = Number.parseFloat(m[8] ?? "");
993
+ if (Number.isFinite(start) && Number.isFinite(end)) {
994
+ tokens.push({ kind: "chapRange", start, end });
995
+ }
996
+ continue;
997
+ }
998
+ if (groups.css !== void 0) {
999
+ const value = Number.parseFloat(m[10] ?? "");
1000
+ if (Number.isFinite(value)) tokens.push({ kind: "chapter", value });
1001
+ continue;
1002
+ }
1003
+ if (groups.brs !== void 0) {
1004
+ const start = Number.parseInt(m[12] ?? "", 10);
1005
+ const end = Number.parseInt(m[13] ?? "", 10);
1006
+ if (Number.isFinite(start) && Number.isFinite(end)) {
1007
+ tokens.push({ kind: "chapRange", start, end });
1008
+ }
1009
+ continue;
1010
+ }
1011
+ if (groups.bss !== void 0) {
1012
+ const raw = m[15] ?? "";
1013
+ const value = Number.parseInt(raw, 10);
1014
+ if (!Number.isFinite(value)) continue;
1015
+ if (raw.length < 3 && tokens.length === 0) continue;
1016
+ tokens.push({ kind: "chapter", value });
1017
+ }
1018
+ }
1019
+ return tokens;
1020
+ }
1021
+ function aggregateTokens(tokens) {
1022
+ let vMin = null;
1023
+ let vMax = null;
1024
+ let cMin = null;
1025
+ let cMax = null;
1026
+ for (const t of tokens) {
1027
+ if (t.kind === "volume") {
1028
+ vMin = vMin === null || t.value < vMin ? t.value : vMin;
1029
+ vMax = vMax === null || t.value > vMax ? t.value : vMax;
1030
+ } else if (t.kind === "volRange") {
1031
+ vMin = vMin === null || t.start < vMin ? t.start : vMin;
1032
+ vMax = vMax === null || t.end > vMax ? t.end : vMax;
1033
+ } else if (t.kind === "chapter") {
1034
+ cMin = cMin === null || t.value < cMin ? t.value : cMin;
1035
+ cMax = cMax === null || t.value > cMax ? t.value : cMax;
1036
+ } else {
1037
+ cMin = cMin === null || t.start < cMin ? t.start : cMin;
1038
+ cMax = cMax === null || t.end > cMax ? t.end : cMax;
1039
+ }
1040
+ }
1041
+ return {
1042
+ volume: vMin,
1043
+ // Only emit a range-end when it actually differs from the start: a single
1044
+ // volume is `volume=N, volumeRangeEnd=null`, matching the prior contract.
1045
+ volumeRangeEnd: vMin !== null && vMax !== null && vMax !== vMin ? vMax : null,
1046
+ chapter: cMin,
1047
+ chapterRangeEnd: cMin !== null && cMax !== null && cMax !== cMin ? cMax : null
1048
+ };
1049
+ }
1050
+ function extractFormatHints(s) {
1051
+ const hints = {};
1052
+ const tagRe = /\(([^)]+)\)/g;
1053
+ for (; ; ) {
1054
+ const match = tagRe.exec(s);
1055
+ if (match === null) break;
1056
+ const tag = (match[1] ?? "").trim().toLowerCase();
1057
+ if (tag.length === 0) continue;
1058
+ if (tag === "digital") hints.digital = true;
1059
+ else if (tag === "jxl") hints.jxl = true;
1060
+ else if (tag === "magazine" || tag === "mag-z") hints.magazine = true;
1061
+ else if (tag === "webtoon") hints.webtoon = true;
1062
+ else if (tag === "bw" || tag === "b&w") hints.bw = true;
1063
+ else if (tag === "color") hints.color = true;
1064
+ else if (tag === "omnibus" || tag === "omnibus edition") hints.omnibus = true;
1065
+ }
1066
+ return hints;
1067
+ }
1068
+ function stripTrailingBracket(s) {
1069
+ return s.replace(/\s*\[[^\]]+\]\s*$/g, "").trim();
1070
+ }
1071
+ function extractSeriesAliases(nameRegion) {
1072
+ const dashJoined = nameRegion.replace(/\s+[-–—]\s+/g, " ");
1073
+ const parts = dashJoined.split(/\s+\/\s+/).map((p) => p.replace(/\s+/g, " ").trim()).filter((p) => p.length > 0);
1074
+ if (parts.length === 0) return { primary: "", aliases: [] };
1075
+ return { primary: parts[0] ?? "", aliases: parts };
1076
+ }
1077
+ function parseTitle(title) {
1078
+ const trimmed = title.trim();
1079
+ if (trimmed.length === 0) return null;
1080
+ const { rest, group } = extractLeadingGroup(trimmed);
1081
+ const formatHints = extractFormatHints(rest);
1082
+ const flattened = stripTrailingBracket(stripParens(rest));
1083
+ const anchor = findReleaseInfoStart(flattened);
1084
+ const nameRegion = anchor === -1 ? flattened : flattened.slice(0, anchor);
1085
+ const infoRegion = anchor === -1 ? "" : flattened.slice(anchor);
1086
+ const tokens = tokenizeReleaseInfo(infoRegion);
1087
+ const { volume, volumeRangeEnd, chapter, chapterRangeEnd } = aggregateTokens(tokens);
1088
+ const { primary, aliases } = extractSeriesAliases(nameRegion);
1089
+ return {
1090
+ seriesGuess: primary,
1091
+ seriesGuessAliases: aliases.length > 0 ? aliases : [primary],
1092
+ chapter,
1093
+ chapterRangeEnd,
1094
+ volume,
1095
+ volumeRangeEnd,
1096
+ group,
1097
+ formatHints
1098
+ };
1099
+ }
1100
+ function pubDateToIso(raw) {
1101
+ if (raw) {
1102
+ const d = new Date(raw);
1103
+ if (!Number.isNaN(d.getTime())) return d.toISOString();
1104
+ }
1105
+ return (/* @__PURE__ */ new Date()).toISOString();
1106
+ }
1107
+ function derivePageUrl(guid) {
1108
+ if (!guid) return null;
1109
+ const trimmed = guid.trim();
1110
+ if (trimmed.length === 0) return null;
1111
+ if (/^https?:\/\/[^/]+\/view\/[^/?#]+/i.test(trimmed)) return trimmed;
1112
+ return null;
1113
+ }
1114
+ function deriveExternalReleaseId(guid, link, infoHash, title, pubDate) {
1115
+ if (guid && guid.trim().length > 0) return guid.trim();
1116
+ if (link && link.trim().length > 0) return link.trim();
1117
+ if (infoHash && infoHash.length > 0) return `urn:btih:${infoHash}`;
1118
+ const fallback = `${title}|${pubDate ?? ""}`;
1119
+ let h = 5381;
1120
+ for (let i = 0; i < fallback.length; i++) {
1121
+ h = (h << 5) + h + fallback.charCodeAt(i) | 0;
1122
+ }
1123
+ return `t:${(h >>> 0).toString(36)}`;
1124
+ }
1125
+ function parseItem(itemXml) {
1126
+ const title = extractTagText(itemXml, "title");
1127
+ if (!title) return null;
1128
+ const link = extractTagText(itemXml, "link");
1129
+ const guid = extractTagText(itemXml, "guid");
1130
+ const pubDate = extractTagText(itemXml, "pubDate");
1131
+ const infoHashRaw = extractTagText(itemXml, "nyaa:infoHash");
1132
+ const infoHash = infoHashRaw ? infoHashRaw.toLowerCase().trim() : null;
1133
+ const parsedTitle = parseTitle(title);
1134
+ if (parsedTitle === null) return null;
1135
+ return {
1136
+ externalReleaseId: deriveExternalReleaseId(guid, link, infoHash, title, pubDate),
1137
+ title,
1138
+ seriesGuess: parsedTitle.seriesGuess,
1139
+ seriesGuessAliases: parsedTitle.seriesGuessAliases,
1140
+ chapter: parsedTitle.chapter,
1141
+ chapterRangeEnd: parsedTitle.chapterRangeEnd,
1142
+ volume: parsedTitle.volume,
1143
+ volumeRangeEnd: parsedTitle.volumeRangeEnd,
1144
+ group: parsedTitle.group,
1145
+ formatHints: parsedTitle.formatHints,
1146
+ link: link ?? "",
1147
+ pageUrl: derivePageUrl(guid),
1148
+ infoHash,
1149
+ observedAt: pubDateToIso(pubDate)
1150
+ };
1151
+ }
1152
+ function parseFeed(xml) {
1153
+ return splitItems(xml).map(parseItem).filter((i) => i !== null);
1154
+ }
1155
+
1156
+ // src/index.ts
1157
+ var logger = createLogger({ name: manifest.name, level: "info" });
1158
+ var state = {
1159
+ hostRpc: null,
1160
+ subscriptions: [],
1161
+ requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
1162
+ minConfidence: DEFAULT_MIN_CONFIDENCE,
1163
+ baseUrl: null
1164
+ };
1165
+ function _resetState() {
1166
+ state.hostRpc = null;
1167
+ state.subscriptions = [];
1168
+ state.requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
1169
+ state.minConfidence = DEFAULT_MIN_CONFIDENCE;
1170
+ state.baseUrl = null;
1171
+ }
1172
+ async function listTracked(rpc, sourceId, offset, limit) {
1173
+ return rpc.call(RELEASES_METHODS.LIST_TRACKED, {
1174
+ sourceId,
1175
+ offset,
1176
+ limit
1177
+ });
1178
+ }
1179
+ async function recordCandidate(rpc, sourceId, candidate) {
1180
+ try {
1181
+ return await rpc.call(RELEASES_METHODS.RECORD, {
1182
+ sourceId,
1183
+ candidate
1184
+ });
1185
+ } catch (err) {
1186
+ if (err instanceof HostRpcError) {
1187
+ logger.warn(
1188
+ `record failed for ${candidate.externalReleaseId}: ${err.message} (code ${err.code})`
1189
+ );
1190
+ } else {
1191
+ const msg = err instanceof Error ? err.message : "unknown error";
1192
+ logger.warn(`record failed for ${candidate.externalReleaseId}: ${msg}`);
1193
+ }
1194
+ return null;
1195
+ }
1196
+ }
1197
+ async function fetchAllTracked(rpc, sourceId) {
1198
+ const out = [];
1199
+ const pageSize = 200;
1200
+ let offset = 0;
1201
+ while (true) {
1202
+ const page = await listTracked(rpc, sourceId, offset, pageSize);
1203
+ for (const entry of page.tracked) {
1204
+ const aliases = entry.aliases ?? [];
1205
+ if (aliases.length === 0) continue;
1206
+ out.push({ seriesId: entry.seriesId, aliases });
1207
+ }
1208
+ if (page.nextOffset === void 0 || page.tracked.length === 0) return out;
1209
+ offset = page.nextOffset;
1210
+ }
1211
+ }
1212
+ function toCandidate(match, item, subscription) {
1213
+ const formatHints = { ...item.formatHints };
1214
+ if (item.chapterRangeEnd !== null) {
1215
+ formatHints.chapterRangeEnd = item.chapterRangeEnd;
1216
+ }
1217
+ if (item.volumeRangeEnd !== null) {
1218
+ formatHints.volumeRangeEnd = item.volumeRangeEnd;
1219
+ }
1220
+ formatHints.subscription = `${subscription.kind}:${subscription.identifier}`;
1221
+ const torrentLink = item.link.length > 0 ? item.link : null;
1222
+ const payloadUrl = item.pageUrl ?? torrentLink ?? `urn:nyaa:${item.externalReleaseId}`;
1223
+ const hasDistinctMedia = item.pageUrl !== null && torrentLink !== null;
1224
+ return {
1225
+ seriesMatch: {
1226
+ codexSeriesId: match.seriesId,
1227
+ confidence: match.confidence,
1228
+ reason: match.reason
1229
+ },
1230
+ externalReleaseId: item.externalReleaseId,
1231
+ chapter: item.chapter,
1232
+ volume: item.volume,
1233
+ language: "en",
1234
+ groupOrUploader: item.group ?? (subscription.kind === "user" ? subscription.identifier : null),
1235
+ payloadUrl,
1236
+ ...hasDistinctMedia ? { mediaUrl: torrentLink, mediaUrlKind: "torrent" } : {},
1237
+ infoHash: item.infoHash,
1238
+ formatHints,
1239
+ observedAt: item.observedAt
1240
+ };
1241
+ }
1242
+ async function pollSubscription(rpc, sourceId, subscription, candidates, options) {
1243
+ const result = await fetchSubscriptionFeed(subscription, options.previousEtag, null, {
1244
+ fetchImpl: options.fetchImpl,
1245
+ timeoutMs: options.timeoutMs,
1246
+ ...options.baseUrl ? { baseUrl: options.baseUrl } : {}
1247
+ });
1248
+ if (result.kind === "notModified") {
1249
+ return {
1250
+ subscription,
1251
+ fetched: true,
1252
+ notModified: true,
1253
+ parsed: 0,
1254
+ matched: 0,
1255
+ recorded: 0,
1256
+ deduped: 0,
1257
+ upstreamStatus: 304,
1258
+ etag: null,
1259
+ error: ""
1260
+ };
1261
+ }
1262
+ if (result.kind === "error") {
1263
+ return {
1264
+ subscription,
1265
+ fetched: false,
1266
+ notModified: false,
1267
+ parsed: 0,
1268
+ matched: 0,
1269
+ recorded: 0,
1270
+ deduped: 0,
1271
+ upstreamStatus: result.status,
1272
+ etag: null,
1273
+ error: result.message
1274
+ };
1275
+ }
1276
+ const items = parseFeed(result.body);
1277
+ let matched = 0;
1278
+ let recorded = 0;
1279
+ let deduped = 0;
1280
+ for (const item of items) {
1281
+ const guesses = item.seriesGuessAliases.length > 0 ? item.seriesGuessAliases : [item.seriesGuess];
1282
+ const m = matchSeriesAny(guesses, candidates, {
1283
+ fuzzyFloor: options.minConfidence
1284
+ });
1285
+ if (m === null) continue;
1286
+ matched++;
1287
+ const candidate = toCandidate(m, item, subscription);
1288
+ const outcome = await recordCandidate(rpc, sourceId, candidate);
1289
+ if (!outcome) continue;
1290
+ if (outcome.deduped) {
1291
+ deduped++;
1292
+ } else {
1293
+ recorded++;
1294
+ }
1295
+ }
1296
+ return {
1297
+ subscription,
1298
+ fetched: true,
1299
+ notModified: false,
1300
+ parsed: items.length,
1301
+ matched,
1302
+ recorded,
1303
+ deduped,
1304
+ upstreamStatus: 200,
1305
+ etag: result.etag,
1306
+ error: ""
1307
+ };
1308
+ }
1309
+ function resolveSubscription(params) {
1310
+ const cfg = params.config;
1311
+ const fromConfig = cfg?.subscription;
1312
+ if (fromConfig && typeof fromConfig === "object") {
1313
+ const obj = fromConfig;
1314
+ const kind = obj.kind;
1315
+ const identifier = obj.identifier;
1316
+ if (typeof identifier === "string" && identifier.length > 0 && (kind === "user" || kind === "query" || kind === "params")) {
1317
+ return { kind, identifier };
1318
+ }
1319
+ }
1320
+ if (typeof params.sourceKey === "string" && params.sourceKey.length > 0) {
1321
+ return sourceKeyToSubscription(params.sourceKey);
1322
+ }
1323
+ return null;
1324
+ }
1325
+ async function poll(params, rpc) {
1326
+ const sourceId = params.sourceId;
1327
+ const subscription = resolveSubscription(params);
1328
+ if (subscription === null) {
1329
+ logger.warn(`source=${sourceId} no resolvable subscription on poll request; skipping`);
1330
+ return { notModified: false, upstreamStatus: 200 };
1331
+ }
1332
+ const tracked = await fetchAllTracked(rpc, sourceId);
1333
+ if (tracked.length === 0) {
1334
+ logger.info(`no tracked series with aliases for source=${sourceId}`);
1335
+ return { notModified: false, upstreamStatus: 200 };
1336
+ }
1337
+ const outcome = await pollSubscription(rpc, sourceId, subscription, tracked, {
1338
+ previousEtag: params.etag ?? null,
1339
+ timeoutMs: state.requestTimeoutMs,
1340
+ minConfidence: state.minConfidence,
1341
+ ...state.baseUrl ? { baseUrl: state.baseUrl } : {}
1342
+ });
1343
+ if (outcome.error) {
1344
+ logger.warn(
1345
+ `source=${sourceId} ${subscription.kind}:${subscription.identifier}: ${outcome.error} (status ${outcome.upstreamStatus})`
1346
+ );
1347
+ }
1348
+ logger.info(
1349
+ `poll complete: source=${sourceId} subscription=${subscription.kind}:${subscription.identifier} tracked=${tracked.length} parsed=${outcome.parsed} matched=${outcome.matched} recorded=${outcome.recorded} deduped=${outcome.deduped} status=${outcome.upstreamStatus}${outcome.notModified ? " (304)" : ""}`
1350
+ );
1351
+ return {
1352
+ notModified: outcome.notModified,
1353
+ upstreamStatus: outcome.upstreamStatus,
1354
+ parsed: outcome.parsed,
1355
+ matched: outcome.matched,
1356
+ recorded: outcome.recorded,
1357
+ deduped: outcome.deduped,
1358
+ ...outcome.etag !== null ? { etag: outcome.etag } : {}
1359
+ };
1360
+ }
1361
+ async function registerSources(rpc, subscriptions) {
1362
+ const sources = subscriptions.map((sub) => ({
1363
+ sourceKey: subscriptionToSourceKey(sub),
1364
+ displayName: displayNameFor(sub),
1365
+ kind: "rss-uploader",
1366
+ config: { subscription: { kind: sub.kind, identifier: sub.identifier } }
1367
+ }));
1368
+ const maxAttempts = 5;
1369
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1370
+ try {
1371
+ return await rpc.call(
1372
+ RELEASES_METHODS.REGISTER_SOURCES,
1373
+ { sources }
1374
+ );
1375
+ } catch (err) {
1376
+ const isMethodNotFound = err instanceof HostRpcError && err.code === -32601;
1377
+ if (isMethodNotFound && attempt < maxAttempts) {
1378
+ await new Promise((r) => setTimeout(r, 50 * attempt));
1379
+ continue;
1380
+ }
1381
+ const reason = err instanceof Error ? err.message : String(err);
1382
+ logger.error(`register_sources failed: ${reason}`);
1383
+ return null;
1384
+ }
1385
+ }
1386
+ return null;
1387
+ }
1388
+ function displayNameFor(sub) {
1389
+ if (sub.kind === "user") return `Nyaa: ${sub.identifier}`;
1390
+ if (sub.kind === "query") return `Nyaa search: ${sub.identifier}`;
1391
+ return `Nyaa params: ${sub.identifier}`;
1392
+ }
1393
+ createReleaseSourcePlugin({
1394
+ manifest,
1395
+ provider: {
1396
+ async poll(params) {
1397
+ if (!state.hostRpc) {
1398
+ throw new Error("Plugin not initialized: hostRpc client missing");
1399
+ }
1400
+ return poll(params, state.hostRpc);
1401
+ }
1402
+ },
1403
+ logLevel: "info",
1404
+ async onInitialize(params) {
1405
+ state.hostRpc = params.hostRpc;
1406
+ const ac = params.adminConfig ?? {};
1407
+ state.subscriptions = parseSubscriptionList(ac.uploaders);
1408
+ if (typeof ac.requestTimeoutMs === "number" && Number.isFinite(ac.requestTimeoutMs)) {
1409
+ state.requestTimeoutMs = Math.max(1e3, Math.min(ac.requestTimeoutMs, 6e4));
1410
+ }
1411
+ if (typeof ac.baseUrl === "string" && ac.baseUrl.trim().length > 0) {
1412
+ state.baseUrl = ac.baseUrl.trim();
1413
+ }
1414
+ logger.info(
1415
+ `initialized: subscriptions=${state.subscriptions.length} timeoutMs=${state.requestTimeoutMs} minConfidence=${state.minConfidence}`
1416
+ );
1417
+ queueMicrotask(() => {
1418
+ void registerSources(params.hostRpc, state.subscriptions).then((result) => {
1419
+ if (result) {
1420
+ logger.info(`register_sources: registered=${result.registered} pruned=${result.pruned}`);
1421
+ }
1422
+ });
1423
+ });
1424
+ }
1425
+ });
1426
+ logger.info("Nyaa release-source plugin started");
1427
+ export {
1428
+ _resetState,
1429
+ fetchAllTracked,
1430
+ pollSubscription,
1431
+ registerSources
1432
+ };
1433
+ //# sourceMappingURL=index.js.map