@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 +1433 -0
- package/dist/index.js.map +7 -0
- package/package.json +52 -0
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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/'/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
|