mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 05:16:27 -07:00
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:
@@ -1,4 +1,4 @@
|
|||||||
type: docs
|
type: docs
|
||||||
area: stats
|
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.
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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.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 |
|
| `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.
|
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,
|
survivor,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (mergedAnimeIds.length > 0) recomputeLifetimeAnimeAggregatesInTransaction(db);
|
recomputeLifetimeAnimeAggregatesInTransaction(db);
|
||||||
return { animeId: survivor, mergedAnimeIds };
|
return { animeId: survivor, mergedAnimeIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test, { type TestContext } from 'node:test';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -11,6 +11,22 @@ import {
|
|||||||
resolveTmdbApiKey,
|
resolveTmdbApiKey,
|
||||||
} from './tmdb-client.js';
|
} 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 {
|
function jsonResponse(payload: unknown, status = 200): Response {
|
||||||
return new Response(JSON.stringify(payload), {
|
return new Response(JSON.stringify(payload), {
|
||||||
status,
|
status,
|
||||||
@@ -28,22 +44,37 @@ function captureFetch(handler: (url: URL, init?: RequestInit) => Response) {
|
|||||||
return { calls, fetchImpl };
|
return { calls, fetchImpl };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('resolveTmdbApiKey prefers the literal key and trims it', async () => {
|
test('resolveTmdbApiKey prefers the literal key and trims it', async (t) => {
|
||||||
assert.equal(await resolveTmdbApiKey({ apiKey: ' abc ', apiKeyCommand: 'echo nope' }), 'abc');
|
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({ apiKey: '', apiKeyCommand: '' }), null);
|
||||||
assert.equal(await resolveTmdbApiKey(undefined), null);
|
assert.equal(await resolveTmdbApiKey(undefined), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveTmdbApiKey runs apiKeyCommand when no literal key is set', async () => {
|
test('resolveTmdbApiKey runs apiKeyCommand when no literal key is set', async (t) => {
|
||||||
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'printf " from-cmd "' }), 'from-cmd');
|
const { command } = commandFixture(t);
|
||||||
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'exit 3' }), null);
|
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({}, 'bundled'), 'bundled');
|
||||||
assert.equal(await resolveTmdbApiKey({ apiKey: 'mine' }, 'bundled'), 'mine');
|
assert.equal(await resolveTmdbApiKey({ apiKey: 'mine' }, 'bundled'), 'mine');
|
||||||
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'printf mine' }, 'bundled'), 'mine');
|
assert.equal(
|
||||||
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'exit 3' }, 'bundled'), 'bundled');
|
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 () => {
|
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);
|
assert.equal(await client.getDetails('tv', 1), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('client reuses command output across requests and invalidates it when either setting changes', async () => {
|
test('client reuses command output across requests and invalidates it when either setting changes', async (t) => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tmdb-command-'));
|
const fixture = commandFixture(t);
|
||||||
const counter = path.join(dir, 'calls');
|
const counter = path.join(fixture.dir, 'calls');
|
||||||
const command = `printf x >> '${counter}'; printf command-key`;
|
const createCommand = (key: string) =>
|
||||||
let config: TmdbConfig = { apiKeyCommand: command };
|
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 { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
|
||||||
const client = createTmdbClient({
|
const client = createTmdbClient({
|
||||||
resolveApiKey: createTmdbApiKeyResolver(
|
resolveApiKey: createTmdbApiKeyResolver(
|
||||||
@@ -218,39 +255,81 @@ test('client reuses command output across requests and invalidates it when eithe
|
|||||||
),
|
),
|
||||||
fetch: fetchImpl,
|
fetch: fetchImpl,
|
||||||
});
|
});
|
||||||
try {
|
await Promise.all([client.search('a'), client.search('b')]);
|
||||||
await Promise.all([client.search('a'), client.search('b')]);
|
await client.getDetails('tv', 1);
|
||||||
await client.getDetails('tv', 1);
|
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
|
||||||
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
|
assert.ok(calls.every(({ url }) => url.searchParams.get('api_key') === 'command-key'));
|
||||||
assert.ok(calls.every(({ url }) => url.searchParams.get('api_key') === 'command-key'));
|
config = { ...config, apiKey: 'literal' };
|
||||||
config = { ...config, apiKey: 'literal' };
|
await client.search('c');
|
||||||
await client.search('c');
|
assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'literal');
|
||||||
assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'literal');
|
config = { ...config, apiKey: '' };
|
||||||
config = { ...config, apiKey: '' };
|
await client.search('d');
|
||||||
await client.search('d');
|
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
|
||||||
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
|
config = { apiKeyCommand: createCommand('new-key') };
|
||||||
config = { apiKeyCommand: command.replace('command-key', 'new-key') };
|
await client.search('e');
|
||||||
await client.search('e');
|
assert.equal(fs.readFileSync(counter, 'utf8'), 'xxx');
|
||||||
assert.equal(fs.readFileSync(counter, 'utf8'), 'xxx');
|
assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'new-key');
|
||||||
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 () => {
|
for (const failure of ['error', 'empty'] as const) {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tmdb-retry-'));
|
test(`${failure} command output uses a bounded cooldown before retrying`, async (t) => {
|
||||||
const marker = path.join(dir, 'ready');
|
const fixture = commandFixture(t);
|
||||||
const resolve = createTmdbApiKeyResolver(
|
const counter = path.join(fixture.dir, 'calls');
|
||||||
() => ({
|
const command = fixture.command(`
|
||||||
apiKeyCommand: `if [ -f '${marker}' ]; then printf recovered; else touch '${marker}'; exit 1; fi`,
|
const fs = require('node:fs');
|
||||||
}),
|
const path = require('node:path');
|
||||||
() => 'bundled',
|
const counter = path.join(__dirname, 'calls');
|
||||||
);
|
fs.appendFileSync(counter, 'x');
|
||||||
try {
|
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: command }),
|
||||||
|
() => bundledKey,
|
||||||
|
);
|
||||||
|
assert.deepEqual(await Promise.all([resolve(), resolve()]), ['bundled', 'bundled']);
|
||||||
|
now += 29_999;
|
||||||
assert.equal(await resolve(), 'bundled');
|
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');
|
||||||
} finally {
|
assert.equal(await resolve(), 'recovered');
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
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',
|
||||||
|
);
|
||||||
|
assert.equal(await resolve(), 'bundled');
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { StatsTmdbSearchResult } from '../../../types/stats-http-contract';
|
|||||||
export const TMDB_API_BASE_URL = 'https://api.themoviedb.org/3';
|
export const TMDB_API_BASE_URL = 'https://api.themoviedb.org/3';
|
||||||
const TMDB_POSTER_BASE_URL = 'https://image.tmdb.org/t/p/w500';
|
const TMDB_POSTER_BASE_URL = 'https://image.tmdb.org/t/p/w500';
|
||||||
const REQUEST_TIMEOUT_MS = 8_000;
|
const REQUEST_TIMEOUT_MS = 8_000;
|
||||||
|
const API_KEY_COMMAND_RETRY_MS = 30_000;
|
||||||
const ANIMATION_GENRE_ID = 16;
|
const ANIMATION_GENRE_ID = 16;
|
||||||
|
|
||||||
export type TmdbSearchResult = StatsTmdbSearchResult;
|
export type TmdbSearchResult = StatsTmdbSearchResult;
|
||||||
@@ -92,6 +93,7 @@ export function createTmdbApiKeyResolver(
|
|||||||
apiKey: string | undefined;
|
apiKey: string | undefined;
|
||||||
apiKeyCommand: string | undefined;
|
apiKeyCommand: string | undefined;
|
||||||
pending: Promise<string | null> | null;
|
pending: Promise<string | null> | null;
|
||||||
|
retryAfterMs: number;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -102,15 +104,26 @@ export function createTmdbApiKeyResolver(
|
|||||||
state.apiKey !== config?.apiKey ||
|
state.apiKey !== config?.apiKey ||
|
||||||
state.apiKeyCommand !== config?.apiKeyCommand
|
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 current = state;
|
||||||
const literal = current.apiKey?.trim();
|
const literal = current.apiKey?.trim();
|
||||||
if (literal) return literal;
|
if (literal) return literal;
|
||||||
if (!current.apiKeyCommand?.trim()) return getBundledKey();
|
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;
|
const key = await current.pending;
|
||||||
if (!key) current.pending = null;
|
|
||||||
return key ?? getBundledKey();
|
return key ?? getBundledKey();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user