fix(stats): refresh totals and bound TMDB credential retries

- Recompute completion aggregates after TMDB relinking
- Cool down failed credential commands for 30 seconds
This commit is contained in:
2026-09-19 00:19:41 -07:00
parent c6059e340b
commit 65ea5d5a5a
7 changed files with 168 additions and 53 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
type: docs
area: stats
- Documented provider reassignment, merge compatibility, and TMDB credential command caching.
- Documented provider reassignment, merge compatibility, and TMDB credential command caching and retry cooldown.
+2 -1
View File
@@ -4,4 +4,5 @@ area: stats
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB. Release builds include a project key, so it works out of the box; `tmdb.apiKey` (or `tmdb.apiKeyCommand`) overrides it, and is required when running from source.
- Unlinked titles that AniList cannot match are looked up on TMDB automatically when the parsed filename matches a Japanese live-action title exactly; otherwise use the new **Link to TMDB** action on a title to pick it by hand.
- Entries linked to the same TMDB title are merged into one card even when they came from different season folders, and the Library gained an Anime / Live Action filter.
- Provider reassignment preserves the previous link and artwork if the replacement download fails. Merges and sync keep conflicting AniList and TMDB identities separate.
- Provider reassignment preserves the previous link and artwork if the replacement download fails, and refreshes completion totals when the episode count changes. Merges and sync keep conflicting AniList and TMDB identities separate.
- TMDB credential commands cache successful output and wait 30 seconds before retrying failed or empty output, using the bundled key in the meantime when available.
+1 -1
View File
@@ -1178,7 +1178,7 @@ Release builds ship with a project TMDB key, so nothing needs to be configured.
| `tmdb.apiKey` | string | Your own TMDB API key or read access token; overrides the bundled key (default: empty) |
| `tmdb.apiKeyCommand` | string | Shell command that prints the key to stdout, used instead of `apiKey` to keep it out of the config |
Successful `apiKeyCommand` output is cached for the running client until `tmdb.apiKey` or `tmdb.apiKeyCommand` changes. Failed or empty command output is retried on the next request.
Successful `apiKeyCommand` output is cached for the running client until `tmdb.apiKey` or `tmdb.apiKeyCommand` changes. Failed or empty command output uses the bundled key when available and waits 30 seconds before the next request can retry the command. Changing either credential setting resets this cooldown.
Changes apply to the next TMDB request without a restart.
@@ -282,3 +282,25 @@ for (const mode of ['manual', 'auto'] as const) {
});
});
}
for (const mode of ['manual', 'auto'] as const) {
test(`${mode} TMDB linking refreshes completion totals without merging records`, () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki');
insertEpisode(db, 1, 1, 1);
const completed = () =>
(
db
.prepare('SELECT anime_completed AS count FROM imm_lifetime_global WHERE global_id = 1')
.get() as { count: number }
).count;
assert.equal(completed(), 0);
const result = linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, episodesTotal: 1 }, { mode });
assert.deepEqual(result.mergedAnimeIds, []);
assert.equal(completed(), 1);
linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, episodesTotal: 2 }, { mode: 'manual' });
assert.equal(completed(), 0);
assert.equal(animeCount(db), 1);
});
});
}
@@ -158,7 +158,7 @@ export function linkAnimeToTmdbTitleInTransaction(
survivor,
);
}
if (mergedAnimeIds.length > 0) recomputeLifetimeAnimeAggregatesInTransaction(db);
recomputeLifetimeAnimeAggregatesInTransaction(db);
return { animeId: survivor, mergedAnimeIds };
}
+109 -30
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import test, { type TestContext } from 'node:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -11,6 +11,22 @@ import {
resolveTmdbApiKey,
} from './tmdb-client.js';
function commandFixture(t: TestContext) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer tmdb command-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
let nextId = 0;
const quotePath = (value: string) =>
`"${process.platform === 'win32' ? value : value.replace(/["\\$`]/g, '\\$&')}"`;
return {
dir,
command(source: string): string {
const script = path.join(dir, `credential-${nextId++}.cjs`);
fs.writeFileSync(script, source);
return `${quotePath(process.execPath)} ${quotePath(script)}`;
},
};
}
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
@@ -28,22 +44,37 @@ function captureFetch(handler: (url: URL, init?: RequestInit) => Response) {
return { calls, fetchImpl };
}
test('resolveTmdbApiKey prefers the literal key and trims it', async () => {
assert.equal(await resolveTmdbApiKey({ apiKey: ' abc ', apiKeyCommand: 'echo nope' }), 'abc');
test('resolveTmdbApiKey prefers the literal key and trims it', async (t) => {
const { command } = commandFixture(t);
assert.equal(
await resolveTmdbApiKey({ apiKey: ' abc ', apiKeyCommand: command('process.exit(3)') }),
'abc',
);
assert.equal(await resolveTmdbApiKey({ apiKey: '', apiKeyCommand: '' }), null);
assert.equal(await resolveTmdbApiKey(undefined), null);
});
test('resolveTmdbApiKey runs apiKeyCommand when no literal key is set', async () => {
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'printf " from-cmd "' }), 'from-cmd');
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'exit 3' }), null);
test('resolveTmdbApiKey runs apiKeyCommand when no literal key is set', async (t) => {
const { command } = commandFixture(t);
assert.equal(
await resolveTmdbApiKey({ apiKeyCommand: command('process.stdout.write(" from-cmd ")') }),
'from-cmd',
);
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: command('process.exit(3)') }), null);
});
test('resolveTmdbApiKey falls back to the bundled key only when the user set nothing usable', async () => {
test('resolveTmdbApiKey falls back to the bundled key only when the user set nothing usable', async (t) => {
const { command } = commandFixture(t);
assert.equal(await resolveTmdbApiKey({}, 'bundled'), 'bundled');
assert.equal(await resolveTmdbApiKey({ apiKey: 'mine' }, 'bundled'), 'mine');
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'printf mine' }, 'bundled'), 'mine');
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'exit 3' }, 'bundled'), 'bundled');
assert.equal(
await resolveTmdbApiKey({ apiKeyCommand: command('process.stdout.write("mine")') }, 'bundled'),
'mine',
);
assert.equal(
await resolveTmdbApiKey({ apiKeyCommand: command('process.exit(3)') }, 'bundled'),
'bundled',
);
});
test('search rejects without a key and never touches the network', async () => {
@@ -205,11 +236,17 @@ test('getDetails returns null for an unknown id', async () => {
assert.equal(await client.getDetails('tv', 1), null);
});
test('client reuses command output across requests and invalidates it when either setting changes', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tmdb-command-'));
const counter = path.join(dir, 'calls');
const command = `printf x >> '${counter}'; printf command-key`;
let config: TmdbConfig = { apiKeyCommand: command };
test('client reuses command output across requests and invalidates it when either setting changes', async (t) => {
const fixture = commandFixture(t);
const counter = path.join(fixture.dir, 'calls');
const createCommand = (key: string) =>
fixture.command(`
const fs = require('node:fs');
const path = require('node:path');
fs.appendFileSync(path.join(__dirname, 'calls'), 'x');
process.stdout.write(${JSON.stringify(key)});
`);
let config: TmdbConfig = { apiKeyCommand: createCommand('command-key') };
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
const client = createTmdbClient({
resolveApiKey: createTmdbApiKeyResolver(
@@ -218,7 +255,6 @@ test('client reuses command output across requests and invalidates it when eithe
),
fetch: fetchImpl,
});
try {
await Promise.all([client.search('a'), client.search('b')]);
await client.getDetails('tv', 1);
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
@@ -229,28 +265,71 @@ test('client reuses command output across requests and invalidates it when eithe
config = { ...config, apiKey: '' };
await client.search('d');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
config = { apiKeyCommand: command.replace('command-key', 'new-key') };
config = { apiKeyCommand: createCommand('new-key') };
await client.search('e');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xxx');
assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'new-key');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('failed commands retry instead of caching the bundled fallback', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tmdb-retry-'));
const marker = path.join(dir, 'ready');
for (const failure of ['error', 'empty'] as const) {
test(`${failure} command output uses a bounded cooldown before retrying`, async (t) => {
const fixture = commandFixture(t);
const counter = path.join(fixture.dir, 'calls');
const command = fixture.command(`
const fs = require('node:fs');
const path = require('node:path');
const counter = path.join(__dirname, 'calls');
fs.appendFileSync(counter, 'x');
if (fs.readFileSync(counter, 'utf8').length === 1) process.exit(${failure === 'error' ? 1 : 0});
process.stdout.write('recovered');
`);
let now = 1000;
const originalNow = Date.now;
Date.now = () => now;
t.after(() => {
Date.now = originalNow;
});
let bundledKey: string | null = 'bundled';
const resolve = createTmdbApiKeyResolver(
() => ({
apiKeyCommand: `if [ -f '${marker}' ]; then printf recovered; else touch '${marker}'; exit 1; fi`,
}),
() => ({ apiKeyCommand: command }),
() => bundledKey,
);
assert.deepEqual(await Promise.all([resolve(), resolve()]), ['bundled', 'bundled']);
now += 29_999;
assert.equal(await resolve(), 'bundled');
bundledKey = null;
assert.equal(await resolve(), null);
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
now += 1;
assert.equal(await resolve(), 'recovered');
assert.equal(await resolve(), 'recovered');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
});
}
for (const setting of ['apiKey', 'apiKeyCommand'] as const) {
test(`changing ${setting} clears a failed command cooldown`, async (t) => {
const fixture = commandFixture(t);
const counter = path.join(fixture.dir, 'calls');
const source = `
const fs = require('node:fs');
const path = require('node:path');
fs.appendFileSync(path.join(__dirname, 'calls'), 'x');
process.exit(1);
`;
let config: TmdbConfig = { apiKeyCommand: fixture.command(source) };
const resolve = createTmdbApiKeyResolver(
() => config,
() => 'bundled',
);
try {
assert.equal(await resolve(), 'bundled');
assert.equal(await resolve(), 'recovered');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
assert.equal(await resolve(), 'bundled');
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
config =
setting === 'apiKey'
? { ...config, apiKey: ' ' }
: { apiKeyCommand: fixture.command(source) };
assert.equal(await resolve(), 'bundled');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
});
}
+16 -3
View File
@@ -6,6 +6,7 @@ import type { StatsTmdbSearchResult } from '../../../types/stats-http-contract';
export const TMDB_API_BASE_URL = 'https://api.themoviedb.org/3';
const TMDB_POSTER_BASE_URL = 'https://image.tmdb.org/t/p/w500';
const REQUEST_TIMEOUT_MS = 8_000;
const API_KEY_COMMAND_RETRY_MS = 30_000;
const ANIMATION_GENRE_ID = 16;
export type TmdbSearchResult = StatsTmdbSearchResult;
@@ -92,6 +93,7 @@ export function createTmdbApiKeyResolver(
apiKey: string | undefined;
apiKeyCommand: string | undefined;
pending: Promise<string | null> | null;
retryAfterMs: number;
}
| undefined;
@@ -102,15 +104,26 @@ export function createTmdbApiKeyResolver(
state.apiKey !== config?.apiKey ||
state.apiKeyCommand !== config?.apiKeyCommand
) {
state = { apiKey: config?.apiKey, apiKeyCommand: config?.apiKeyCommand, pending: null };
state = {
apiKey: config?.apiKey,
apiKeyCommand: config?.apiKeyCommand,
pending: null,
retryAfterMs: 0,
};
}
const current = state;
const literal = current.apiKey?.trim();
if (literal) return literal;
if (!current.apiKeyCommand?.trim()) return getBundledKey();
current.pending ??= resolveTmdbApiKey(current);
if (Date.now() < current.retryAfterMs) return getBundledKey();
current.pending ??= resolveTmdbApiKey(current).then((key) => {
if (!key) {
current.retryAfterMs = Date.now() + API_KEY_COMMAND_RETRY_MS;
current.pending = null;
}
return key;
});
const key = await current.pending;
if (!key) current.pending = null;
return key ?? getBundledKey();
};
}