@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
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../sdk-typescript/src/types/rpc.ts", "../../sdk-typescript/src/errors.ts", "../../sdk-typescript/src/request-context.ts", "../../sdk-typescript/src/host-rpc.ts", "../../sdk-typescript/src/logger.ts", "../../sdk-typescript/src/server.ts", "../../sdk-typescript/src/storage.ts", "../../sdk-typescript/src/types/releases.ts", "../src/fetcher.ts", "../package.json", "../src/manifest.ts", "../src/matcher.ts", "../src/parser.ts", "../src/index.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * JSON-RPC 2.0 types for plugin communication\n */\n\nexport interface JsonRpcRequest {\n jsonrpc: \"2.0\";\n id: string | number | null;\n method: string;\n params?: unknown;\n /**\n * Reverse-RPC only: id of the forward call this plugin is currently\n * servicing. Tells the host to route the reverse-RPC back to the\n * originating caller's task so emitted events land in that caller's\n * recording broadcaster (and replay correctly in distributed\n * deployments). The SDK stamps this automatically via\n * `AsyncLocalStorage` \u2014 plugin authors don't set it.\n */\n parentRequestId?: string | number | null;\n}\n\nexport interface JsonRpcSuccessResponse {\n jsonrpc: \"2.0\";\n id: string | number | null;\n result: unknown;\n}\n\nexport interface JsonRpcErrorResponse {\n jsonrpc: \"2.0\";\n id: string | number | null;\n error: JsonRpcError;\n}\n\nexport interface JsonRpcError {\n code: number;\n message: string;\n data?: unknown;\n}\n\nexport type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;\n\n/**\n * Standard JSON-RPC error codes\n */\nexport const JSON_RPC_ERROR_CODES = {\n /** Invalid JSON was received */\n PARSE_ERROR: -32700,\n /** The JSON sent is not a valid Request object */\n INVALID_REQUEST: -32600,\n /** The method does not exist / is not available */\n METHOD_NOT_FOUND: -32601,\n /** Invalid method parameter(s) */\n INVALID_PARAMS: -32602,\n /** Internal JSON-RPC error */\n INTERNAL_ERROR: -32603,\n} as const;\n\n/**\n * Plugin-specific error codes (in the -32000 to -32099 range)\n */\nexport const PLUGIN_ERROR_CODES = {\n /** Rate limited by external API */\n RATE_LIMITED: -32001,\n /** Resource not found (e.g., series ID doesn't exist) */\n NOT_FOUND: -32002,\n /** Authentication failed (invalid credentials) */\n AUTH_FAILED: -32003,\n /** External API error */\n API_ERROR: -32004,\n /** Plugin configuration error */\n CONFIG_ERROR: -32005,\n} as const;\n", "/**\n * Plugin error classes for structured error handling\n */\n\nimport { type JsonRpcError, PLUGIN_ERROR_CODES } from \"./types/rpc.js\";\n\n/**\n * Base class for plugin errors that map to JSON-RPC errors\n */\nexport abstract class PluginError extends Error {\n abstract readonly code: number;\n readonly data?: unknown;\n\n constructor(message: string, data?: unknown) {\n super(message);\n this.name = this.constructor.name;\n this.data = data;\n }\n\n /**\n * Convert to JSON-RPC error format\n */\n toJsonRpcError(): JsonRpcError {\n return {\n code: this.code,\n message: this.message,\n data: this.data,\n };\n }\n}\n\n/**\n * Thrown when rate limited by an external API\n */\nexport class RateLimitError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.RATE_LIMITED;\n /** Seconds to wait before retrying */\n readonly retryAfterSeconds: number;\n\n constructor(retryAfterSeconds: number, message?: string) {\n super(message ?? `Rate limited, retry after ${retryAfterSeconds}s`, {\n retryAfterSeconds,\n });\n this.retryAfterSeconds = retryAfterSeconds;\n }\n}\n\n/**\n * Thrown when a requested resource is not found\n */\nexport class NotFoundError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.NOT_FOUND;\n}\n\n/**\n * Thrown when authentication fails (invalid credentials)\n */\nexport class AuthError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.AUTH_FAILED;\n\n constructor(message?: string) {\n super(message ?? \"Authentication failed\");\n }\n}\n\n/**\n * Thrown when an external API returns an error\n */\nexport class ApiError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.API_ERROR;\n readonly statusCode: number | undefined;\n\n constructor(message: string, statusCode?: number) {\n super(message, statusCode !== undefined ? { statusCode } : undefined);\n this.statusCode = statusCode;\n }\n}\n\n/**\n * Thrown when the plugin is misconfigured\n */\nexport class ConfigError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.CONFIG_ERROR;\n}\n", "/**\n * Async-local context for the currently-handled forward request.\n *\n * When the SDK dispatches a forward call (e.g. `releases/poll`), it stores\n * the call's `id` in this context for the duration of the handler. Any\n * reverse-RPC the plugin makes while servicing that call (e.g.\n * `releases/record` via `HostRpcClient.call`) reads the id and stamps it as\n * `parentRequestId` on the outgoing request.\n *\n * The host uses `parentRequestId` to route the reverse-RPC back to the\n * originating caller's tokio task, so emitted events land in the recording\n * broadcaster scoped to that task and replay correctly in distributed\n * deployments. Without this stamping, plugins that emit events via\n * reverse-RPC would silently lose them on the worker.\n *\n * Plugin authors don't interact with this directly. The SDK's request\n * dispatch (`server.ts`) sets it; `HostRpcClient.call` reads it.\n */\n\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nconst store = new AsyncLocalStorage<string | number | null>();\n\n/**\n * Run `fn` with `forwardRequestId` as the current parent. Calls to\n * `currentParentRequestId()` made inside `fn` (or anything it awaits) will\n * see this value.\n */\nexport function runWithParentRequestId<T>(\n forwardRequestId: string | number | null,\n fn: () => Promise<T>,\n): Promise<T> {\n return store.run(forwardRequestId, fn);\n}\n\n/**\n * Snapshot the current forward request id, or `undefined` if no forward\n * request is on the call stack (e.g. background timers in the plugin that\n * fire reverse-RPCs outside a forward-call context \u2014 those won't be replay-\n * eligible, by design, since they don't belong to any task).\n */\nexport function currentParentRequestId(): string | number | null | undefined {\n return store.getStore();\n}\n", "/**\n * Generic host reverse-RPC client.\n *\n * Plugins use this to call host methods outside the storage namespace \u2014\n * notably `releases/list_tracked`, `releases/record`,\n * `releases/source_state/get`, and `releases/source_state/set`. The class is\n * intentionally generic so future reverse-RPC namespaces can reuse it\n * without a per-namespace client.\n *\n * Wire-format and lifecycle mirror `PluginStorage`: send a JSON-RPC request\n * over stdout with a unique id, and resolve when the host's response with\n * the matching id arrives on stdin. The plugin server's main loop calls\n * `handleResponse(line)` on every incoming response; whichever client owns\n * the id resolves it (others no-op silently).\n *\n * The id counter starts at a high value (`1_000_000_000`) so it can never\n * collide with `PluginStorage`'s sequence (`1, 2, 3, ...`). This means the\n * dispatch in the server doesn't need to know which client a response\n * belongs to \u2014 it can fan out to both, and at most one will match.\n */\n\nimport { currentParentRequestId } from \"./request-context.js\";\nimport type { JsonRpcError, JsonRpcRequest } from \"./types/rpc.js\";\n\n/** Write function signature for sending JSON-RPC requests. */\ntype WriteFn = (line: string) => void;\n\n/**\n * Error thrown when a reverse-RPC call fails (host returned a JSON-RPC error,\n * or the client was canceled).\n */\nexport class HostRpcError extends Error {\n constructor(\n message: string,\n public readonly code: number,\n public readonly data?: unknown,\n ) {\n super(message);\n this.name = \"HostRpcError\";\n }\n}\n\n/**\n * Generic reverse-RPC client. Construct one per plugin instance and pass it\n * around via `InitializeParams`.\n */\nexport class HostRpcClient {\n // Start the counter high so it can't collide with PluginStorage's id space.\n // `Number.MAX_SAFE_INTEGER` is far above this, so we have plenty of room\n // before wrapping (and we never expect a single plugin lifetime to issue\n // more than ~9 quintillion calls).\n private nextId = 1_000_000_000;\n private pendingRequests = new Map<\n number,\n {\n resolve: (value: unknown) => void;\n reject: (error: Error) => void;\n }\n >();\n private writeFn: WriteFn;\n\n /**\n * @param writeFn - Optional custom write function (defaults to\n * `process.stdout.write`). Useful for testing.\n */\n constructor(writeFn?: WriteFn) {\n this.writeFn =\n writeFn ??\n ((line: string) => {\n process.stdout.write(line);\n });\n }\n\n /**\n * Send a JSON-RPC request to the host and resolve with the result.\n *\n * @param method - JSON-RPC method name (e.g. `\"releases/list_tracked\"`).\n * @param params - Method-specific params. Pass `undefined` when the method\n * takes no params.\n */\n async call<T = unknown>(method: string, params?: unknown): Promise<T> {\n const id = this.nextId++;\n // Stamp the forward call we're inside so the host can route this\n // reverse-RPC back to the originating caller's task. Lifted from the\n // `request-context` async-local storage that `server.ts` sets around\n // every forward-request handler.\n const parent = currentParentRequestId();\n const request: JsonRpcRequest = {\n jsonrpc: \"2.0\",\n id,\n method,\n params,\n ...(parent !== undefined ? { parentRequestId: parent } : {}),\n };\n\n return new Promise<T>((resolve, reject) => {\n this.pendingRequests.set(id, {\n resolve: (v) => resolve(v as T),\n reject,\n });\n try {\n this.writeFn(`${JSON.stringify(request)}\\n`);\n } catch (err) {\n this.pendingRequests.delete(id);\n const message = err instanceof Error ? err.message : \"Unknown write error\";\n reject(new HostRpcError(`Failed to send request: ${message}`, -1));\n }\n });\n }\n\n /**\n * Process an incoming JSON-RPC response line. Returns `true` if this\n * client owned the response id and resolved it, `false` otherwise (so\n * other clients can try).\n *\n * Called by the plugin server's main loop on every response.\n */\n handleResponse(line: string): boolean {\n const trimmed = line.trim();\n if (!trimmed) return false;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(trimmed);\n } catch {\n return false;\n }\n\n const obj = parsed as Record<string, unknown>;\n if (obj.method !== undefined) return false; // not a response\n const rawId = obj.id;\n if (typeof rawId !== \"number\") return false;\n if (!this.pendingRequests.has(rawId)) return false;\n\n const pending = this.pendingRequests.get(rawId);\n if (!pending) return false;\n this.pendingRequests.delete(rawId);\n\n if (\"error\" in obj && obj.error) {\n const err = obj.error as JsonRpcError;\n pending.reject(new HostRpcError(err.message, err.code, err.data));\n } else {\n pending.resolve(obj.result);\n }\n return true;\n }\n\n /** Reject all pending requests (e.g. on shutdown). */\n cancelAll(): void {\n for (const [, pending] of this.pendingRequests) {\n pending.reject(new HostRpcError(\"Host RPC client stopped\", -1));\n }\n this.pendingRequests.clear();\n }\n}\n", "/**\n * Logging utilities for plugins\n *\n * IMPORTANT: Plugins must ONLY write to stderr for logging.\n * stdout is reserved for JSON-RPC communication.\n */\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nconst LOG_LEVELS: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3,\n};\n\nexport interface LoggerOptions {\n /** Plugin name to prefix log messages */\n name: string;\n /** Minimum log level (default: \"info\") */\n level?: LogLevel;\n /** Whether to include timestamps (default: true) */\n timestamps?: boolean;\n}\n\n/**\n * Logger that writes to stderr (safe for plugins)\n */\nexport class Logger {\n private readonly name: string;\n private readonly minLevel: number;\n private readonly timestamps: boolean;\n\n constructor(options: LoggerOptions) {\n this.name = options.name;\n this.minLevel = LOG_LEVELS[options.level ?? \"info\"];\n this.timestamps = options.timestamps ?? true;\n }\n\n private shouldLog(level: LogLevel): boolean {\n return LOG_LEVELS[level] >= this.minLevel;\n }\n\n private format(level: LogLevel, message: string, data?: unknown): string {\n const parts: string[] = [];\n\n if (this.timestamps) {\n parts.push(new Date().toISOString());\n }\n\n parts.push(`[${level.toUpperCase()}]`);\n parts.push(`[${this.name}]`);\n parts.push(message);\n\n if (data !== undefined) {\n if (data instanceof Error) {\n parts.push(`- ${data.message}`);\n if (data.stack) {\n parts.push(`\\n${data.stack}`);\n }\n } else if (typeof data === \"object\") {\n parts.push(`- ${JSON.stringify(data)}`);\n } else {\n parts.push(`- ${String(data)}`);\n }\n }\n\n return parts.join(\" \");\n }\n\n private log(level: LogLevel, message: string, data?: unknown): void {\n if (this.shouldLog(level)) {\n // Write to stderr (not stdout!) - stdout is for JSON-RPC only\n process.stderr.write(`${this.format(level, message, data)}\\n`);\n }\n }\n\n debug(message: string, data?: unknown): void {\n this.log(\"debug\", message, data);\n }\n\n info(message: string, data?: unknown): void {\n this.log(\"info\", message, data);\n }\n\n warn(message: string, data?: unknown): void {\n this.log(\"warn\", message, data);\n }\n\n error(message: string, data?: unknown): void {\n this.log(\"error\", message, data);\n }\n}\n\n/**\n * Create a logger for a plugin\n */\nexport function createLogger(options: LoggerOptions): Logger {\n return new Logger(options);\n}\n", "/**\n * Plugin server - handles JSON-RPC communication over stdio\n *\n * Provides factory functions for creating different plugin types.\n * All plugin types share a common base server that handles:\n * - stdin readline parsing\n * - JSON-RPC error handling\n * - initialize/ping/shutdown lifecycle methods\n *\n * Each plugin type adds its own method routing on top.\n */\n\nimport { createInterface } from \"node:readline\";\nimport { PluginError } from \"./errors.js\";\nimport { HostRpcClient } from \"./host-rpc.js\";\nimport { createLogger, type Logger } from \"./logger.js\";\nimport { runWithParentRequestId } from \"./request-context.js\";\nimport { PluginStorage } from \"./storage.js\";\nimport type {\n BookMetadataProvider,\n MetadataContentType,\n MetadataProvider,\n RecommendationProvider,\n ReleaseSourceProvider,\n SyncProvider,\n} from \"./types/capabilities.js\";\nimport type { PluginManifest, ReleaseSourceCapability } from \"./types/manifest.js\";\nimport type {\n BookMatchParams,\n BookSearchParams,\n MetadataGetParams,\n MetadataMatchParams,\n MetadataSearchParams,\n} from \"./types/protocol.js\";\nimport type {\n ProfileUpdateRequest,\n RecommendationDismissRequest,\n RecommendationRequest,\n} from \"./types/recommendations.js\";\nimport type { ReleasePollRequest } from \"./types/releases.js\";\nimport { JSON_RPC_ERROR_CODES, type JsonRpcRequest, type JsonRpcResponse } from \"./types/rpc.js\";\nimport type { SyncPullRequest, SyncPushRequest } from \"./types/sync.js\";\n\n// =============================================================================\n// Parameter Validation\n// =============================================================================\n\ninterface ValidationError {\n field: string;\n message: string;\n}\n\n/**\n * Validate that the required string fields are present and non-empty\n */\nfunction validateStringFields(params: unknown, fields: string[]): ValidationError | null {\n if (params === null || params === undefined) {\n return { field: \"params\", message: \"params is required\" };\n }\n if (typeof params !== \"object\") {\n return { field: \"params\", message: \"params must be an object\" };\n }\n\n const obj = params as Record<string, unknown>;\n for (const field of fields) {\n const value = obj[field];\n if (value === undefined || value === null) {\n return { field, message: `${field} is required` };\n }\n if (typeof value !== \"string\") {\n return { field, message: `${field} must be a string` };\n }\n if (value.trim() === \"\") {\n return { field, message: `${field} cannot be empty` };\n }\n }\n\n return null;\n}\n\n/**\n * Validate MetadataSearchParams\n */\nfunction validateSearchParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"query\"]);\n}\n\n/**\n * Validate MetadataGetParams\n */\nfunction validateGetParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"externalId\"]);\n}\n\n/**\n * Validate MetadataMatchParams\n */\nfunction validateMatchParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"title\"]);\n}\n\n/**\n * Validate BookSearchParams - requires either isbn or query\n */\nfunction validateBookSearchParams(params: unknown): ValidationError | null {\n if (params === null || params === undefined) {\n return { field: \"params\", message: \"params is required\" };\n }\n if (typeof params !== \"object\") {\n return { field: \"params\", message: \"params must be an object\" };\n }\n\n const obj = params as Record<string, unknown>;\n const hasIsbn = obj.isbn !== undefined && obj.isbn !== null && obj.isbn !== \"\";\n const hasQuery = obj.query !== undefined && obj.query !== null && obj.query !== \"\";\n\n if (!hasIsbn && !hasQuery) {\n return { field: \"isbn/query\", message: \"either isbn or query is required\" };\n }\n\n return null;\n}\n\n/**\n * Validate BookMatchParams\n */\nfunction validateBookMatchParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"title\"]);\n}\n\n/**\n * Create an INVALID_PARAMS error response\n */\nfunction invalidParamsError(id: string | number | null, error: ValidationError): JsonRpcResponse {\n return {\n jsonrpc: \"2.0\",\n id,\n error: {\n code: JSON_RPC_ERROR_CODES.INVALID_PARAMS,\n message: `Invalid params: ${error.message}`,\n data: { field: error.field },\n },\n };\n}\n\n// =============================================================================\n// Shared Types\n// =============================================================================\n\n/**\n * Initialize parameters received from Codex\n */\nexport interface InitializeParams {\n /** Admin-level plugin configuration (from plugin settings) */\n adminConfig?: Record<string, unknown>;\n /** Per-user plugin configuration (from user plugin settings) */\n userConfig?: Record<string, unknown>;\n /** Plugin credentials (API keys, tokens, etc.) */\n credentials?: Record<string, string>;\n /**\n * Per-user key-value storage client.\n *\n * Use this to persist data across plugin restarts (e.g., dismissed IDs,\n * cached profiles, user preferences). Storage is scoped per user-plugin\n * instance \u2014 the host resolves the user context automatically.\n */\n storage: PluginStorage;\n /**\n * Generic host reverse-RPC client.\n *\n * Use this to call host methods outside the storage namespace, notably\n * the `releases/*` methods (`releases/list_tracked`, `releases/record`,\n * `releases/source_state/get`, `releases/source_state/set`) for plugins\n * declaring the `releaseSource` capability.\n */\n hostRpc: HostRpcClient;\n}\n\n/**\n * A method router handles capability-specific JSON-RPC methods.\n * Returns a response for known methods, or null to indicate \"not my method\".\n */\ntype MethodRouter = (\n method: string,\n params: unknown,\n id: string | number | null,\n) => Promise<JsonRpcResponse | null>;\n\n// =============================================================================\n// Shared Plugin Server\n// =============================================================================\n\ninterface PluginServerOptions {\n manifest: PluginManifest;\n onInitialize?: ((params: InitializeParams) => void | Promise<void>) | undefined;\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\" | undefined;\n label?: string | undefined;\n router: MethodRouter;\n}\n\n/**\n * Shared plugin server that handles JSON-RPC communication over stdio.\n *\n * Handles the common lifecycle methods (initialize, ping, shutdown) and\n * delegates capability-specific methods to the provided router.\n */\nfunction createPluginServer(options: PluginServerOptions): void {\n const { manifest, onInitialize, logLevel = \"info\", label, router } = options;\n const logger = createLogger({ name: manifest.name, level: logLevel });\n const prefix = label ? `${label} plugin` : \"plugin\";\n const storage = new PluginStorage();\n const hostRpc = new HostRpcClient();\n\n logger.info(`Starting ${prefix}: ${manifest.displayName} v${manifest.version}`);\n\n const rl = createInterface({\n input: process.stdin,\n terminal: false,\n });\n\n rl.on(\"line\", (line) => {\n void handleLine(line, manifest, onInitialize, router, logger, storage, hostRpc);\n });\n\n rl.on(\"close\", () => {\n logger.info(\"stdin closed, shutting down\");\n storage.cancelAll();\n hostRpc.cancelAll();\n process.exit(0);\n });\n\n process.on(\"uncaughtException\", (error) => {\n logger.error(\"Uncaught exception\", error);\n process.exit(1);\n });\n\n process.on(\"unhandledRejection\", (reason) => {\n logger.error(\"Unhandled rejection\", reason);\n });\n}\n\n/**\n * Detect whether a parsed JSON object is a JSON-RPC response (not a request).\n *\n * A response has `id` and either `result` or `error`, but no `method`.\n * A request always has `method`.\n */\nfunction isJsonRpcResponse(obj: Record<string, unknown>): boolean {\n if (obj.method !== undefined) return false;\n if (obj.id === undefined || obj.id === null) return false;\n return \"result\" in obj || \"error\" in obj;\n}\n\nasync function handleLine(\n line: string,\n manifest: PluginManifest,\n onInitialize: ((params: InitializeParams) => void | Promise<void>) | undefined,\n router: MethodRouter,\n logger: Logger,\n storage: PluginStorage,\n hostRpc: HostRpcClient,\n): Promise<void> {\n const trimmed = line.trim();\n if (!trimmed) return;\n\n // Try to detect responses (storage or host-rpc) before full request handling.\n // Both come from the host on stdin \u2014 they have id + (result|error) but no\n // method field. The two clients use disjoint id ranges so each can claim\n // ownership without coordination; whichever owns the id resolves it.\n let parsed: Record<string, unknown> | undefined;\n try {\n parsed = JSON.parse(trimmed) as Record<string, unknown>;\n } catch {\n // Will be handled as a parse error below\n }\n\n if (parsed && isJsonRpcResponse(parsed)) {\n logger.debug(\"Routing reverse-RPC response\", { id: parsed.id });\n if (!hostRpc.handleResponse(trimmed)) {\n storage.handleResponse(trimmed);\n }\n return;\n }\n\n let id: string | number | null = null;\n\n try {\n const request = (parsed ?? JSON.parse(trimmed)) as JsonRpcRequest;\n id = request.id;\n\n logger.debug(`Received request: ${request.method}`, { id: request.id });\n\n // Run the request handler inside the parent-request async-local context.\n // Reverse-RPCs the handler issues via `HostRpcClient.call` will read this\n // and stamp `parentRequestId` so the host can route the call back to the\n // originating task. See `request-context.ts`.\n const response = await runWithParentRequestId(request.id, () =>\n handleRequest(request, manifest, onInitialize, router, logger, storage, hostRpc),\n );\n if (response !== null) {\n writeResponse(response);\n }\n } catch (error) {\n if (error instanceof SyntaxError) {\n writeResponse({\n jsonrpc: \"2.0\",\n id: null,\n error: {\n code: JSON_RPC_ERROR_CODES.PARSE_ERROR,\n message: \"Parse error: invalid JSON\",\n },\n });\n } else if (error instanceof PluginError) {\n writeResponse({\n jsonrpc: \"2.0\",\n id,\n error: error.toJsonRpcError(),\n });\n } else {\n const message = error instanceof Error ? error.message : \"Unknown error\";\n logger.error(\"Request failed\", error);\n writeResponse({\n jsonrpc: \"2.0\",\n id,\n error: {\n code: JSON_RPC_ERROR_CODES.INTERNAL_ERROR,\n message,\n },\n });\n }\n }\n}\n\nasync function handleRequest(\n request: JsonRpcRequest,\n manifest: PluginManifest,\n onInitialize: ((params: InitializeParams) => void | Promise<void>) | undefined,\n router: MethodRouter,\n logger: Logger,\n storage: PluginStorage,\n hostRpc: HostRpcClient,\n): Promise<JsonRpcResponse | null> {\n const { method, params, id } = request;\n\n // Common lifecycle methods\n switch (method) {\n case \"initialize\": {\n const initParams = (params ?? {}) as InitializeParams;\n // Inject the reverse-RPC clients so plugins can persist data and\n // call host-side methods (e.g. releases/list_tracked).\n initParams.storage = storage;\n initParams.hostRpc = hostRpc;\n if (onInitialize) {\n await onInitialize(initParams);\n }\n return { jsonrpc: \"2.0\", id, result: manifest };\n }\n\n case \"ping\":\n return { jsonrpc: \"2.0\", id, result: \"pong\" };\n\n case \"shutdown\": {\n logger.info(\"Shutdown requested\");\n storage.cancelAll();\n hostRpc.cancelAll();\n const response: JsonRpcResponse = { jsonrpc: \"2.0\", id, result: null };\n process.stdout.write(`${JSON.stringify(response)}\\n`, () => {\n process.exit(0);\n });\n // Response already written above; return null so handleLine skips the write\n return null;\n }\n }\n\n // Delegate to capability-specific router\n const response = await router(method, params, id);\n if (response !== null) {\n return response;\n }\n\n // Unknown method\n return {\n jsonrpc: \"2.0\",\n id,\n error: {\n code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,\n message: `Method not found: ${method}`,\n },\n };\n}\n\nfunction writeResponse(response: JsonRpcResponse): void {\n process.stdout.write(`${JSON.stringify(response)}\\n`);\n}\n\n// =============================================================================\n// Response Helpers\n// =============================================================================\n\nfunction methodNotFound(id: string | number | null, message: string): JsonRpcResponse {\n return {\n jsonrpc: \"2.0\",\n id,\n error: {\n code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,\n message,\n },\n };\n}\n\nfunction success(id: string | number | null, result: unknown): JsonRpcResponse {\n return { jsonrpc: \"2.0\", id, result };\n}\n\n// =============================================================================\n// Metadata Plugin\n// =============================================================================\n\n/**\n * Options for creating a metadata plugin\n */\nexport interface MetadataPluginOptions {\n /** Plugin manifest - must have capabilities.metadataProvider with content types */\n manifest: PluginManifest & {\n capabilities: { metadataProvider: MetadataContentType[] };\n };\n /** Series MetadataProvider implementation (required if \"series\" in metadataProvider) */\n provider?: MetadataProvider;\n /** Book MetadataProvider implementation (required if \"book\" in metadataProvider) */\n bookProvider?: BookMetadataProvider;\n /** Called when plugin receives initialize with credentials/config */\n onInitialize?: (params: InitializeParams) => void | Promise<void>;\n /** Log level (default: \"info\") */\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\";\n}\n\n/**\n * Create and run a metadata provider plugin\n *\n * Creates a plugin server that handles JSON-RPC communication over stdio.\n * The TypeScript compiler will ensure you implement all required methods.\n *\n * @example\n * ```typescript\n * import { createMetadataPlugin, type MetadataProvider } from \"@ashdev/codex-plugin-sdk\";\n *\n * const provider: MetadataProvider = {\n * async search(params) {\n * return {\n * results: [{\n * externalId: \"123\",\n * title: \"Example\",\n * alternateTitles: [],\n * relevanceScore: 0.95,\n * }],\n * };\n * },\n * async get(params) {\n * return {\n * externalId: params.externalId,\n * externalUrl: \"https://example.com/123\",\n * alternateTitles: [],\n * genres: [],\n * tags: [],\n * authors: [],\n * artists: [],\n * externalLinks: [],\n * };\n * },\n * };\n *\n * createMetadataPlugin({\n * manifest: {\n * name: \"my-plugin\",\n * displayName: \"My Plugin\",\n * version: \"1.0.0\",\n * description: \"Example plugin\",\n * author: \"Me\",\n * protocolVersion: \"1.0\",\n * capabilities: { metadataProvider: [\"series\"] },\n * },\n * provider,\n * });\n * ```\n */\nexport function createMetadataPlugin(options: MetadataPluginOptions): void {\n const { manifest, provider, bookProvider, onInitialize, logLevel } = options;\n\n // Validate that required providers are present based on manifest\n const contentTypes = manifest.capabilities.metadataProvider;\n if (contentTypes.includes(\"series\") && !provider) {\n throw new Error(\n \"Series metadata provider is required when 'series' is in metadataProvider capabilities\",\n );\n }\n if (contentTypes.includes(\"book\") && !bookProvider) {\n throw new Error(\n \"Book metadata provider is required when 'book' is in metadataProvider capabilities\",\n );\n }\n\n const router: MethodRouter = async (method, params, id) => {\n switch (method) {\n // Series metadata methods\n case \"metadata/series/search\": {\n if (!provider) return methodNotFound(id, \"This plugin does not support series metadata\");\n const err = validateSearchParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.search(params as MetadataSearchParams));\n }\n case \"metadata/series/get\": {\n if (!provider) return methodNotFound(id, \"This plugin does not support series metadata\");\n const err = validateGetParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.get(params as MetadataGetParams));\n }\n case \"metadata/series/match\": {\n if (!provider) return methodNotFound(id, \"This plugin does not support series metadata\");\n if (!provider.match) return methodNotFound(id, \"This plugin does not support series match\");\n const err = validateMatchParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.match(params as MetadataMatchParams));\n }\n\n // Book metadata methods\n case \"metadata/book/search\": {\n if (!bookProvider) return methodNotFound(id, \"This plugin does not support book metadata\");\n const err = validateBookSearchParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await bookProvider.search(params as BookSearchParams));\n }\n case \"metadata/book/get\": {\n if (!bookProvider) return methodNotFound(id, \"This plugin does not support book metadata\");\n const err = validateGetParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await bookProvider.get(params as MetadataGetParams));\n }\n case \"metadata/book/match\": {\n if (!bookProvider) return methodNotFound(id, \"This plugin does not support book metadata\");\n if (!bookProvider.match)\n return methodNotFound(id, \"This plugin does not support book match\");\n const err = validateBookMatchParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await bookProvider.match(params as BookMatchParams));\n }\n\n default:\n return null;\n }\n };\n\n createPluginServer({ manifest, onInitialize, logLevel, router });\n}\n\n// =============================================================================\n// Sync Plugin\n// =============================================================================\n\n/**\n * Options for creating a sync provider plugin\n */\nexport interface SyncPluginOptions {\n /** Plugin manifest - must have capabilities.userReadSync: true */\n manifest: PluginManifest & {\n capabilities: { userReadSync: true };\n };\n /** SyncProvider implementation */\n provider: SyncProvider;\n /** Called when plugin receives initialize with credentials/config */\n onInitialize?: (params: InitializeParams) => void | Promise<void>;\n /** Log level (default: \"info\") */\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\";\n}\n\n/**\n * Create and run a sync provider plugin\n *\n * Creates a plugin server that handles JSON-RPC communication over stdio\n * for sync operations (push/pull reading progress with external services).\n *\n * @example\n * ```typescript\n * import { createSyncPlugin, type SyncProvider } from \"@ashdev/codex-plugin-sdk\";\n *\n * const provider: SyncProvider = {\n * async getUserInfo() {\n * return { externalId: \"123\", username: \"user\" };\n * },\n * async pushProgress(params) {\n * return { success: [], failed: [] };\n * },\n * async pullProgress(params) {\n * return { entries: [], hasMore: false };\n * },\n * };\n *\n * createSyncPlugin({\n * manifest: {\n * name: \"my-sync-plugin\",\n * displayName: \"My Sync Plugin\",\n * version: \"1.0.0\",\n * description: \"Syncs reading progress\",\n * author: \"Me\",\n * protocolVersion: \"1.0\",\n * capabilities: { userReadSync: true },\n * },\n * provider,\n * });\n * ```\n */\nexport function createSyncPlugin(options: SyncPluginOptions): void {\n const { manifest, provider, onInitialize, logLevel } = options;\n\n const router: MethodRouter = async (method, params, id) => {\n switch (method) {\n case \"sync/getUserInfo\":\n return success(id, await provider.getUserInfo());\n case \"sync/pushProgress\":\n return success(id, await provider.pushProgress(params as SyncPushRequest));\n case \"sync/pullProgress\":\n return success(id, await provider.pullProgress(params as SyncPullRequest));\n case \"sync/status\": {\n if (!provider.status) return methodNotFound(id, \"This plugin does not support sync/status\");\n return success(id, await provider.status());\n }\n default:\n return null;\n }\n };\n\n createPluginServer({ manifest, onInitialize, logLevel, label: \"sync\", router });\n}\n\n// =============================================================================\n// Recommendation Plugin\n// =============================================================================\n\n/**\n * Options for creating a recommendation provider plugin\n */\nexport interface RecommendationPluginOptions {\n /** Plugin manifest - must have capabilities.userRecommendationProvider: true */\n manifest: PluginManifest & {\n capabilities: { userRecommendationProvider: true };\n };\n /** RecommendationProvider implementation */\n provider: RecommendationProvider;\n /** Called when plugin receives initialize with credentials/config */\n onInitialize?: (params: InitializeParams) => void | Promise<void>;\n /** Log level (default: \"info\") */\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\";\n}\n\n/**\n * Create and run a recommendation provider plugin\n *\n * Creates a plugin server that handles JSON-RPC communication over stdio\n * for recommendation operations (get recommendations, update profile, dismiss).\n */\nexport function createRecommendationPlugin(options: RecommendationPluginOptions): void {\n const { manifest, provider, onInitialize, logLevel } = options;\n\n const router: MethodRouter = async (method, params, id) => {\n switch (method) {\n case \"recommendations/get\":\n return success(id, await provider.get(params as RecommendationRequest));\n case \"recommendations/updateProfile\": {\n if (!provider.updateProfile)\n return methodNotFound(id, \"This plugin does not support recommendations/updateProfile\");\n return success(id, await provider.updateProfile(params as ProfileUpdateRequest));\n }\n case \"recommendations/clear\": {\n if (!provider.clear)\n return methodNotFound(id, \"This plugin does not support recommendations/clear\");\n return success(id, await provider.clear());\n }\n case \"recommendations/dismiss\": {\n if (!provider.dismiss)\n return methodNotFound(id, \"This plugin does not support recommendations/dismiss\");\n const err = validateStringFields(params, [\"externalId\"]);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.dismiss(params as RecommendationDismissRequest));\n }\n default:\n return null;\n }\n };\n\n createPluginServer({ manifest, onInitialize, logLevel, label: \"recommendation\", router });\n}\n\n// =============================================================================\n// Release Source Plugin\n// =============================================================================\n\n/**\n * Validate `releases/poll` parameters. Requires a non-empty `sourceId` string;\n * `etag` is optional.\n */\nfunction validateReleasePollParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"sourceId\"]);\n}\n\n/**\n * Options for creating a release-source plugin.\n */\nexport interface ReleaseSourcePluginOptions {\n /** Plugin manifest. Must declare `capabilities.releaseSource`. */\n manifest: PluginManifest & {\n capabilities: { releaseSource: ReleaseSourceCapability };\n };\n /** ReleaseSourceProvider implementation. */\n provider: ReleaseSourceProvider;\n /** Called when plugin receives initialize with credentials/config. */\n onInitialize?: (params: InitializeParams) => void | Promise<void>;\n /** Log level (default: \"info\"). */\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\";\n}\n\n/**\n * Create and run a release-source plugin.\n *\n * The host calls `releases/poll` on a schedule (per `release_sources` row).\n * The plugin returns candidates either inline (in the poll response) or by\n * streaming `releases/record` reverse-RPC calls during the poll. Both styles\n * are supported by the host.\n *\n * Plugins typically:\n * 1. Fetch tracked series via `releases/list_tracked`.\n * 2. For each series, GET the upstream feed (with `If-None-Match` from the\n * previous ETag).\n * 3. Parse + filter (language, group blocklist, etc.).\n * 4. Either return all candidates in the poll response or call\n * `releases/record` for each.\n * 5. Persist the new ETag via `releases/source_state/set` (or include it on\n * the poll response).\n *\n * @example\n * ```typescript\n * import { createReleaseSourcePlugin, type ReleaseSourceProvider } from \"@ashdev/codex-plugin-sdk\";\n *\n * const provider: ReleaseSourceProvider = {\n * async poll({ sourceId, etag }) {\n * // ...fetch + parse...\n * return { candidates: [...], etag: \"new-etag\" };\n * },\n * };\n *\n * createReleaseSourcePlugin({ manifest, provider });\n * ```\n */\nexport function createReleaseSourcePlugin(options: ReleaseSourcePluginOptions): void {\n const { manifest, provider, onInitialize, logLevel } = options;\n\n if (!manifest.capabilities.releaseSource) {\n throw new Error(\n \"manifest.capabilities.releaseSource is required for createReleaseSourcePlugin\",\n );\n }\n\n const router: MethodRouter = async (method, params, id) => {\n switch (method) {\n case \"releases/poll\": {\n const err = validateReleasePollParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.poll(params as ReleasePollRequest));\n }\n default:\n return null;\n }\n };\n\n createPluginServer({ manifest, onInitialize, logLevel, label: \"release-source\", router });\n}\n", "/**\n * Plugin Storage - key-value storage for per-user plugin data\n *\n * Storage is scoped per user-plugin instance. Plugins only specify a key;\n * the host resolves the user context from the connection.\n *\n * Plugins send storage requests as JSON-RPC calls to the host over stdout\n * and receive responses on stdin. This is the reverse of the normal\n * host-to-plugin request flow.\n *\n * @example\n * ```typescript\n * import { PluginStorage } from \"@ashdev/codex-plugin-sdk\";\n *\n * const storage = new PluginStorage();\n *\n * // Store data\n * await storage.set(\"taste_profile\", { genres: [\"action\", \"drama\"] });\n *\n * // Retrieve data\n * const data = await storage.get(\"taste_profile\");\n *\n * // Store with TTL (expires in 24 hours)\n * const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();\n * await storage.set(\"cache\", { items: [1, 2, 3] }, expires);\n *\n * // List all keys\n * const keys = await storage.list();\n *\n * // Delete a key\n * await storage.delete(\"cache\");\n *\n * // Clear all data\n * await storage.clear();\n * ```\n */\n\nimport type { JsonRpcError, JsonRpcRequest } from \"./types/rpc.js\";\n\n// =============================================================================\n// Storage Types\n// =============================================================================\n\n/** Response from storage/get */\nexport interface StorageGetResponse {\n /** The stored data, or null if key doesn't exist */\n data: unknown | null;\n /** Expiration timestamp (ISO 8601) if TTL was set */\n expiresAt?: string;\n}\n\n/** Response from storage/set */\nexport interface StorageSetResponse {\n /** Always true on success */\n success: boolean;\n}\n\n/** Response from storage/delete */\nexport interface StorageDeleteResponse {\n /** Whether the key existed and was deleted */\n deleted: boolean;\n}\n\n/** Individual key entry from storage/list */\nexport interface StorageKeyEntry {\n /** Storage key name */\n key: string;\n /** Expiration timestamp (ISO 8601) if TTL was set */\n expiresAt?: string;\n /** Last update timestamp (ISO 8601) */\n updatedAt: string;\n}\n\n/** Response from storage/list */\nexport interface StorageListResponse {\n /** All keys for this plugin instance (excluding expired) */\n keys: StorageKeyEntry[];\n}\n\n/** Response from storage/clear */\nexport interface StorageClearResponse {\n /** Number of entries deleted */\n deletedCount: number;\n}\n\n// =============================================================================\n// Storage Error\n// =============================================================================\n\n/** Error from a storage operation */\nexport class StorageError extends Error {\n constructor(\n message: string,\n public readonly code: number,\n public readonly data?: unknown,\n ) {\n super(message);\n this.name = \"StorageError\";\n }\n}\n\n// =============================================================================\n// Plugin Storage Client\n// =============================================================================\n\n/** Write function signature for sending JSON-RPC requests */\ntype WriteFn = (line: string) => void;\n\n/**\n * Client for plugin key-value storage.\n *\n * Sends JSON-RPC requests to the host process over stdout and reads\n * responses on stdin. Each request gets a unique ID so responses can\n * be correlated even if they arrive out of order.\n */\nexport class PluginStorage {\n private nextId = 1;\n private pendingRequests = new Map<\n string | number,\n {\n resolve: (value: unknown) => void;\n reject: (error: Error) => void;\n }\n >();\n private writeFn: WriteFn;\n\n /**\n * Create a new storage client.\n *\n * @param writeFn - Optional custom write function (defaults to process.stdout.write).\n * Useful for testing or custom transport layers.\n */\n constructor(writeFn?: WriteFn) {\n this.writeFn =\n writeFn ??\n ((line: string) => {\n process.stdout.write(line);\n });\n }\n\n /**\n * Get a value by key\n *\n * @param key - Storage key to retrieve\n * @returns The stored data and optional expiration, or null data if key doesn't exist\n */\n async get(key: string): Promise<StorageGetResponse> {\n return (await this.sendRequest(\"storage/get\", { key })) as StorageGetResponse;\n }\n\n /**\n * Set a value by key (upsert - creates or updates)\n *\n * @param key - Storage key\n * @param data - JSON-serializable data to store\n * @param expiresAt - Optional expiration timestamp (ISO 8601)\n * @returns Success indicator\n */\n async set(key: string, data: unknown, expiresAt?: string): Promise<StorageSetResponse> {\n const params: Record<string, unknown> = { key, data };\n if (expiresAt !== undefined) {\n params.expiresAt = expiresAt;\n }\n return (await this.sendRequest(\"storage/set\", params)) as StorageSetResponse;\n }\n\n /**\n * Delete a value by key\n *\n * @param key - Storage key to delete\n * @returns Whether the key existed and was deleted\n */\n async delete(key: string): Promise<StorageDeleteResponse> {\n return (await this.sendRequest(\"storage/delete\", { key })) as StorageDeleteResponse;\n }\n\n /**\n * List all keys for this plugin instance (excluding expired)\n *\n * @returns List of key entries with metadata\n */\n async list(): Promise<StorageListResponse> {\n return (await this.sendRequest(\"storage/list\", {})) as StorageListResponse;\n }\n\n /**\n * Clear all data for this plugin instance\n *\n * @returns Number of entries deleted\n */\n async clear(): Promise<StorageClearResponse> {\n return (await this.sendRequest(\"storage/clear\", {})) as StorageClearResponse;\n }\n\n /**\n * Handle an incoming JSON-RPC response line from the host.\n *\n * Call this method from your readline handler to deliver responses\n * back to pending storage requests.\n */\n handleResponse(line: string): void {\n const trimmed = line.trim();\n if (!trimmed) return;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(trimmed);\n } catch {\n // Not JSON - ignore\n return;\n }\n\n const obj = parsed as Record<string, unknown>;\n\n // Only handle responses (have \"result\" or \"error\", no \"method\")\n if (obj.method !== undefined) {\n // This is a host-to-plugin request, not a storage response - ignore\n return;\n }\n\n const id = obj.id;\n if (id === undefined || id === null) return;\n\n const pending = this.pendingRequests.get(id as string | number);\n if (!pending) return;\n\n this.pendingRequests.delete(id as string | number);\n\n if (\"error\" in obj && obj.error) {\n const err = obj.error as JsonRpcError;\n pending.reject(new StorageError(err.message, err.code, err.data));\n } else {\n pending.resolve(obj.result);\n }\n }\n\n /**\n * Cancel all pending requests (e.g. on shutdown).\n */\n cancelAll(): void {\n for (const [, pending] of this.pendingRequests) {\n pending.reject(new StorageError(\"Storage client stopped\", -1));\n }\n this.pendingRequests.clear();\n }\n\n // ===========================================================================\n // Internal\n // ===========================================================================\n\n private sendRequest(method: string, params: unknown): Promise<unknown> {\n const id = this.nextId++;\n\n const request: JsonRpcRequest = {\n jsonrpc: \"2.0\",\n id,\n method,\n params,\n };\n\n return new Promise((resolve, reject) => {\n this.pendingRequests.set(id, { resolve, reject });\n\n try {\n this.writeFn(`${JSON.stringify(request)}\\n`);\n } catch (err) {\n this.pendingRequests.delete(id);\n const message = err instanceof Error ? err.message : \"Unknown write error\";\n reject(new StorageError(`Failed to send request: ${message}`, -1));\n }\n });\n }\n}\n", "/**\n * Release-source protocol types - MUST match the Rust protocol exactly.\n *\n * Plugins implementing the `release_source` capability poll external sources\n * for new chapter/volume releases and emit `ReleaseCandidate` rows. The host\n * threshold-gates and dedups them through the `release_ledger` table.\n *\n * @see src/services/plugin/protocol.rs (`ReleasePollRequest`, `ReleasePollResponse`)\n * @see src/services/release/candidate.rs (`ReleaseCandidate`, `SeriesMatch`)\n * @see src/services/plugin/releases_handler.rs (reverse-RPC handlers)\n */\n\n// =============================================================================\n// Reverse-RPC method names (plugin -> host)\n// =============================================================================\n\n/**\n * Method names for the `releases/*` reverse-RPC namespace. Plugins call these\n * over the open RPC channel during `releases/poll` (or any other time).\n */\nexport const RELEASES_METHODS = {\n /** List tracked series, scoped to what the plugin's manifest declared. */\n LIST_TRACKED: \"releases/list_tracked\",\n /** Submit a candidate to the host's release ledger. */\n RECORD: \"releases/record\",\n /** Get persisted per-source state (etag, last_polled_at, last_error). */\n SOURCE_STATE_GET: \"releases/source_state/get\",\n /** Set persisted per-source state (etag only \u2014 other fields are host-owned). */\n SOURCE_STATE_SET: \"releases/source_state/set\",\n /**\n * Replace the set of `release_sources` rows owned by this plugin.\n *\n * Plugins call this from `onInitialize` (and after any config change, which\n * triggers a process restart that re-runs `onInitialize`). Each call carries\n * the plugin's full desired-state list; the host upserts every entry on\n * `(plugin_id, source_key)` and prunes rows whose `source_key` is not in\n * the request. User-managed fields (`enabled`, `pollIntervalS`) are\n * preserved across re-registrations so an admin's overrides aren't\n * trampled by a plugin restart.\n */\n REGISTER_SOURCES: \"releases/register_sources\",\n} as const;\n\n// =============================================================================\n// ReleaseCandidate (the wire shape plugins emit)\n// =============================================================================\n\n/**\n * Per-series match metadata attached to every candidate.\n *\n * - `codexSeriesId` is the host's UUID for the series. Plugins resolve this\n * from `releases/list_tracked` (don't invent series IDs).\n * - `confidence` (0.0..=1.0) tells the host how sure the plugin is about the\n * match. The host drops below-threshold candidates (default 0.7).\n * - `reason` is a short opaque string used for debugging/UI, e.g.\n * `\"mangaupdates_id\"`, `\"alias-exact\"`, `\"alias-fuzzy\"`.\n */\nexport interface SeriesMatch {\n codexSeriesId: string;\n confidence: number;\n reason: string;\n}\n\n/**\n * Release candidate emitted by a plugin.\n *\n * **Field semantics:**\n * - `externalReleaseId`: Stable per-source ID. The first dedup key.\n * `(sourceId, externalReleaseId)` is `UNIQUE` in `release_ledger`.\n * - `chapter` / `volume`: At least one should be set; both is fine for a\n * \"vol 15 covers ch 126-142\" case (the volume axis advances; the chapter\n * axis advances to the volume's last chapter only if the candidate\n * carries it). Decimals supported on `chapter` (e.g. 47.5).\n * - `language`: ISO 639-1 code, lowercase. Must be non-empty. The host's\n * `latest_known_*` advance gate uses this against the per-series\n * effective language list.\n * - `groupOrUploader`: Scanlation group (MangaUpdates) or torrent uploader\n * handle (Nyaa). Optional but strongly recommended.\n * - `payloadUrl`: The link the user follows to actually consume / acquire\n * the release. Must be non-empty. Conventionally a human-readable landing\n * page (Nyaa view page, MangaUpdates release page).\n * - `mediaUrl` / `mediaUrlKind`: Optional second URL describing how to\n * actually fetch the bits \u2014 a `.torrent` file, a magnet link, or a direct\n * download. Set both together; leave both unset for sources that only\n * surface a landing page. The UI renders a kind-specific icon next to\n * the standard external-link icon when these are present.\n * - `infoHash`: Torrent info_hash if applicable. Cross-source dedup key.\n * - `metadata` / `formatHints`: Free-form JSON for plugin-specific data\n * (Nyaa size in bytes, MangaUpdates \"is volume bundle\" flag, etc.).\n * - `observedAt`: When the plugin saw this entry. Used for ordering;\n * bounded by `MAX_FUTURE_SKEW_S` (1h) on the host side.\n */\nexport interface ReleaseCandidate {\n seriesMatch: SeriesMatch;\n externalReleaseId: string;\n chapter?: number | null;\n volume?: number | null;\n language: string;\n formatHints?: Record<string, unknown> | null;\n groupOrUploader?: string | null;\n payloadUrl: string;\n mediaUrl?: string | null;\n mediaUrlKind?: MediaUrlKind | null;\n infoHash?: string | null;\n metadata?: Record<string, unknown> | null;\n /** ISO-8601 timestamp. */\n observedAt: string;\n}\n\n/**\n * Classifies what `mediaUrl` points at so the UI can pick an appropriate\n * icon and the host can label it consistently across sources.\n *\n * - `torrent`: HTTP(S) URL to a `.torrent` file.\n * - `magnet`: `magnet:` URI.\n * - `direct`: HTTP(S) URL to the file itself (DDL host, CDN, etc.).\n * - `other`: anything else; render a generic download icon.\n */\nexport type MediaUrlKind = \"torrent\" | \"magnet\" | \"direct\" | \"other\";\n\n// =============================================================================\n// releases/list_tracked\n// =============================================================================\n\nexport interface ListTrackedRequest {\n sourceId: string;\n limit?: number;\n offset?: number;\n}\n\n/**\n * One tracked-series row scoped to what the plugin's manifest asked for.\n * Aliases are present only when `requiresAliases: true`; external IDs are\n * present only for sources the plugin listed in `requiresExternalIds`.\n */\nexport interface TrackedSeriesEntry {\n seriesId: string;\n aliases?: string[];\n /** Map keyed by external-ID source name (e.g. `{ mangaupdates: \"12345\" }`). */\n externalIds?: Record<string, string>;\n latestKnownChapter?: number | null;\n latestKnownVolume?: number | null;\n}\n\nexport interface ListTrackedResponse {\n tracked: TrackedSeriesEntry[];\n nextOffset?: number;\n}\n\n// =============================================================================\n// releases/record\n// =============================================================================\n\nexport interface RecordRequest {\n sourceId: string;\n candidate: ReleaseCandidate;\n}\n\nexport interface RecordResponse {\n ledgerId: string;\n /** True if the row deduped onto an existing ledger entry. */\n deduped: boolean;\n}\n\n// =============================================================================\n// releases/source_state\n// =============================================================================\n\nexport interface SourceStateGetRequest {\n sourceId: string;\n}\n\nexport interface SourceStateView {\n etag?: string;\n lastPolledAt?: string;\n lastError?: string;\n lastErrorAt?: string;\n}\n\nexport interface SourceStateSetRequest {\n sourceId: string;\n /** Only `etag` is plugin-writable; other fields are host-owned. */\n etag?: string;\n}\n\n// =============================================================================\n// releases/register_sources\n// =============================================================================\n\n/**\n * One source the plugin wants the host to materialize as a `release_sources`\n * row. The plugin owns the `sourceKey` namespace; the host treats it as an\n * opaque string for dedup keyed on `(pluginId, sourceKey)`.\n */\nexport interface RegisteredSourceInput {\n /**\n * Stable per-plugin identifier. Reuse the same key across calls so user\n * overrides (enabled, pollIntervalS) survive plugin restarts.\n */\n sourceKey: string;\n /** Human-readable label shown in the Release tracking settings UI. */\n displayName: string;\n /**\n * Must be one of the kinds the plugin declared in its\n * `releaseSource.kinds` capability \u2014 the host rejects anything else.\n */\n kind: \"rss-uploader\" | \"rss-series\" | \"api-feed\" | \"metadata-feed\";\n /**\n * Optional opaque per-source config snapshot persisted on the row. The\n * host doesn't interpret it; the plugin reads its own admin config\n * directly. Useful for surfacing \"what did this source originate from?\"\n * in the UI / logs.\n */\n config?: Record<string, unknown> | null;\n}\n\nexport interface RegisterSourcesRequest {\n sources: RegisteredSourceInput[];\n}\n\nexport interface RegisterSourcesResponse {\n /** Number of sources upserted (created or refreshed). */\n registered: number;\n /** Number of sources removed because they were not in the request. */\n pruned: number;\n}\n\n// =============================================================================\n// releases/poll (host -> plugin)\n// =============================================================================\n\n/**\n * Parameters for the host's call into a release-source plugin's\n * `releases/poll` handler. Carries the source row to poll plus any ETag the\n * plugin recorded on its previous poll, plus the plugin-defined source key\n * and per-source config snapshot so the plugin can dispatch directly without\n * a reverse-RPC roundtrip.\n */\nexport interface ReleasePollRequest {\n sourceId: string;\n /**\n * The same `sourceKey` the plugin passed to `releases/register_sources`.\n * Useful when one plugin process owns multiple source rows (e.g., one per\n * Nyaa uploader) and needs to know which one to poll.\n */\n sourceKey?: string;\n /**\n * Snapshot of `release_sources.config` for this row. Plugins that stash\n * per-source config on register can read it back here.\n */\n config?: Record<string, unknown> | null;\n etag?: string;\n}\n\n/**\n * Response from a `releases/poll` call.\n *\n * Plugins may also stream candidates over `releases/record` mid-poll; the\n * host treats both styles identically. Use `candidates` for plugins that\n * prefer to return everything at once.\n *\n * Plugins that stream via `releases/record` should also populate the\n * counter fields (`parsed`, `matched`, `recorded`, `deduped`). Without\n * them, the host can only see what came back in `candidates` and the\n * source's status badge will read \"Fetched 0 items\" no matter what\n * actually happened.\n */\nexport interface ReleasePollResponse {\n /** Optional batch of candidates the host should evaluate and ledger. */\n candidates?: ReleaseCandidate[];\n /** New ETag observed (e.g., from the upstream feed's `ETag` header). */\n etag?: string;\n /** Whether the upstream returned `304 Not Modified` (or equivalent). */\n notModified?: boolean;\n /** HTTP status code observed (used by host's per-host backoff). */\n upstreamStatus?: number;\n /**\n * Items the plugin parsed from the upstream feed before any matching\n * or threshold filtering. Streaming plugins should set this so the\n * host's `last_summary` reflects upstream activity, not just the shape\n * of the response payload.\n */\n parsed?: number;\n /**\n * Of those parsed, the count that matched a tracked-series alias (i.e.\n * became candidates the plugin then evaluated/streamed).\n */\n matched?: number;\n /**\n * Of those matched, the count actually inserted into the ledger\n * (excludes dedupes). For plugins that stream via `releases/record`,\n * this is the count of non-deduped record outcomes.\n */\n recorded?: number;\n /**\n * Of those matched, the count the host deduped onto an existing ledger\n * row. Optional; when omitted the host infers `matched - recorded`.\n */\n deduped?: number;\n}\n", "/**\n * Nyaa.si RSS fetcher.\n *\n * Wraps `fetch` with conditional GET (`If-None-Match` from a stored ETag, plus\n * `If-Modified-Since` from a stored Last-Modified header) and a hard timeout.\n *\n * Nyaa exposes two feed shapes we care about:\n * - User feed: `https://nyaa.si/?page=rss&u=<username>`\n * - Search feed: `https://nyaa.si/?page=rss&q=<query>` (with optional\n * filters; the plugin keeps it simple and lets aliases\n * do the matching)\n *\n * Returns a discriminated result so the caller can:\n * - act on `200`: parse the body, persist the new ETag.\n * - skip parse on `304`: nothing changed since last poll.\n * - report `429` / `5xx` upstream-status codes back to the host so the\n * per-host backoff layer can react.\n *\n * Network is the only side effect; nothing in here touches storage, the host,\n * or process state. That keeps it trivially testable: pass a mocked `fetch`\n * implementation and assert.\n */\n\n/** Discriminated fetch result. */\nexport type FetchResult =\n | { kind: \"ok\"; body: string; etag: string | null; lastModified: string | null; status: 200 }\n | { kind: \"notModified\"; status: 304 }\n | { kind: \"error\"; status: number; message: string };\n\nexport interface FetcherOptions {\n /** Custom `fetch` impl (for testing). Defaults to global `fetch`. */\n fetchImpl?: typeof fetch;\n /** Per-request timeout. Defaults to 10s. */\n timeoutMs?: number;\n /** Override base URL (for tests / mirrors). Defaults to `https://nyaa.si`. */\n baseUrl?: string;\n}\n\n/** Default Nyaa base URL. */\nexport const NYAA_BASE_URL = \"https://nyaa.si\";\n\n/**\n * One uploader subscription entry.\n *\n * Three shapes:\n * - `user` \u2014 pulls `?page=rss&u=<identifier>` (a Nyaa user feed).\n * - `query` \u2014 pulls `?page=rss&q=<identifier>` (a plain text search).\n * - `params` \u2014 pulls `?page=rss&<params>` where `<params>` is an\n * allowlisted set of Nyaa query keys (`q`, `c`, `f`). Used to express\n * category / filter combinations like the Literature \u2192 English-translated\n * view (`c=3_1`).\n */\nexport type UploaderSubscription =\n | { kind: \"user\"; identifier: string }\n | { kind: \"query\"; identifier: string }\n | { kind: \"params\"; identifier: string };\n\n/**\n * Keys allowed through from a `q:?\u2026` URL-style token. `page` is always\n * injected by the plugin and can't be overridden; anything not in this set\n * is silently dropped to keep the surface tight.\n */\nconst PARAMS_ALLOWLIST = new Set([\"q\", \"c\", \"f\", \"u\"]);\n\n/**\n * Parse a `q:?key=value&\u2026` body into a normalized, allowlisted query string.\n * Returns null when no allowlisted keys remain (caller drops the token).\n *\n * Normalization sorts params alphabetically so two tokens that differ only\n * in key order dedupe to the same identifier.\n */\nfunction parseUrlParams(body: string): { kind: \"user\" | \"params\"; identifier: string } | null {\n const params = new URLSearchParams(body);\n const kept: [string, string][] = [];\n for (const [rawKey, rawValue] of params.entries()) {\n const key = rawKey.toLowerCase();\n if (!PARAMS_ALLOWLIST.has(key)) continue;\n const value = rawValue.trim();\n if (value.length === 0) continue;\n kept.push([key, value]);\n }\n if (kept.length === 0) return null;\n\n // If the *only* allowlisted key is `u`, collapse to a plain user token so\n // `q:?u=1r0n` dedupes against the bare `1r0n` form and reuses the same\n // URL-building branch.\n if (kept.length === 1 && kept[0]?.[0] === \"u\") {\n return { kind: \"user\", identifier: kept[0][1] };\n }\n\n kept.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));\n const normalized = new URLSearchParams(kept).toString();\n return { kind: \"params\", identifier: normalized };\n}\n\n/**\n * Parse a single uploader subscription token.\n *\n * Tokens look like:\n * - `1r0n` \u2192 user feed\n * - `q:LuminousScans` \u2192 plain search query\n * - `query:Manga Group` \u2192 plain search query (long form)\n * - `q:?c=3_1&q=Berserk` \u2192 URL-style params (allowlisted: q, c, f, u)\n * - `query:?u=1r0n&c=3_1` \u2192 URL-style params, treated as user feed\n *\n * The leading `?` after `q:` / `query:` is the opt-in switch into URL mode,\n * which keeps `q:c=3_1&q=Berserk` (no `?`) parsing as a literal search term\n * for backwards compatibility.\n *\n * Empty / whitespace-only tokens return null (caller should drop them).\n */\nexport function parseSubscriptionToken(raw: string): UploaderSubscription | null {\n const trimmed = raw.trim();\n if (trimmed.length === 0) return null;\n\n // `q:` / `query:` prefix \u2192 search query, in either plain or URL-params form.\n const prefixMatch = trimmed.match(/^(q|query):(.*)$/i);\n if (prefixMatch) {\n const body = (prefixMatch[2] ?? \"\").trim();\n if (body.length === 0) return null;\n\n if (body.startsWith(\"?\")) {\n return parseUrlParams(body.slice(1));\n }\n return { kind: \"query\", identifier: body };\n }\n\n // Plain identifier \u2192 username feed.\n return { kind: \"user\", identifier: trimmed };\n}\n\n/**\n * Build a stable per-plugin source key for a subscription. Mirrors the\n * dedup key used in `parseSubscriptionList` so two ways of writing the\n * same subscription collapse to the same source row.\n *\n * Used by `releases/register_sources` (to declare the plugin-owned key for\n * each row) and as a fallback when reconstructing a subscription from a\n * source key whose `config` is missing. Lower-cased identifier preserves\n * the existing case-insensitive dedup behaviour.\n */\nexport function subscriptionToSourceKey(sub: UploaderSubscription): string {\n return `${sub.kind}:${sub.identifier.toLowerCase()}`;\n}\n\n/**\n * Inverse of `subscriptionToSourceKey`: parse a `kind:identifier` source key\n * back into a subscription. Returns null for unrecognized keys (older rows\n * from a previous plugin version, manual edits, etc.) so the caller can log\n * and skip without crashing the whole poll.\n *\n * Note: the identifier coming back is lower-cased (per the source key\n * convention). Nyaa is case-insensitive on usernames and search terms, so\n * the round-trip is lossless for our purposes.\n */\nexport function sourceKeyToSubscription(key: string): UploaderSubscription | null {\n const idx = key.indexOf(\":\");\n if (idx <= 0 || idx === key.length - 1) return null;\n const kind = key.slice(0, idx);\n const identifier = key.slice(idx + 1);\n if (kind === \"user\" || kind === \"query\" || kind === \"params\") {\n return { kind, identifier };\n }\n return null;\n}\n\n/**\n * Parse the admin `uploaders` config into a clean list of subscriptions.\n *\n * Accepts either a JSON array (preferred \u2014 what the manifest now declares) or\n * a legacy comma-separated string. The string path is retained so existing\n * stored configs and CLI/env-driven setups keep working without a migration.\n *\n * Skips empty tokens; preserves order; deduplicates case-insensitively.\n */\nexport function parseSubscriptionList(raw: unknown): UploaderSubscription[] {\n let tokens: string[];\n if (Array.isArray(raw)) {\n tokens = raw.filter((t): t is string => typeof t === \"string\");\n } else if (typeof raw === \"string\") {\n tokens = raw.split(\",\");\n } else {\n return [];\n }\n\n const seen = new Set<string>();\n const out: UploaderSubscription[] = [];\n for (const token of tokens) {\n const sub = parseSubscriptionToken(token);\n if (sub === null) continue;\n const key = subscriptionToSourceKey(sub);\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(sub);\n }\n return out;\n}\n\n/** Build the per-subscription RSS URL. */\nexport function feedUrl(\n subscription: UploaderSubscription,\n baseUrl: string = NYAA_BASE_URL,\n): string {\n const base = baseUrl.replace(/\\/+$/, \"\");\n if (subscription.kind === \"user\") {\n return `${base}/?page=rss&u=${encodeURIComponent(subscription.identifier)}`;\n }\n if (subscription.kind === \"query\") {\n return `${base}/?page=rss&q=${encodeURIComponent(subscription.identifier)}`;\n }\n // params: identifier is already a URL-encoded, allowlisted query string.\n return `${base}/?page=rss&${subscription.identifier}`;\n}\n\n/**\n * Conditional GET against an uploader-subscription RSS feed.\n *\n * @param subscription - The uploader subscription to fetch.\n * @param previousEtag - The ETag from the previous successful poll (if any).\n * @param previousLastModified - Optional Last-Modified header from the previous\n * poll. Nyaa often returns one but doesn't always honor `If-None-Match`;\n * sending both maximizes 304 hit rate.\n * @param opts - Fetcher options (custom fetch, timeout, base URL override).\n */\nexport async function fetchSubscriptionFeed(\n subscription: UploaderSubscription,\n previousEtag: string | null,\n previousLastModified: string | null,\n opts: FetcherOptions = {},\n): Promise<FetchResult> {\n const fetchImpl = opts.fetchImpl ?? globalThis.fetch;\n const timeoutMs = opts.timeoutMs ?? 10_000;\n const baseUrl = opts.baseUrl ?? NYAA_BASE_URL;\n\n const url = feedUrl(subscription, baseUrl);\n const headers: Record<string, string> = {\n Accept: \"application/rss+xml, application/xml;q=0.9, */*;q=0.5\",\n \"User-Agent\": \"Codex-ReleaseTracker/1.0 (+https://github.com/AshDevFr/codex)\",\n };\n if (previousEtag) {\n headers[\"If-None-Match\"] = previousEtag;\n }\n if (previousLastModified) {\n headers[\"If-Modified-Since\"] = previousLastModified;\n }\n\n const signal = AbortSignal.timeout(timeoutMs);\n\n let resp: Response;\n try {\n resp = await fetchImpl(url, { method: \"GET\", headers, signal });\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"Unknown fetch error\";\n return { kind: \"error\", status: 0, message: msg };\n }\n\n if (resp.status === 304) {\n return { kind: \"notModified\", status: 304 };\n }\n\n if (resp.status === 200) {\n const body = await resp.text();\n const etag = resp.headers.get(\"etag\");\n const lastModified = resp.headers.get(\"last-modified\");\n return { kind: \"ok\", body, etag, lastModified, status: 200 };\n }\n\n return {\n kind: \"error\",\n status: resp.status,\n message: `upstream returned ${resp.status} ${resp.statusText}`,\n };\n}\n", "{\n \"name\": \"@ashdev/codex-plugin-release-nyaa\",\n \"version\": \"1.18.0\",\n \"description\": \"Nyaa.si uploader-feed release-source plugin for Codex - announces torrent releases for tracked series, filtered by an admin allowlist of trusted uploaders\",\n \"main\": \"dist/index.js\",\n \"bin\": \"dist/index.js\",\n \"type\": \"module\",\n \"files\": [\n \"dist\",\n \"README.md\"\n ],\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/AshDevFr/codex.git\",\n \"directory\": \"plugins/release-nyaa\"\n },\n \"scripts\": {\n \"build\": \"esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'\",\n \"dev\": \"npm run build -- --watch\",\n \"clean\": \"rm -rf dist\",\n \"start\": \"node dist/index.js\",\n \"lint\": \"biome check .\",\n \"lint:fix\": \"biome check --write .\",\n \"typecheck\": \"tsc --noEmit\",\n \"test\": \"vitest run --passWithNoTests\",\n \"test:watch\": \"vitest\",\n \"prepublishOnly\": \"npm run lint && npm run build\"\n },\n \"keywords\": [\n \"codex\",\n \"plugin\",\n \"nyaa\",\n \"release-source\",\n \"manga\",\n \"torrent\"\n ],\n \"author\": \"Codex\",\n \"license\": \"MIT\",\n \"engines\": {\n \"node\": \">=22.0.0\"\n },\n \"dependencies\": {\n \"@ashdev/codex-plugin-sdk\": \"file:../sdk-typescript\"\n },\n \"devDependencies\": {\n \"@biomejs/biome\": \"^2.4.4\",\n \"@types/node\": \"^22.0.0\",\n \"esbuild\": \"^0.27.3\",\n \"typescript\": \"^5.9.3\",\n \"vitest\": \"^4.0.18\"\n }\n}\n", "import type { PluginManifest } from \"@ashdev/codex-plugin-sdk\";\nimport packageJson from \"../package.json\" with { type: \"json\" };\n\n/** Default per-fetch HTTP timeout. Nyaa is usually fast; 10s is generous. */\nexport const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;\n\n/**\n * Default minimum confidence threshold for emitted candidates. Nyaa matches\n * series via title parsing + alias comparison, which is fuzzier than the\n * external-ID match used by MangaUpdates. The host's threshold (default 0.7)\n * still filters at record time; this is the plugin-side floor below which we\n * don't even bother calling `releases/record`.\n */\nexport const DEFAULT_MIN_CONFIDENCE = 0.7;\n\nexport const manifest = {\n name: \"release-nyaa\",\n displayName: \"Nyaa Releases\",\n version: packageJson.version,\n description:\n \"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.\",\n author: \"Codex\",\n homepage: \"https://github.com/AshDevFr/codex\",\n protocolVersion: \"1.1\",\n capabilities: {\n releaseSource: {\n kinds: [\"rss-uploader\"],\n requiresAliases: true,\n canAnnounceChapters: true,\n canAnnounceVolumes: true,\n },\n },\n configSchema: {\n description:\n \"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.\",\n fields: [\n {\n key: \"uploaders\",\n label: \"Uploader Subscriptions\",\n description:\n \"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.\",\n type: \"string-array\" as const,\n required: false,\n default: [],\n example: [\"1r0n\", \"TankobonBlur\", \"q:LuminousScans\", \"q:?c=3_1&q=Berserk\"],\n },\n {\n key: \"requestTimeoutMs\",\n label: \"Request Timeout (ms)\",\n description:\n \"How long to wait for a single Nyaa RSS fetch before giving up. Defaults to 10000 (10 seconds).\",\n type: \"number\" as const,\n required: false,\n default: DEFAULT_REQUEST_TIMEOUT_MS,\n },\n {\n key: \"baseUrl\",\n label: \"Nyaa Base URL\",\n description:\n \"Override the Nyaa base URL. Useful for mirrors or for tests. Defaults to https://nyaa.si.\",\n type: \"string\" as const,\n required: false,\n default: \"https://nyaa.si\",\n example: \"https://nyaa.si\",\n },\n ],\n },\n userDescription:\n \"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.\",\n adminSetupInstructions:\n \"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.\",\n} as const satisfies PluginManifest & {\n capabilities: { releaseSource: { kinds: [\"rss-uploader\"] } };\n};\n", "/**\n * Alias matcher for Nyaa releases.\n *\n * Nyaa identifies series only by name in the torrent title \u2014 there's no\n * `nyaa_id` or other stable external ID that ties a release to a specific\n * series in our DB. So matching is a two-step pipeline:\n *\n * 1. Normalize the parsed `seriesGuess` and every alias the host returned\n * to a common shape (lowercase, alphanumeric + spaces only). This\n * mirrors the `normalize_alias` function on the host\n * ([src/db/entities/series_aliases.rs](src/db/entities/series_aliases.rs))\n * so a release whose normalized title exactly matches one of a series'\n * stored aliases lands at confidence 0.95.\n * 2. If no exact match, compute a token-level S\u00F8rensen-Dice similarity\n * against every candidate alias. The highest ratio wins, scaled into a\n * 0.7..0.85 confidence band; below the configured threshold we skip.\n *\n * The Dice ratio is more forgiving than edit distance for word-rearranged\n * titles (`\"Boruto Two Blue Vortex\"` vs. `\"Boruto - Two Blue Vortex\"`) while\n * still rejecting unrelated series at the threshold. We deliberately don't\n * wire a heavy fuzzy-match library; the surface area is small.\n */\n\n/** A tracked-series candidate with its raw aliases. */\nexport interface AliasCandidate {\n /** Codex series UUID. */\n seriesId: string;\n /** Raw aliases from `releases/list_tracked`. */\n aliases: string[];\n}\n\n/** A successful match. */\nexport interface AliasMatch {\n seriesId: string;\n confidence: number;\n /** Reason string surfaced in the SeriesMatch \u2014 \"alias-exact\" or \"alias-fuzzy\". */\n reason: string;\n /** The matched alias (raw form, for logging). */\n matchedAlias: string;\n}\n\n/**\n * Confidence assigned on an exact normalized match.\n *\n * Below 1.0 because we still don't have an external ID \u2014 a release titled\n * `\"X\"` could legitimately match multiple series with that alias. The host's\n * threshold treats this as a strong-but-not-certain signal.\n */\nexport const CONFIDENCE_EXACT = 0.95;\n\n/**\n * Floor below which fuzzy matches don't get emitted. The host's default\n * threshold is 0.7; we share that floor so plugin-side filtering doesn't\n * silently second-guess host config.\n */\nexport const DEFAULT_FUZZY_FLOOR = 0.7;\n\n/**\n * Anything below this Dice-coefficient is rejected outright (even before the\n * confidence floor kicks in). 0.85 lets through \"two-blue-vortex\" vs. \"two\n * blue vortex\" but kills \"naruto\" vs. \"boruto two blue vortex\".\n */\nexport const MIN_DICE_RATIO = 0.85;\n\n// ---------------------------------------------------------------------------\n// Normalization\n// ---------------------------------------------------------------------------\n\n/**\n * Normalize an alias to the same shape the host stores in\n * `series_aliases.normalized`. Mirrors the Rust `normalize_alias` impl \u2014 keep\n * these in lockstep.\n */\nexport function normalizeAlias(input: string): string {\n let out = \"\";\n let lastWasSpace = false;\n for (const ch of input) {\n // Match Rust's `is_alphanumeric()` (Unicode-aware).\n if (/[\\p{L}\\p{N}]/u.test(ch)) {\n out += ch.toLowerCase();\n lastWasSpace = false;\n } else if (/\\s/.test(ch) && out.length > 0 && !lastWasSpace) {\n out += \" \";\n lastWasSpace = true;\n }\n // Anything else (punctuation, control, symbols) is dropped.\n }\n return out.endsWith(\" \") ? out.slice(0, -1) : out;\n}\n\n// ---------------------------------------------------------------------------\n// Dice coefficient (token-level, character-bigram fallback)\n// ---------------------------------------------------------------------------\n\n/**\n * S\u00F8rensen-Dice coefficient on word-bigrams of the input strings (with a\n * character-bigram fallback for short / single-word strings).\n *\n * Range: 0..1, where 1.0 means identical bigram sets.\n */\nexport function diceRatio(a: string, b: string): number {\n if (a.length === 0 || b.length === 0) return 0;\n if (a === b) return 1;\n\n const bigramsA = bigrams(a);\n const bigramsB = bigrams(b);\n if (bigramsA.size === 0 || bigramsB.size === 0) return 0;\n\n let intersection = 0;\n for (const bg of bigramsA) {\n if (bigramsB.has(bg)) intersection++;\n }\n return (2 * intersection) / (bigramsA.size + bigramsB.size);\n}\n\nfunction bigrams(s: string): Set<string> {\n const out = new Set<string>();\n // Word bigrams first.\n const words = s.split(/\\s+/).filter((w) => w.length > 0);\n if (words.length >= 2) {\n for (let i = 0; i < words.length - 1; i++) {\n out.add(`${words[i]} ${words[i + 1]}`);\n }\n }\n // Plus character bigrams to handle word-rearrangement and short strings.\n const flat = s.replace(/\\s+/g, \"\");\n if (flat.length >= 2) {\n for (let i = 0; i < flat.length - 1; i++) {\n out.add(`#${flat.slice(i, i + 2)}`);\n }\n } else if (flat.length === 1) {\n out.add(`#${flat}`);\n }\n return out;\n}\n\n// ---------------------------------------------------------------------------\n// Public matching entry point\n// ---------------------------------------------------------------------------\n\nexport interface MatchOptions {\n /**\n * Minimum confidence for a fuzzy match to be returned. Defaults to\n * `DEFAULT_FUZZY_FLOOR` (0.7). Below this, the matcher returns null.\n */\n fuzzyFloor?: number;\n}\n\n/**\n * Match a parsed series-guess against a list of tracked-series candidates and\n * their aliases. Returns the best match or null if nothing clears the floor.\n *\n * On an exact normalized match against any alias of a candidate, confidence\n * is `CONFIDENCE_EXACT` (0.95). If multiple candidates have aliases that\n * normalize to the same form, the first one wins \u2014 that's a data-quality\n * issue the host surfaces via the `latest_known_*` advance gate, not\n * something the matcher can untangle alone.\n *\n * On no exact match, the matcher computes Dice ratios across the cartesian\n * product (candidates \u00D7 aliases), finds the maximum, scales it from\n * `[MIN_DICE_RATIO, 1.0]` into `[fuzzyFloor, 0.85]`, and returns a fuzzy\n * match if the result is at or above the floor.\n */\nexport function matchSeries(\n seriesGuess: string,\n candidates: AliasCandidate[],\n opts: MatchOptions = {},\n): AliasMatch | null {\n const floor = opts.fuzzyFloor ?? DEFAULT_FUZZY_FLOOR;\n const target = normalizeAlias(seriesGuess);\n if (target.length === 0 || candidates.length === 0) return null;\n\n // Pass 1 \u2014 exact normalized match.\n for (const c of candidates) {\n for (const alias of c.aliases) {\n if (normalizeAlias(alias) === target) {\n return {\n seriesId: c.seriesId,\n confidence: CONFIDENCE_EXACT,\n reason: \"alias-exact\",\n matchedAlias: alias,\n };\n }\n }\n }\n\n // Pass 2 \u2014 best fuzzy match.\n let best: AliasMatch | null = null;\n let bestRatio = 0;\n for (const c of candidates) {\n for (const alias of c.aliases) {\n const ratio = diceRatio(target, normalizeAlias(alias));\n if (ratio > bestRatio) {\n bestRatio = ratio;\n best = {\n seriesId: c.seriesId,\n confidence: 0,\n reason: \"alias-fuzzy\",\n matchedAlias: alias,\n };\n }\n }\n }\n if (best === null || bestRatio < MIN_DICE_RATIO) return null;\n\n // Linearly scale [MIN_DICE_RATIO..1.0] \u2192 [fuzzyFloor..0.85].\n // (We cap the fuzzy ceiling below CONFIDENCE_EXACT so an alias-exact match\n // is always strictly stronger than the best alias-fuzzy match.)\n const ceiling = 0.85;\n const span = 1 - MIN_DICE_RATIO;\n const t = (bestRatio - MIN_DICE_RATIO) / span; // 0..1 inside the band\n const confidence = floor + t * (ceiling - floor);\n if (confidence < floor) return null;\n best.confidence = Number(confidence.toFixed(4));\n return best;\n}\n\n/**\n * Match a list of alias guesses (e.g. from a `Title A / Title B` Nyaa title)\n * and return the best result across them.\n *\n * Picks the highest-confidence match across all guesses, preferring\n * `alias-exact` over `alias-fuzzy` when ties exist (because exact carries a\n * fixed `CONFIDENCE_EXACT` and fuzzy is bounded below it). When two guesses\n * both produce alias-exact matches against different series, the first guess\n * wins \u2014 that's the same precedence rule `matchSeries` applies internally\n * across candidates.\n */\nexport function matchSeriesAny(\n seriesGuesses: string[],\n candidates: AliasCandidate[],\n opts: MatchOptions = {},\n): AliasMatch | null {\n if (seriesGuesses.length === 0) return null;\n let best: AliasMatch | null = null;\n for (const guess of seriesGuesses) {\n const m = matchSeries(guess, candidates, opts);\n if (m === null) continue;\n if (best === null || m.confidence > best.confidence) {\n best = m;\n }\n }\n return best;\n}\n", "/**\n * RSS parser for Nyaa.si feeds.\n *\n * Nyaa's RSS namespace exposes one extra element per item that we care about\n * (`<nyaa:infoHash>`), plus the standard `<title>`, `<link>`, `<guid>`,\n * `<pubDate>`, and `<description>` fields. We pull all of them with the same\n * lightweight regex pipeline used for MangaUpdates \u2014 no heavy XML dep.\n *\n * Parsing the title is where most of the work is. Real-world examples\n * (sourced from production Nyaa feeds and the user's screenshot of 1r0n's\n * subscription):\n *\n * \"[1r0n] Boruto - Two Blue Vortex - Volume 02 (Digital) (1r0n)\"\n * \"[1r0n] One Piece v107 (Digital)\"\n * \"[1r0n] Chainsaw Man - Chapter 142 (Digital)\"\n * \"[Group] Dandadan c126-142 (2024) (Digital)\"\n * \"[Tankobon Blur] Solo Leveling Vol. 13 (2024) (Digital) (Tankobon Blur)\"\n * \"Berserk Volume 42 (Digital)\"\n *\n * The shape we want out of each item:\n * - parsed series guess (alias-free string used for matching)\n * - chapter / volume axes (decimals supported on chapter)\n * - format hints (Digital / JXL / etc.)\n * - uploader-tagged group (if encoded as a leading `[Group]` token)\n *\n * Nyaa titles are noisy; we keep parsing best-effort and surface confidence\n * downstream from the alias matcher rather than failing here.\n */\n\n/** Parsed item, pre-`ReleaseCandidate`. */\nexport interface ParsedRssItem {\n /** Stable per-source ID. Derived from the link or guid. */\n externalReleaseId: string;\n /** Original title. Useful for debugging / fallback. */\n title: string;\n /** Series-name guess after stripping volume/chapter/group/format tokens. */\n seriesGuess: string;\n /**\n * All alias candidates extracted from the series-name region. When the title\n * uses `Title A / Title B` (a common 1r0n / LuCaZ convention for \"JP name /\n * EN name\"), both halves are surfaced here so the matcher can score against\n * either. For titles without a slash separator this is a single-element\n * array equal to `[seriesGuess]`.\n */\n seriesGuessAliases: string[];\n /** Chapter number (decimals supported). Null if untyped. */\n chapter: number | null;\n /** Trailing chapter of a chapter range (e.g. `c126-142` \u2192 126..142). */\n chapterRangeEnd: number | null;\n /** Volume number. Null if untyped. */\n volume: number | null;\n /** Trailing volume of a volume range (e.g. `v01-14` \u2192 1..14). */\n volumeRangeEnd: number | null;\n /** Leading `[Group]` token, if any. */\n group: string | null;\n /** Format hints as a small dictionary (digital, jxl, ...). */\n formatHints: Record<string, boolean>;\n /** RSS `<link>` value. On Nyaa this is the `.torrent` download URL. */\n link: string;\n /**\n * Permalink to the release post page (e.g. `https://nyaa.si/view/12345`),\n * derived from the `<guid isPermaLink=\"true\">` tag. Null when the guid is\n * missing or doesn't look like a post URL.\n */\n pageUrl: string | null;\n /** `nyaa:infoHash` value, lowercased; null if missing. */\n infoHash: string | null;\n /** ISO-8601 timestamp. Falls back to \"now\" if pubDate is missing/invalid. */\n observedAt: string;\n}\n\n// -----------------------------------------------------------------------------\n// XML helpers (mirror release-mangaupdates conventions)\n// -----------------------------------------------------------------------------\n\nfunction decodeXmlText(raw: string): string {\n let s = raw.trim();\n const cdataMatch = s.match(/^<!\\[CDATA\\[([\\s\\S]*?)]]>$/);\n if (cdataMatch?.[1] !== undefined) {\n s = cdataMatch[1];\n }\n return s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .replace(/'/g, \"'\");\n}\n\n/** Pull the first `<tag>` text content from an XML fragment, or null. */\nfunction extractTagText(xml: string, tag: string): string | null {\n // Escape `:` for namespaced tags (e.g. `nyaa:infoHash`).\n const safeTag = tag.replace(/:/g, \"\\\\:\");\n const re = new RegExp(`<${safeTag}[^>]*>([\\\\s\\\\S]*?)</${safeTag}>`, \"i\");\n const m = xml.match(re);\n if (!m?.[1]) return null;\n return decodeXmlText(m[1]);\n}\n\nfunction splitItems(xml: string): string[] {\n const out: string[] = [];\n const re = /<item\\b[^>]*>([\\s\\S]*?)<\\/item>/gi;\n for (;;) {\n const match = re.exec(xml);\n if (match === null) break;\n if (match[1] !== undefined) out.push(match[1]);\n }\n return out;\n}\n\n// -----------------------------------------------------------------------------\n// Title parsing\n// -----------------------------------------------------------------------------\n\n/**\n * Strip a leading `[Group]` token off the title and return both pieces.\n * If the title has no leading bracketed token, returns `{ rest: title,\n * group: null }`.\n */\nfunction extractLeadingGroup(title: string): { rest: string; group: string | null } {\n const m = title.match(/^\\s*\\[([^\\]]+)\\]\\s*(.*)$/);\n if (!m?.[1]) return { rest: title, group: null };\n const group = m[1].trim();\n const rest = m[2] ?? \"\";\n return { rest, group: group.length > 0 ? group : null };\n}\n\n/**\n * Strip every `(...)` group from a string. Used to keep year ranges, uploader\n * credits, and format-hint tags out of the chapter/volume tokenizer \u2014 those\n * always live inside parentheses, so anything inside them must not be\n * interpreted as release-info.\n */\nfunction stripParens(s: string): string {\n return s.replace(/\\([^)]*\\)/g, \" \");\n}\n\n/**\n * Locate the start of the \"release-info span\" \u2014 the offset in `s` (which has\n * already had `(...)` groups blanked) where chapter/volume tokens begin.\n *\n * Anchors, in priority order:\n * 1. A `v##`, `vol.##`, `volume ##` token (with or without a range).\n * 2. A bare numeric range with both sides at 3+ digits (`031-037`,\n * `001-069`). Two-digit forms are rejected to avoid false positives\n * inside series names (`30s`, `My 100`, etc.).\n * 3. A `c##` / `ch.##` / `Chapter ##` token.\n *\n * Returns the index of the anchor, or -1 if no release-info is present (the\n * whole string is then treated as a series name).\n */\nfunction findReleaseInfoStart(s: string): number {\n const anchors: RegExp[] = [\n /\\b(?:v|vol|volume)\\.?\\s*[0-9]+/i,\n /\\b[0-9]{3,4}\\s*[-\u2013]\\s*[0-9]{3,4}\\b/,\n /\\b(?:c|ch|chapter)\\.?\\s*[0-9]+/i,\n ];\n let best = -1;\n for (const re of anchors) {\n const m = s.match(re);\n if (m && m.index !== undefined && (best === -1 || m.index < best)) {\n best = m.index;\n }\n }\n return best;\n}\n\n/**\n * Spread tokens are the comma- / `+`- / whitespace- / `as`-separated atoms\n * that make up the release-info span:\n *\n * - `volume` : single volume number (`v01`, `Vol. 13`)\n * - `volRange` : volume range (`v01-14`)\n * - `chapter` : single chapter number (`c143`, bare `70`)\n * - `chapRange` : chapter range (`c126-142`, bare `031-037`)\n *\n * The tokenizer scans left-to-right and consumes one token per match. Bare\n * numeric tokens are only accepted *after* the release-info anchor \u2014 see\n * `findReleaseInfoStart` \u2014 so series-name digits don't leak in.\n */\ntype SpreadToken =\n | { kind: \"volume\"; value: number }\n | { kind: \"volRange\"; start: number; end: number }\n | { kind: \"chapter\"; value: number }\n | { kind: \"chapRange\"; start: number; end: number };\n\n/**\n * Tokenize the release-info span into volume/chapter atoms.\n *\n * `s` should be the parens-stripped substring starting at the release-info\n * anchor. The tokenizer is intentionally permissive about separators (commas,\n * `+`, whitespace, `as`) \u2014 we just consume tokens greedily and aggregate\n * downstream.\n */\nfunction tokenizeReleaseInfo(s: string): SpreadToken[] {\n const tokens: SpreadToken[] = [];\n\n // Match either a prefixed volume/chapter token, or a bare numeric range /\n // single. The order in the alternation matters: ranges must be tried before\n // single tokens, and prefixed forms must be tried before bare numerics so\n // we don't mis-classify `v05` as bare-chapter `5`.\n //\n // 1. `v##-##` / `vol.##-##` / `volume ##-##` \u2192 volRange\n // 2. `v##` / `vol.##` / `volume ##` \u2192 volume\n // 3. `c##.##-##.##` / `ch.##-##` / `Chapter ##-##` \u2192 chapRange\n // 4. `c##.##` / `ch.##` / `Chapter ##` \u2192 chapter\n // 5. bare `###-###` (3+ digits each side) \u2192 chapRange\n // 6. bare `##` (1+ digits) \u2014 only matches *after* the first anchor token\n // has been emitted, see `acceptShortBare` below. Lets us pick up\n // \"extra\" chapters expressed as short numerics (`+ 70`) without\n // promoting incidental name-region digits.\n const tokenRe = new RegExp(\n [\n \"\\\\b(?<vrs>v|vol|volume)\\\\.?\\\\s*([0-9]+)\\\\s*[-\u2013]\\\\s*([0-9]+)\\\\b\",\n \"\\\\b(?<vss>v|vol|volume)\\\\.?\\\\s*([0-9]+)\\\\b\",\n \"\\\\b(?<crs>c|ch|chapter)\\\\.?\\\\s*([0-9]+(?:\\\\.[0-9]+)?)\\\\s*[-\u2013]\\\\s*([0-9]+(?:\\\\.[0-9]+)?)\\\\b\",\n \"\\\\b(?<css>c|ch|chapter)\\\\.?\\\\s*([0-9]+(?:\\\\.[0-9]+)?)\\\\b\",\n \"\\\\b(?<brs>)([0-9]{3,4})\\\\s*[-\u2013]\\\\s*([0-9]{3,4})\\\\b\",\n \"\\\\b(?<bss>)([0-9]{1,4})\\\\b\",\n ].join(\"|\"),\n \"gi\",\n );\n\n for (;;) {\n const m = tokenRe.exec(s);\n if (m === null) break;\n const groups = m.groups ?? {};\n if (groups.vrs !== undefined) {\n const start = Number.parseInt(m[2] ?? \"\", 10);\n const end = Number.parseInt(m[3] ?? \"\", 10);\n if (Number.isFinite(start) && Number.isFinite(end)) {\n tokens.push({ kind: \"volRange\", start, end });\n }\n continue;\n }\n if (groups.vss !== undefined) {\n const value = Number.parseInt(m[5] ?? \"\", 10);\n if (Number.isFinite(value)) tokens.push({ kind: \"volume\", value });\n continue;\n }\n if (groups.crs !== undefined) {\n const start = Number.parseFloat(m[7] ?? \"\");\n const end = Number.parseFloat(m[8] ?? \"\");\n if (Number.isFinite(start) && Number.isFinite(end)) {\n tokens.push({ kind: \"chapRange\", start, end });\n }\n continue;\n }\n if (groups.css !== undefined) {\n const value = Number.parseFloat(m[10] ?? \"\");\n if (Number.isFinite(value)) tokens.push({ kind: \"chapter\", value });\n continue;\n }\n if (groups.brs !== undefined) {\n const start = Number.parseInt(m[12] ?? \"\", 10);\n const end = Number.parseInt(m[13] ?? \"\", 10);\n if (Number.isFinite(start) && Number.isFinite(end)) {\n tokens.push({ kind: \"chapRange\", start, end });\n }\n continue;\n }\n if (groups.bss !== undefined) {\n const raw = m[15] ?? \"\";\n const value = Number.parseInt(raw, 10);\n if (!Number.isFinite(value)) continue;\n // Only accept short (\u22642 digit) bare numerics once we've already\n // committed to a richer token; on its own a `42` is more likely a\n // year fragment or noise than a chapter. 3+ digits is unambiguous in\n // this corpus so we always accept it.\n if (raw.length < 3 && tokens.length === 0) continue;\n tokens.push({ kind: \"chapter\", value });\n }\n }\n\n return tokens;\n}\n\n/**\n * Aggregate spread tokens into volume + chapter axes by taking min/max across\n * each kind. Downstream matching just needs to know the span a release covers\n * (\"does this release include chapter X?\") \u2014 a min..max window answers that\n * question conservatively without picking a single canonical token.\n */\nfunction aggregateTokens(tokens: SpreadToken[]): {\n volume: number | null;\n volumeRangeEnd: number | null;\n chapter: number | null;\n chapterRangeEnd: number | null;\n} {\n let vMin: number | null = null;\n let vMax: number | null = null;\n let cMin: number | null = null;\n let cMax: number | null = null;\n for (const t of tokens) {\n if (t.kind === \"volume\") {\n vMin = vMin === null || t.value < vMin ? t.value : vMin;\n vMax = vMax === null || t.value > vMax ? t.value : vMax;\n } else if (t.kind === \"volRange\") {\n vMin = vMin === null || t.start < vMin ? t.start : vMin;\n vMax = vMax === null || t.end > vMax ? t.end : vMax;\n } else if (t.kind === \"chapter\") {\n cMin = cMin === null || t.value < cMin ? t.value : cMin;\n cMax = cMax === null || t.value > cMax ? t.value : cMax;\n } else {\n cMin = cMin === null || t.start < cMin ? t.start : cMin;\n cMax = cMax === null || t.end > cMax ? t.end : cMax;\n }\n }\n return {\n volume: vMin,\n // Only emit a range-end when it actually differs from the start: a single\n // volume is `volume=N, volumeRangeEnd=null`, matching the prior contract.\n volumeRangeEnd: vMin !== null && vMax !== null && vMax !== vMin ? vMax : null,\n chapter: cMin,\n chapterRangeEnd: cMin !== null && cMax !== null && cMax !== cMin ? cMax : null,\n };\n}\n\n/**\n * Walk the parenthesized tags in the title and extract format hints.\n *\n * Common Nyaa hints we care about:\n * - `(Digital)` \u2192 `digital`\n * - `(JXL)` \u2192 `jxl`\n * - `(Mag-Z)` / `(Magazine)` \u2192 `magazine`\n * - `(Omnibus Edition)` / `(Omnibus)` \u2192 `omnibus`\n * - `(2024)` is a year, ignored (we'd need it for naming dedup but not for filtering)\n */\nfunction extractFormatHints(s: string): Record<string, boolean> {\n const hints: Record<string, boolean> = {};\n const tagRe = /\\(([^)]+)\\)/g;\n for (;;) {\n const match = tagRe.exec(s);\n if (match === null) break;\n const tag = (match[1] ?? \"\").trim().toLowerCase();\n if (tag.length === 0) continue;\n if (tag === \"digital\") hints.digital = true;\n else if (tag === \"jxl\") hints.jxl = true;\n else if (tag === \"magazine\" || tag === \"mag-z\") hints.magazine = true;\n else if (tag === \"webtoon\") hints.webtoon = true;\n else if (tag === \"bw\" || tag === \"b&w\") hints.bw = true;\n else if (tag === \"color\") hints.color = true;\n else if (tag === \"omnibus\" || tag === \"omnibus edition\") hints.omnibus = true;\n }\n return hints;\n}\n\n/**\n * Strip a trailing `[...]` token (e.g. `[Oak]` at the end of some\n * danke-Empire releases). Mirrors `extractLeadingGroup` but at the tail and\n * without surfacing the value \u2014 trailing brackets are credit, not a parsing\n * signal we currently use.\n */\nfunction stripTrailingBracket(s: string): string {\n return s.replace(/\\s*\\[[^\\]]+\\]\\s*$/g, \"\").trim();\n}\n\n/**\n * Take the \"name region\" of a release title (everything before the first\n * release-info anchor, with parens already stripped) and reduce it to a clean\n * primary guess plus alias candidates.\n *\n * The name region may still contain:\n * - subtitle dashes: `Boruto - Two Blue Vortex` \u2192 joined with spaces\n * - alias separator: `Ao no Hako / Blue Box` \u2192 both halves returned\n *\n * Apostrophes and hyphenated words (`Amagami-san`, `Chillin'`) are preserved\n * \u2014 the host's `normalize_alias` strips them at match time, but we want to\n * keep them readable in logs and admin surfaces.\n */\nfunction extractSeriesAliases(nameRegion: string): {\n primary: string;\n aliases: string[];\n} {\n // Subtitle dashes: ` - `, ` \u2013 `, ` \u2014 ` are titling glue, not separators.\n // Joining the halves with a single space mirrors the prior behavior the\n // existing tests assert (`Boruto Two Blue Vortex`).\n const dashJoined = nameRegion.replace(/\\s+[-\u2013\u2014]\\s+/g, \" \");\n\n // Alias separator. Only ` / ` (with whitespace on both sides) splits \u2014 bare\n // `/` survives so e.g. `AC/DC Tales` stays one alias.\n const parts = dashJoined\n .split(/\\s+\\/\\s+/)\n .map((p) => p.replace(/\\s+/g, \" \").trim())\n .filter((p) => p.length > 0);\n\n if (parts.length === 0) return { primary: \"\", aliases: [] };\n return { primary: parts[0] ?? \"\", aliases: parts };\n}\n\n/**\n * Public entry point \u2014 extract the structured fields from a single Nyaa\n * release title.\n *\n * Returns null only if the title is empty after trimming. Otherwise returns a\n * best-effort parse where the series guess may still be empty (e.g. for\n * meta-bundles without a leading series name); the matcher then drops those.\n */\nexport function parseTitle(title: string): {\n seriesGuess: string;\n seriesGuessAliases: string[];\n chapter: number | null;\n chapterRangeEnd: number | null;\n volume: number | null;\n volumeRangeEnd: number | null;\n group: string | null;\n formatHints: Record<string, boolean>;\n} | null {\n const trimmed = title.trim();\n if (trimmed.length === 0) return null;\n\n const { rest, group } = extractLeadingGroup(trimmed);\n const formatHints = extractFormatHints(rest);\n\n // Blank out `(...)` groups so years and uploader credits can't be picked up\n // by the release-info tokenizer, then split into name region / release-info\n // region at the first chapter/volume anchor.\n const flattened = stripTrailingBracket(stripParens(rest));\n const anchor = findReleaseInfoStart(flattened);\n const nameRegion = anchor === -1 ? flattened : flattened.slice(0, anchor);\n const infoRegion = anchor === -1 ? \"\" : flattened.slice(anchor);\n\n const tokens = tokenizeReleaseInfo(infoRegion);\n const { volume, volumeRangeEnd, chapter, chapterRangeEnd } = aggregateTokens(tokens);\n const { primary, aliases } = extractSeriesAliases(nameRegion);\n\n return {\n seriesGuess: primary,\n seriesGuessAliases: aliases.length > 0 ? aliases : [primary],\n chapter,\n chapterRangeEnd,\n volume,\n volumeRangeEnd,\n group,\n formatHints,\n };\n}\n\n// -----------------------------------------------------------------------------\n// Item parsing\n// -----------------------------------------------------------------------------\n\nfunction pubDateToIso(raw: string | null): string {\n if (raw) {\n const d = new Date(raw);\n if (!Number.isNaN(d.getTime())) return d.toISOString();\n }\n return new Date().toISOString();\n}\n\n/**\n * Pull the post-page URL out of the guid when it looks like a Nyaa\n * `/view/<id>` permalink. The `<link>` tag in Nyaa feeds is the `.torrent`\n * download URL, which is not what we want to surface to users.\n */\nfunction derivePageUrl(guid: string | null): string | null {\n if (!guid) return null;\n const trimmed = guid.trim();\n if (trimmed.length === 0) return null;\n // Match http(s)://<host>/view/<id> with optional trailing slash / query.\n if (/^https?:\\/\\/[^/]+\\/view\\/[^/?#]+/i.test(trimmed)) return trimmed;\n return null;\n}\n\nfunction deriveExternalReleaseId(\n guid: string | null,\n link: string | null,\n infoHash: string | null,\n title: string,\n pubDate: string | null,\n): string {\n if (guid && guid.trim().length > 0) return guid.trim();\n if (link && link.trim().length > 0) return link.trim();\n if (infoHash && infoHash.length > 0) return `urn:btih:${infoHash}`;\n // Deterministic fallback: djb2-ish hash. Same algorithm MangaUpdates uses.\n const fallback = `${title}|${pubDate ?? \"\"}`;\n let h = 5381;\n for (let i = 0; i < fallback.length; i++) {\n h = ((h << 5) + h + fallback.charCodeAt(i)) | 0;\n }\n return `t:${(h >>> 0).toString(36)}`;\n}\n\n/**\n * Parse a single Nyaa `<item>` block. Returns null when the title is missing\n * (truly malformed entry).\n */\nexport function parseItem(itemXml: string): ParsedRssItem | null {\n const title = extractTagText(itemXml, \"title\");\n if (!title) return null;\n\n const link = extractTagText(itemXml, \"link\");\n const guid = extractTagText(itemXml, \"guid\");\n const pubDate = extractTagText(itemXml, \"pubDate\");\n const infoHashRaw = extractTagText(itemXml, \"nyaa:infoHash\");\n const infoHash = infoHashRaw ? infoHashRaw.toLowerCase().trim() : null;\n\n const parsedTitle = parseTitle(title);\n if (parsedTitle === null) return null;\n\n return {\n externalReleaseId: deriveExternalReleaseId(guid, link, infoHash, title, pubDate),\n title,\n seriesGuess: parsedTitle.seriesGuess,\n seriesGuessAliases: parsedTitle.seriesGuessAliases,\n chapter: parsedTitle.chapter,\n chapterRangeEnd: parsedTitle.chapterRangeEnd,\n volume: parsedTitle.volume,\n volumeRangeEnd: parsedTitle.volumeRangeEnd,\n group: parsedTitle.group,\n formatHints: parsedTitle.formatHints,\n link: link ?? \"\",\n pageUrl: derivePageUrl(guid),\n infoHash,\n observedAt: pubDateToIso(pubDate),\n };\n}\n\n/**\n * Parse a full Nyaa RSS feed body into structured items. Bad items (missing\n * title) are dropped silently \u2014 Nyaa feeds occasionally include broken entries\n * and we'd rather keep going than poison the whole poll.\n */\nexport function parseFeed(xml: string): ParsedRssItem[] {\n return splitItems(xml)\n .map(parseItem)\n .filter((i): i is ParsedRssItem => i !== null);\n}\n", "/**\n * Nyaa.si Release-Source Plugin for Codex.\n *\n * Polls Nyaa user / search RSS feeds for an admin-configured uploader\n * allowlist and announces new releases for tracked series. Matching is\n * alias-based: each parsed Nyaa title is normalized and compared to every\n * tracked series' alias list. Confidence is 0.95 on exact normalized match,\n * dropping to a fuzzy floor of 0.7 for near-matches; below that, the\n * candidate is silently dropped (the host's threshold would reject it\n * anyway).\n *\n * Source-row model:\n * - On `onInitialize` (which the host re-runs after every config save),\n * the plugin parses the admin's `uploaders` CSV and calls\n * `releases/register_sources` with one entry per subscription. The host\n * materializes one `release_sources` row per uploader, keyed on\n * `(plugin_id, sourceKey)` where `sourceKey` is `kind:identifier`\n * (e.g. `user:tsuna69`, `query:luminousscans`, `params:c=3_1&q=berserk`).\n * - The host scheduler fires one `releases/poll` task per source row, so\n * each uploader has its own poll cadence, ETag, and last-error status.\n *\n * Flow per `releases/poll`:\n * 1. Recover the subscription from `params.config.subscription` (or fall\n * back to parsing `params.sourceKey`).\n * 2. Pull tracked-series + aliases from the host\n * (`releases/list_tracked`).\n * 3. Conditional GET the RSS feed using `params.etag`.\n * 4. Parse each item; match against tracked aliases; emit a candidate via\n * `releases/record`.\n * 5. Return the new ETag and upstream status for the host's per-host\n * backoff layer.\n */\n\nimport {\n createLogger,\n createReleaseSourcePlugin,\n type HostRpcClient,\n HostRpcError,\n type InitializeParams,\n RELEASES_METHODS,\n type ReleaseCandidate,\n type ReleasePollRequest,\n type ReleasePollResponse,\n type TrackedSeriesEntry,\n} from \"@ashdev/codex-plugin-sdk\";\nimport {\n fetchSubscriptionFeed,\n parseSubscriptionList,\n sourceKeyToSubscription,\n subscriptionToSourceKey,\n type UploaderSubscription,\n} from \"./fetcher.js\";\nimport { DEFAULT_MIN_CONFIDENCE, DEFAULT_REQUEST_TIMEOUT_MS, manifest } from \"./manifest.js\";\nimport { type AliasCandidate, type AliasMatch, matchSeriesAny } from \"./matcher.js\";\nimport { type ParsedRssItem, parseFeed } from \"./parser.js\";\n\nconst logger = createLogger({ name: manifest.name, level: \"info\" });\n\n// =============================================================================\n// Plugin-level state (set during initialize)\n// =============================================================================\n\ninterface PluginState {\n hostRpc: HostRpcClient | null;\n /** Parsed admin uploader subscription list. */\n subscriptions: UploaderSubscription[];\n /** Hard timeout for upstream fetches. */\n requestTimeoutMs: number;\n /** Minimum confidence floor \u2014 passed to the matcher's `fuzzyFloor`. */\n minConfidence: number;\n /** Override base URL (for tests / mirrors). */\n baseUrl: string | null;\n}\n\nconst state: PluginState = {\n hostRpc: null,\n subscriptions: [],\n requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,\n minConfidence: DEFAULT_MIN_CONFIDENCE,\n baseUrl: null,\n};\n\n/** Reset state. Exported for tests; not part of the plugin contract. */\nexport function _resetState(): void {\n state.hostRpc = null;\n state.subscriptions = [];\n state.requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;\n state.minConfidence = DEFAULT_MIN_CONFIDENCE;\n state.baseUrl = null;\n}\n\n// =============================================================================\n// Reverse-RPC wrappers\n// =============================================================================\n\ninterface ListTrackedResponse {\n tracked: TrackedSeriesEntry[];\n nextOffset?: number;\n}\n\ninterface RecordResponse {\n ledgerId: string;\n deduped: boolean;\n}\n\nasync function listTracked(\n rpc: HostRpcClient,\n sourceId: string,\n offset: number,\n limit: number,\n): Promise<ListTrackedResponse> {\n return rpc.call<ListTrackedResponse>(RELEASES_METHODS.LIST_TRACKED, {\n sourceId,\n offset,\n limit,\n });\n}\n\nasync function recordCandidate(\n rpc: HostRpcClient,\n sourceId: string,\n candidate: ReleaseCandidate,\n): Promise<RecordResponse | null> {\n try {\n return await rpc.call<RecordResponse>(RELEASES_METHODS.RECORD, {\n sourceId,\n candidate,\n });\n } catch (err) {\n if (err instanceof HostRpcError) {\n logger.warn(\n `record failed for ${candidate.externalReleaseId}: ${err.message} (code ${err.code})`,\n );\n } else {\n const msg = err instanceof Error ? err.message : \"unknown error\";\n logger.warn(`record failed for ${candidate.externalReleaseId}: ${msg}`);\n }\n return null;\n }\n}\n\n// =============================================================================\n// Iteration helpers\n// =============================================================================\n\n/**\n * Pull every tracked-series page from the host. We can't stream\n * subscription-by-subscription because each Nyaa item has to be matched\n * against the *full* alias set; partial pages would leak misses.\n */\nexport async function fetchAllTracked(\n rpc: HostRpcClient,\n sourceId: string,\n): Promise<AliasCandidate[]> {\n const out: AliasCandidate[] = [];\n const pageSize = 200;\n let offset = 0;\n while (true) {\n const page = await listTracked(rpc, sourceId, offset, pageSize);\n for (const entry of page.tracked) {\n const aliases = entry.aliases ?? [];\n // Drop entries with no aliases \u2014 Nyaa matching is alias-only.\n if (aliases.length === 0) continue;\n out.push({ seriesId: entry.seriesId, aliases });\n }\n if (page.nextOffset === undefined || page.tracked.length === 0) return out;\n offset = page.nextOffset;\n }\n}\n\n// =============================================================================\n// Per-subscription poll\n// =============================================================================\n\n/** Outcome of a single per-subscription fetch+parse cycle. */\nexport interface SubscriptionPollOutcome {\n subscription: UploaderSubscription;\n fetched: boolean;\n notModified: boolean;\n parsed: number;\n matched: number;\n recorded: number;\n /** Of those sent to record, how many the host deduped onto an existing row. */\n deduped: number;\n upstreamStatus: number;\n /** New ETag returned by upstream (only set when fetched=true). */\n etag: string | null;\n error: string;\n}\n\n/**\n * Build a `ReleaseCandidate` from a parsed RSS item + the matcher's verdict.\n *\n * Language is hardcoded to `\"en\"` \u2014 Nyaa releases don't carry a language tag\n * in the title or RSS metadata. English-only is the right default for the\n * uploader allowlist this plugin is designed around (`1r0n`, etc.); admins\n * who add non-English uploaders should configure tracked series' languages\n * accordingly. The host's `latest_known_*` advance gate enforces the\n * per-series language list.\n */\nfunction toCandidate(\n match: AliasMatch,\n item: ParsedRssItem,\n subscription: UploaderSubscription,\n): ReleaseCandidate {\n const formatHints: Record<string, unknown> = { ...item.formatHints };\n if (item.chapterRangeEnd !== null) {\n formatHints.chapterRangeEnd = item.chapterRangeEnd;\n }\n if (item.volumeRangeEnd !== null) {\n formatHints.volumeRangeEnd = item.volumeRangeEnd;\n }\n formatHints.subscription = `${subscription.kind}:${subscription.identifier}`;\n\n // Nyaa RSS carries two URLs per item:\n // <guid>: the human-readable post page (`/view/<id>`)\n // <link>: the actual `.torrent` download\n // We surface the page as `payloadUrl` (the inbox's external-link icon)\n // and the torrent as `mediaUrl` with kind=torrent so the UI can render a\n // second, kind-specific icon for one-click acquisition. When the page URL\n // is missing we fall back to the torrent for `payloadUrl` and skip the\n // separate media link to avoid pointing both icons at the same URL.\n const torrentLink = item.link.length > 0 ? item.link : null;\n const payloadUrl = item.pageUrl ?? torrentLink ?? `urn:nyaa:${item.externalReleaseId}`;\n const hasDistinctMedia = item.pageUrl !== null && torrentLink !== null;\n\n return {\n seriesMatch: {\n codexSeriesId: match.seriesId,\n confidence: match.confidence,\n reason: match.reason,\n },\n externalReleaseId: item.externalReleaseId,\n chapter: item.chapter,\n volume: item.volume,\n language: \"en\",\n groupOrUploader: item.group ?? (subscription.kind === \"user\" ? subscription.identifier : null),\n payloadUrl,\n ...(hasDistinctMedia ? { mediaUrl: torrentLink, mediaUrlKind: \"torrent\" as const } : {}),\n infoHash: item.infoHash,\n formatHints,\n observedAt: item.observedAt,\n };\n}\n\n/**\n * Poll a single uploader subscription. Internal \u2014 exposed for testing.\n */\nexport async function pollSubscription(\n rpc: HostRpcClient,\n sourceId: string,\n subscription: UploaderSubscription,\n candidates: AliasCandidate[],\n options: {\n previousEtag: string | null;\n timeoutMs: number;\n minConfidence: number;\n baseUrl?: string | null;\n fetchImpl?: typeof fetch;\n },\n): Promise<SubscriptionPollOutcome> {\n const result = await fetchSubscriptionFeed(subscription, options.previousEtag, null, {\n fetchImpl: options.fetchImpl,\n timeoutMs: options.timeoutMs,\n ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}),\n });\n\n if (result.kind === \"notModified\") {\n return {\n subscription,\n fetched: true,\n notModified: true,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: 304,\n etag: null,\n error: \"\",\n };\n }\n\n if (result.kind === \"error\") {\n return {\n subscription,\n fetched: false,\n notModified: false,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: result.status,\n etag: null,\n error: result.message,\n };\n }\n\n // result.kind === \"ok\"\n const items = parseFeed(result.body);\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n for (const item of items) {\n // Prefer the alias-list form: a `Title A / Title B` Nyaa title surfaces\n // both halves in `seriesGuessAliases`, so the matcher can hit on either\n // the JP or EN side of the alias separator. Falls back to the single\n // guess for titles without a slash.\n const guesses =\n item.seriesGuessAliases.length > 0 ? item.seriesGuessAliases : [item.seriesGuess];\n const m = matchSeriesAny(guesses, candidates, {\n fuzzyFloor: options.minConfidence,\n });\n if (m === null) continue;\n matched++;\n const candidate = toCandidate(m, item, subscription);\n const outcome = await recordCandidate(rpc, sourceId, candidate);\n if (!outcome) continue;\n if (outcome.deduped) {\n deduped++;\n } else {\n recorded++;\n }\n }\n return {\n subscription,\n fetched: true,\n notModified: false,\n parsed: items.length,\n matched,\n recorded,\n deduped,\n upstreamStatus: 200,\n etag: result.etag,\n error: \"\",\n };\n}\n\n// =============================================================================\n// Top-level poll handler\n// =============================================================================\n\n/**\n * Resolve the subscription this poll request is for. The host stamps every\n * `release_sources` row with its plugin-defined `config` (set at register\n * time), so the preferred path is `params.config.subscription`. If a row\n * pre-dates the config field (e.g. created in a previous plugin version),\n * fall back to parsing `params.sourceKey`.\n */\nfunction resolveSubscription(params: ReleasePollRequest): UploaderSubscription | null {\n const cfg = params.config as { subscription?: unknown } | undefined | null;\n const fromConfig = cfg?.subscription;\n if (fromConfig && typeof fromConfig === \"object\") {\n const obj = fromConfig as Record<string, unknown>;\n const kind = obj.kind;\n const identifier = obj.identifier;\n if (\n typeof identifier === \"string\" &&\n identifier.length > 0 &&\n (kind === \"user\" || kind === \"query\" || kind === \"params\")\n ) {\n return { kind, identifier };\n }\n }\n if (typeof params.sourceKey === \"string\" && params.sourceKey.length > 0) {\n return sourceKeyToSubscription(params.sourceKey);\n }\n return null;\n}\n\nasync function poll(params: ReleasePollRequest, rpc: HostRpcClient): Promise<ReleasePollResponse> {\n const sourceId = params.sourceId;\n const subscription = resolveSubscription(params);\n if (subscription === null) {\n logger.warn(`source=${sourceId} no resolvable subscription on poll request; skipping`);\n return { notModified: false, upstreamStatus: 200 };\n }\n\n // 1. Pull tracked-series + aliases.\n const tracked = await fetchAllTracked(rpc, sourceId);\n if (tracked.length === 0) {\n logger.info(`no tracked series with aliases for source=${sourceId}`);\n return { notModified: false, upstreamStatus: 200 };\n }\n\n // 2. Conditional GET against this subscription's feed.\n const outcome = await pollSubscription(rpc, sourceId, subscription, tracked, {\n previousEtag: params.etag ?? null,\n timeoutMs: state.requestTimeoutMs,\n minConfidence: state.minConfidence,\n ...(state.baseUrl ? { baseUrl: state.baseUrl } : {}),\n });\n if (outcome.error) {\n logger.warn(\n `source=${sourceId} ${subscription.kind}:${subscription.identifier}: ${outcome.error} (status ${outcome.upstreamStatus})`,\n );\n }\n\n logger.info(\n `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)\" : \"\"}`,\n );\n\n // Report counters back to the host so it can build a meaningful\n // `last_summary` for the source. Without these, the host only sees the\n // (empty) `candidates` payload \u2014 we record via reverse-RPC mid-poll \u2014\n // and the status badge reads \"Fetched 0 items\" even on a busy poll.\n return {\n notModified: outcome.notModified,\n upstreamStatus: outcome.upstreamStatus,\n parsed: outcome.parsed,\n matched: outcome.matched,\n recorded: outcome.recorded,\n deduped: outcome.deduped,\n ...(outcome.etag !== null ? { etag: outcome.etag } : {}),\n };\n}\n\n// =============================================================================\n// Plugin Initialization\n// =============================================================================\n\n/**\n * Send the desired-state list of source rows to the host. Called from\n * `onInitialize` (after the host has installed the releases reverse-RPC\n * handler) so the plugin's source rows are materialized whenever the\n * config changes.\n *\n * Retries on `METHOD_NOT_FOUND` with linear backoff: the host installs the\n * releases handler shortly after `initialize` returns, and there is a small\n * race window where the plugin's first reverse-RPC call may land before the\n * handler is in place.\n */\nexport async function registerSources(\n rpc: HostRpcClient,\n subscriptions: UploaderSubscription[],\n): Promise<{ registered: number; pruned: number } | null> {\n const sources = subscriptions.map((sub) => ({\n sourceKey: subscriptionToSourceKey(sub),\n displayName: displayNameFor(sub),\n kind: \"rss-uploader\" as const,\n config: { subscription: { kind: sub.kind, identifier: sub.identifier } },\n }));\n\n const maxAttempts = 5;\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n return await rpc.call<{ registered: number; pruned: number }>(\n RELEASES_METHODS.REGISTER_SOURCES,\n { sources },\n );\n } catch (err) {\n const isMethodNotFound = err instanceof HostRpcError && err.code === -32601;\n if (isMethodNotFound && attempt < maxAttempts) {\n // Wait for the host to finish installing the releases reverse-RPC\n // handler. Linear backoff: 50ms, 100ms, 150ms, 200ms.\n await new Promise((r) => setTimeout(r, 50 * attempt));\n continue;\n }\n const reason = err instanceof Error ? err.message : String(err);\n logger.error(`register_sources failed: ${reason}`);\n return null;\n }\n }\n return null;\n}\n\n/** Human-readable label shown in the Release tracking settings table. */\nfunction displayNameFor(sub: UploaderSubscription): string {\n if (sub.kind === \"user\") return `Nyaa: ${sub.identifier}`;\n if (sub.kind === \"query\") return `Nyaa search: ${sub.identifier}`;\n return `Nyaa params: ${sub.identifier}`;\n}\n\ncreateReleaseSourcePlugin({\n manifest,\n provider: {\n async poll(params: ReleasePollRequest): Promise<ReleasePollResponse> {\n if (!state.hostRpc) {\n throw new Error(\"Plugin not initialized: hostRpc client missing\");\n }\n return poll(params, state.hostRpc);\n },\n },\n logLevel: \"info\",\n async onInitialize(params: InitializeParams) {\n state.hostRpc = params.hostRpc;\n const ac = params.adminConfig ?? {};\n state.subscriptions = parseSubscriptionList(ac.uploaders);\n if (typeof ac.requestTimeoutMs === \"number\" && Number.isFinite(ac.requestTimeoutMs)) {\n state.requestTimeoutMs = Math.max(1_000, Math.min(ac.requestTimeoutMs, 60_000));\n }\n if (typeof ac.baseUrl === \"string\" && ac.baseUrl.trim().length > 0) {\n state.baseUrl = ac.baseUrl.trim();\n }\n logger.info(\n `initialized: subscriptions=${state.subscriptions.length} timeoutMs=${state.requestTimeoutMs} minConfidence=${state.minConfidence}`,\n );\n\n // Materialize source rows. Deferred to a microtask + retry on\n // METHOD_NOT_FOUND so we run *after* the host installs the releases\n // reverse-RPC handler (it does so right after `initialize` returns).\n queueMicrotask(() => {\n void registerSources(params.hostRpc, state.subscriptions).then((result) => {\n if (result) {\n logger.info(`register_sources: registered=${result.registered} pruned=${result.pruned}`);\n }\n });\n });\n },\n});\n\nlogger.info(\"Nyaa release-source plugin started\");\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["manifest", "logger", "response", "manifest"]
|
|
7
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ashdev/codex-plugin-release-nyaa",
|
|
3
|
+
"version": "1.18.0",
|
|
4
|
+
"description": "Nyaa.si uploader-feed release-source plugin for Codex - announces torrent releases for tracked series, filtered by an admin allowlist of trusted uploaders",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": "dist/index.js",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/AshDevFr/codex.git",
|
|
15
|
+
"directory": "plugins/release-nyaa"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'",
|
|
19
|
+
"dev": "npm run build -- --watch",
|
|
20
|
+
"clean": "rm -rf dist",
|
|
21
|
+
"start": "node dist/index.js",
|
|
22
|
+
"lint": "biome check .",
|
|
23
|
+
"lint:fix": "biome check --write .",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "vitest run --passWithNoTests",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"prepublishOnly": "npm run lint && npm run build"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"codex",
|
|
31
|
+
"plugin",
|
|
32
|
+
"nyaa",
|
|
33
|
+
"release-source",
|
|
34
|
+
"manga",
|
|
35
|
+
"torrent"
|
|
36
|
+
],
|
|
37
|
+
"author": "Codex",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22.0.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@ashdev/codex-plugin-sdk": "file:../sdk-typescript"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@biomejs/biome": "^2.4.4",
|
|
47
|
+
"@types/node": "^22.0.0",
|
|
48
|
+
"esbuild": "^0.27.3",
|
|
49
|
+
"typescript": "^5.9.3",
|
|
50
|
+
"vitest": "^4.0.18"
|
|
51
|
+
}
|
|
52
|
+
}
|