diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index ff9556e8a..f8570131c 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -252,4 +252,11 @@ export interface IndexerPlugin { resolveFile?(release: PluginReleaseCandidate, config: PluginIndexerConfig, host: PluginHost, signal: AbortSignal): Promise; /** Sources whose session expires on its own clock, called on a schedule while enabled. */ keepalive?(config: PluginIndexerConfig, host: PluginHost): Promise; + /** + * Drop anything held in memory for this indexer, such as a live session. Called when an operator + * changes its base URL, credential or private-address setting, so the next call starts from the + * stored config. Not awaited, and a failure is logged rather than reported. Never called by hosts + * older than the hook. + */ + forget?(indexerId: number): void | Promise; } diff --git a/server/src/modules/book-request/indexers/indexer-config.service.test.ts b/server/src/modules/book-request/indexers/indexer-config.service.test.ts index 28f3efcfc..000fb26cb 100644 --- a/server/src/modules/book-request/indexers/indexer-config.service.test.ts +++ b/server/src/modules/book-request/indexers/indexer-config.service.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_INDEXER_CATEGORIES, INDEXER_COLORS } from '@bookorbit/types'; import type { RequestIndexerManagerRow, RequestIndexerRow } from '../../../db/schema'; import { IndexerConfigService } from './indexer-config.service'; +import { PluginIndexerAdapter } from './plugins/plugin-host'; function indexerRow(overrides: Partial = {}): RequestIndexerRow { return { @@ -493,6 +494,41 @@ describe('IndexerConfigService', () => { expect(adapter.forget).not.toHaveBeenCalled(); }); + /** A plugin's in-memory session otherwise outranks the stored credential until the process restarts. */ + it('drops a plugin-backed session when its connection details change', async () => { + const forget = vi.fn(); + const adapter = new PluginIndexerAdapter( + { + apiVersion: 1, + type: 'demo-tracker', + label: 'Demo Tracker', + requiresCredential: true, + credentialKind: 'sessionId', + mediaKinds: ['ebook'], + usesCategories: false, + seedsBack: true, + search: () => Promise.resolve([]), + test: () => Promise.resolve({ success: true }), + fetchTorrentFile: () => Promise.resolve(new Uint8Array()), + forget, + }, + {} as never, + ); + const { service } = makeService({ + repo: { findById: vi.fn().mockResolvedValue(indexerRow({ adapterType: 'demo-tracker' })) }, + registry: { require: vi.fn().mockReturnValue(adapter), find: vi.fn().mockReturnValue(adapter) }, + }); + + await service.update(9, { credential: 'a-new-session' }); + await service.update(9, { baseUrl: 'http://203.0.113.11:9117' }); + await service.update(9, { allowPrivateAddress: true }); + expect(forget.mock.calls).toEqual([[9], [9], [9]]); + + forget.mockClear(); + await service.update(9, { name: 'renamed', enabled: false }); + expect(forget).not.toHaveBeenCalled(); + }); + it('falls back to the adapter default categories for a medium left blank', async () => { const { service, repo } = makeService(); diff --git a/server/src/modules/book-request/indexers/plugins/plugin-host.test.ts b/server/src/modules/book-request/indexers/plugins/plugin-host.test.ts index 83dd316a9..d6c4e0df2 100644 --- a/server/src/modules/book-request/indexers/plugins/plugin-host.test.ts +++ b/server/src/modules/book-request/indexers/plugins/plugin-host.test.ts @@ -18,6 +18,7 @@ vi.mock('dns/promises', async (importOriginal) => ({ })); import { lookup } from 'dns/promises'; +import { Logger } from '@nestjs/common'; import type { IndexerCredentialStore } from '../indexer-credential-store'; import { IndexerSearchException, type ReleaseQuery, type ResolvedIndexerConfig } from '../indexer-adapter'; @@ -616,4 +617,42 @@ describe('PluginIndexerAdapter', () => { expect(credentials.rotate).toHaveBeenNthCalledWith(1, 4, 'session-2', 'session-1'); expect(credentials.rotate).toHaveBeenNthCalledWith(2, 4, 'session-3', 'session-2'); }); + + describe('forget', () => { + /** A plugin holding a live session would otherwise keep using it until the process restarts. */ + it('tells the plugin to drop what it holds for the indexer', () => { + const forget = vi.fn(); + const { adapter } = makeAdapter({ forget }); + + adapter.forget(12); + + expect(forget).toHaveBeenCalledWith(12); + }); + + it('does nothing for a plugin written before the hook', () => { + expect(() => makeAdapter().adapter.forget(12)).not.toThrow(); + expect(() => makeAdapter({ forget: true as never }).adapter.forget(12)).not.toThrow(); + }); + + /** The operator's change is already saved by then, so a broken plugin cannot be allowed to fail it. */ + it('logs a plugin that throws or rejects instead of failing the save', async () => { + const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + try { + expect(() => + makeAdapter({ + forget: () => { + throw new Error('no session map'); + }, + }).adapter.forget(12), + ).not.toThrow(); + makeAdapter({ forget: () => Promise.reject(new Error('cache closed')) }).adapter.forget(13); + await new Promise((resolve) => setImmediate(resolve)); + + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/indexerId=12 .*no session map/)); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/indexerId=13 .*cache closed/)); + } finally { + warn.mockRestore(); + } + }); + }); }); diff --git a/server/src/modules/book-request/indexers/plugins/plugin-host.ts b/server/src/modules/book-request/indexers/plugins/plugin-host.ts index eee20bf85..e14ab256c 100644 --- a/server/src/modules/book-request/indexers/plugins/plugin-host.ts +++ b/server/src/modules/book-request/indexers/plugins/plugin-host.ts @@ -152,6 +152,26 @@ export class PluginIndexerAdapter implements IndexerAdapter { }; } + /** + * Runs after the operator's change has already been saved, so nothing a plugin does here may fail + * that save: a throw or a rejection is logged and dropped. + */ + forget(indexerId: number): void { + if (typeof this.plugin.forget !== 'function') return; + try { + void Promise.resolve(this.plugin.forget(indexerId)).catch((error: unknown) => this.warnForgetFailed(indexerId, error)); + } catch (error) { + this.warnForgetFailed(indexerId, error); + } + } + + private warnForgetFailed(indexerId: number, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn( + `[request_indexer.plugin] [fail] indexerId=${indexerId} type=${this.type} error="${sanitizeLogValue(message)}" - the plugin threw while forgetting its session`, + ); + } + /** * `deadline` is the caller's, where the caller has one. It is applied here rather than handed to * the plugin because a plugin can only check a signal between requests, and the request that