Merge remote-tracking branch 'origin/main' into t3code/update-jellyfin-authentication

This commit is contained in:
2026-09-21 00:13:23 -07:00
105 changed files with 4489 additions and 595 deletions
+2
View File
@@ -42,6 +42,7 @@ const {
ankiConnect,
jimaku,
tsukihime,
tmdb,
anilist,
mpv,
yomitan,
@@ -76,6 +77,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
auto_start_overlay,
jimaku,
tsukihime,
tmdb,
anilist,
mpv,
yomitan,
@@ -6,6 +6,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
| 'ankiConnect'
| 'jimaku'
| 'tsukihime'
| 'tmdb'
| 'anilist'
| 'mpv'
| 'yomitan'
@@ -113,6 +114,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
apiBaseUrl: 'https://api.tsukihime.org/v1',
maxSearchResults: 10,
},
tmdb: {
apiKey: '',
apiKeyCommand: '',
},
mpv: {
executablePath: '',
launchMode: 'normal',
@@ -479,6 +479,20 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.tsukihime.maxSearchResults,
description: 'Maximum TsukiHime search results returned.',
},
{
path: 'tmdb.apiKey',
kind: 'string',
defaultValue: defaultConfig.tmdb.apiKey,
description:
'Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).',
},
{
path: 'tmdb.apiKeyCommand',
kind: 'string',
defaultValue: defaultConfig.tmdb.apiKeyCommand,
description:
'Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.',
},
{
path: 'anilist.enabled',
kind: 'boolean',
@@ -164,6 +164,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'],
key: 'tsukihime',
},
{
title: 'TMDB',
description: [
'TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.',
],
notes: ['Hot-reload: TMDB changes apply to the next TMDB request.'],
key: 'tmdb',
},
{
title: 'YouTube Playback Settings',
description: [
+1 -1
View File
@@ -54,7 +54,7 @@ export function getConfigHotReloadField(path: string): string | null {
// These consumers read the current config when the next operation starts.
if (
['jimaku', 'subsync', 'notifications', 'subtitleGeneration'].some((root) =>
['jimaku', 'tmdb', 'subsync', 'notifications', 'subtitleGeneration'].some((root) =>
pathStartsWith(path, root),
)
) {
+13
View File
@@ -58,6 +58,19 @@ export function applyIntegrationConfig(context: ResolveContext): void {
warn('ai', src.ai, resolved.ai, 'Expected object.');
}
if (isObject(src.tmdb)) {
for (const key of ['apiKey', 'apiKeyCommand'] as const) {
const value = asString(src.tmdb[key]);
if (value !== undefined) {
resolved.tmdb[key] = value;
} else if (src.tmdb[key] !== undefined) {
warn(`tmdb.${key}`, src.tmdb[key], resolved.tmdb[key], 'Expected string.');
}
}
} else if (src.tmdb !== undefined) {
warn('tmdb', src.tmdb, resolved.tmdb, 'Expected object.');
}
if (isObject(src.anilist)) {
const enabled = asBoolean(src.anilist.enabled);
if (enabled !== undefined) {
+3
View File
@@ -275,6 +275,9 @@ test('settings registry routes playback-related integrations into integrations',
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
assert.equal(field('tsukihime.apiBaseUrl').category, 'integrations');
assert.equal(field('tsukihime.apiBaseUrl').section, 'TsukiHime');
assert.equal(field('tmdb.apiKey').category, 'integrations');
assert.equal(field('tmdb.apiKey').section, 'TMDB');
assert.equal(field('tmdb.apiKey').secret, true);
});
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
+10 -2
View File
@@ -93,7 +93,12 @@ const JSON_OBJECT_FIELDS = new Set([
'subtitleSidebar.css',
]);
export const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
export const SECRET_PATHS = new Set([
'ai.apiKey',
'jimaku.apiKey',
'tmdb.apiKey',
'anilist.accessToken',
]);
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor']);
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
@@ -135,6 +140,7 @@ const SECTION_ORDER = new Map<string, number>(
'Anki AI',
'AnkiConnect Proxy',
'Jimaku',
'TMDB',
'Subtitle Sync',
'MPV Keybindings',
'Overlay Shortcuts',
@@ -327,6 +333,7 @@ function humanizePath(path: string): string {
.replace(/\bmpv\b/i, 'mpv')
.replace(/\byomitan\b/i, 'Yomitan')
.replace(/\bjimaku\b/i, 'Jimaku')
.replace(/\btmdb\b/i, 'TMDB')
.replace(/\banilist\b/i, 'AniList')
.replace(/\banki\b/i, 'Anki');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
@@ -442,7 +449,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
return { category: 'behavior', section: topSection(path) };
}
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.')) {
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.') || path.startsWith('tmdb.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('subsync.')) {
@@ -510,6 +517,7 @@ function topSection(path: string): string {
subsync: 'Subtitle Sync',
texthooker: 'Texthooker',
tsukihime: 'TsukiHime',
tmdb: 'TMDB',
updates: 'Updates',
websocket: 'WebSocket server',
yomitan: 'Yomitan',
@@ -11,6 +11,7 @@ import {
startStatsServerWithRuntime,
} from '../stats-server.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import { INCOMPATIBLE_PROVIDER_MERGE_MESSAGE } from '../immersion-tracker/anime-merge.js';
import {
clearRetimedSecondarySubtitleCache,
resolveRetimedSecondarySubtitleTextFromSidecar,
@@ -311,6 +312,7 @@ function createMockTracker(
getKanjiOccurrences: async () => OCCURRENCES,
getAnimeLibrary: async () => ANIME_LIBRARY,
getAnimeDetail: async (animeId: number) => (animeId === 1 ? ANIME_DETAIL : null),
hasAnime: async (animeId: number) => animeId === 1,
getAnimeEpisodes: async () => ANIME_EPISODES,
getAnimeAnilistEntries: async () => [],
getAnimeWords: async () => ANIME_WORDS,
@@ -444,6 +446,73 @@ async function withFakeAnkiConnect<T>(
}
describe('stats server API routes', () => {
it('rejects untrusted mutation requests before merging anime', async () => {
let merges = 0;
const app = createStatsApp(
createMockTracker({
mergeAnime: async () => {
merges += 1;
return { survivingAnimeId: 1, mergedAnimeIds: [2], movedVideos: 1 };
},
}),
);
const rejectedHeaders: Record<string, string>[] = [
{ Origin: 'https://attacker.example', 'Content-Type': 'text/plain' },
{ Origin: 'https://attacker.example', 'Content-Type': 'application/json' },
{ Origin: 'null', 'Content-Type': 'application/json' },
{ Origin: 'http://localhost:4321', 'Content-Type': 'application/json' },
{ Origin: 'http://localhost/', 'Content-Type': 'application/json' },
{ 'Sec-Fetch-Site': 'cross-site', 'Content-Type': 'application/json' },
{ Host: 'attacker.example', 'Content-Type': 'application/json' },
];
for (const headers of rejectedHeaders) {
const response = await app.request('/api/stats/anime/1/merge', {
method: 'POST',
headers,
body: JSON.stringify({ sourceAnimeIds: [2] }),
});
assert.equal(response.status, 403, JSON.stringify(headers));
}
assert.equal(merges, 0);
for (const origin of [undefined, 'http://localhost']) {
const headers = new Headers({ 'Content-Type': 'application/json; charset=utf-8' });
if (origin) headers.set('Origin', origin);
const response = await app.request('/api/stats/anime/1/merge', {
method: 'POST',
headers,
body: JSON.stringify({ sourceAnimeIds: [2] }),
});
assert.equal(response.status, 200);
}
assert.equal(merges, 2);
});
it('requires JSON for mutation bodies and preserves bodyless deletion', async () => {
let deletions = 0;
const app = createStatsApp(
createMockTracker({
deleteSession: async () => {
deletions += 1;
},
}),
);
const invalid = await app.request('/api/stats/sessions/1', {
method: 'DELETE',
body: '{}',
});
assert.equal(invalid.status, 415);
assert.equal(deletions, 0);
const valid = await app.request('/api/stats/sessions/1', { method: 'DELETE' });
assert.equal(valid.status, 200);
assert.equal(deletions, 1);
const rebound = await app.request('http://attacker.example/api/stats/sessions/1', {
method: 'DELETE',
headers: { Origin: 'http://attacker.example' },
});
assert.equal(rebound.status, 403);
assert.equal(deletions, 1);
});
it('GET /api/stats/overview returns overview data', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/overview');
@@ -1153,7 +1222,7 @@ describe('stats server API routes', () => {
body: JSON.stringify({ dryRun: false, lookbackDays: null }),
});
assert.equal(res.status, 415);
assert.equal(res.status, 403);
assert.equal(cleanupCalls, 0);
});
@@ -3662,6 +3731,25 @@ Aligned English subtitle
assert.equal(res.status, 404);
});
it('POST /api/stats/anime/:animeId/merge rejects mixed AniList and TMDB entries as 409', async () => {
const app = createStatsApp(
createMockTracker({
mergeAnime: async () => {
throw new Error(INCOMPATIBLE_PROVIDER_MERGE_MESSAGE);
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"sourceAnimeIds":[8]}',
});
assert.equal(res.status, 409);
assert.deepEqual(await res.json(), { error: INCOMPATIBLE_PROVIDER_MERGE_MESSAGE });
});
it('PATCH /api/stats/media/:videoId/anime reports an unknown target as 404', async () => {
const app = createStatsApp(
createMockTracker({
@@ -4128,4 +4216,108 @@ Aligned English subtitle
});
}
});
it('enforces request safety through node:http without rejecting bodyless DELETEs', async () => {
await withTempDir(async (staticDir) => {
let deletions = 0;
const tracker = createMockTracker({
deleteSession: async () => {
deletions += 1;
},
});
const listener = http.createServer();
const server = await startNodeHttpServer(
createStatsApp(tracker),
{ port: 0, staticDir, tracker },
(handler) => {
listener.on('request', handler);
return listener;
},
);
try {
const address = listener.address();
assert.ok(address && typeof address !== 'string');
const origin = `http://127.0.0.1:${address.port}`;
const url = `${origin}/api/stats/sessions/1`;
for (const headers of [undefined, { 'Content-Length': '0' }]) {
const response = await fetch(url, { method: 'DELETE', headers });
assert.equal(response.status, 200);
await response.arrayBuffer();
}
assert.equal(deletions, 2);
for (const headers of [
new Headers({ Origin: 'https://attacker.example' }),
new Headers({ Origin: 'null' }),
new Headers({ Host: 'attacker.example' }),
new Headers({ 'Sec-Fetch-Site': 'same-site' }),
]) {
const response = await fetch(url, { method: 'DELETE', headers });
assert.equal(response.status, 403, JSON.stringify(headers));
await response.arrayBuffer();
}
const invalid = await fetch(url, { method: 'DELETE', body: '{}' });
assert.equal(invalid.status, 415);
await invalid.arrayBuffer();
assert.equal(deletions, 2);
const valid = await fetch(url, {
method: 'DELETE',
headers: { Origin: origin, 'Content-Type': 'application/json' },
body: '{}',
});
assert.equal(valid.status, 200);
await valid.arrayBuffer();
assert.equal(deletions, 3);
} finally {
await server.close();
}
});
});
});
it('TMDB reassignment returns 404 for a missing library entry before fetching details', async () => {
const assignments: number[] = [];
let fetches = 0;
const app = createStatsApp(
createMockTracker({
reassignAnimeTmdb: async (animeId: number) => {
assignments.push(animeId);
return { animeId, mergedAnimeIds: [] };
},
}),
{
tmdbClient: {
search: async () => [],
getDetails: async () => {
fetches += 1;
return {
tmdbId: 12,
tmdbType: 'tv',
titleEnglish: 'Drama',
titleNative: null,
description: null,
posterUrl: null,
episodesTotal: 10,
year: null,
originalLanguage: 'ja',
isAnimation: false,
allTitles: ['Drama'],
};
},
},
},
);
const request = (animeId: number) =>
app.request(`/api/stats/anime/${animeId}/tmdb`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tmdbId: 12, tmdbType: 'tv' }),
});
assert.equal((await request(99999)).status, 404);
assert.equal(fetches, 0);
assert.deepEqual(assignments, []);
const response = await request(1);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { ok: true });
assert.equal(fetches, 1);
assert.deepEqual(assignments, [1]);
});
@@ -540,3 +540,200 @@ test('fetchIfMissing re-resolves an unresolved season once AniList publishes the
cleanupDbPath(dbPath);
}
});
for (const linkedToAnilist of [false, true]) {
test(`TMDB fallback preserves AniList identity when linked=${linkedToAnilist}`, async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-01.mkv', {
canonicalTitle: 'Hanzawa Naoki - 01.mkv',
sourcePath: '/tmp/hanzawa-01.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
anilistId: linkedToAnilist ? 42 : null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: null,
parsedTitle: 'Hanzawa Naoki',
parsedSeason: null,
parsedEpisode: 1,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: null,
});
const fetchCalls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
fetchCalls.push(url);
if (url.startsWith('https://graphql.anilist.co')) {
return createJsonResponse({ data: { Page: { media: [] } } });
}
assert.equal(url, 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
return new Response(new Uint8Array([5, 6, 7]), {
status: 200,
headers: { 'Content-Type': 'image/jpeg' },
});
}) as typeof fetch;
const resolvedTitles: string[] = [];
try {
const fetcher = createCoverArtFetcher(
{ acquire: async () => {}, recordResponse: () => {} },
console,
{
runGuessit: async () => {
throw new Error('guessit unavailable');
},
liveAction: {
async resolveByTitle(title) {
resolvedTitles.push(title);
if (title !== 'Hanzawa Naoki') return null;
return {
tmdbId: 61222,
tmdbType: 'tv',
titleEnglish: 'Hanzawa Naoki',
titleNative: '半沢直樹',
description: 'A banker fights back.',
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
episodesTotal: 10,
year: 2013,
originalLanguage: 'ja',
isAnimation: false,
allTitles: ['Hanzawa Naoki', '半沢直樹'],
};
},
async resolveById() {
return null;
},
},
},
);
const fetched = await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 01.mkv');
const stored = getCoverArt(db, videoId);
const anime = db
.prepare(
'SELECT media_kind AS mediaKind, tmdb_id AS tmdbId, description FROM imm_anime WHERE anime_id = ?',
)
.get(animeId) as { mediaKind: string; tmdbId: number | null; description: string | null };
if (linkedToAnilist) {
assert.equal(fetched, false);
assert.equal(stored?.coverBlob, null);
assert.equal(stored?.coverUrl, null);
assert.equal(anime.mediaKind, 'anime');
assert.equal(anime.tmdbId, null);
assert.deepEqual(resolvedTitles, []);
const requestCount = fetchCalls.length;
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 01.mkv'), false);
assert.equal(fetchCalls.length, requestCount);
return;
}
assert.equal(fetched, true);
// The raw fallback-parser title is tried first, then the tag-stripped one.
assert.deepEqual(resolvedTitles, ['Hanzawa Naoki - 01', 'Hanzawa Naoki']);
assert.equal(stored?.anilistId, null);
assert.equal(stored?.coverUrl, 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
assert.equal(Buffer.from(stored?.coverBlob ?? []).toString('hex'), '050607');
assert.equal(anime.mediaKind, 'live_action');
assert.equal(anime.tmdbId, 61222);
assert.equal(anime.description, 'A banker fights back.');
assert.ok(fetchCalls.some((url) => url.startsWith('https://graphql.anilist.co')));
} finally {
globalThis.fetch = originalFetch;
db.close();
cleanupDbPath(dbPath);
}
});
}
test('fetchIfMissing skips AniList for an entry already linked to TMDB', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-02.mkv', {
canonicalTitle: 'Hanzawa Naoki - 02.mkv',
sourcePath: '/tmp/hanzawa-02.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: null,
parsedTitle: 'Hanzawa Naoki',
parsedSeason: null,
parsedEpisode: 2,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: null,
});
db.prepare(
"UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 61222, tmdb_type = 'tv' WHERE anime_id = ?",
).run(animeId);
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
assert.equal(String(input), 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
return new Response(new Uint8Array([1]), { status: 200 });
}) as typeof fetch;
const byIdCalls: Array<[string, number]> = [];
try {
const fetcher = createCoverArtFetcher(
{ acquire: async () => {}, recordResponse: () => {} },
console,
{
liveAction: {
async resolveByTitle() {
throw new Error('title search must not run for a linked entry');
},
async resolveById(tmdbType, tmdbId) {
byIdCalls.push([tmdbType, tmdbId]);
return {
tmdbId,
tmdbType,
titleEnglish: 'Hanzawa Naoki',
titleNative: null,
description: null,
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
episodesTotal: 10,
year: null,
originalLanguage: 'ja',
isAnimation: false,
allTitles: [],
};
},
},
},
);
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 02.mkv'), true);
assert.deepEqual(byIdCalls, [['tv', 61222]]);
assert.equal(getCoverArt(db, videoId)?.coverBlob?.length, 1);
} finally {
globalThis.fetch = originalFetch;
db.close();
cleanupDbPath(dbPath);
}
});
+91 -18
View File
@@ -16,6 +16,9 @@ import {
type AnilistQueryExecutor,
type AnilistSeasonResolution,
} from './season-resolver';
import { getVideoTmdbLink, linkAnimeToTmdbTitle } from '../immersion-tracker/live-action-link';
import type { LiveActionMetadataResolver } from '../tmdb/live-action-resolver';
import type { TmdbTitleDetails } from '../tmdb/tmdb-client';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
const NO_MATCH_RETRY_MS = 5 * 60 * 1000;
@@ -39,6 +42,8 @@ interface CoverArtCandidate {
interface CoverArtFetcherOptions {
runGuessit?: GuessAnilistMediaInfoDeps['runGuessit'];
/** Live-action fallback consulted when AniList has no match for a title. */
liveAction?: LiveActionMetadataResolver;
}
export function stripFilenameTags(raw: string): string {
@@ -152,6 +157,60 @@ export function createCoverArtFetcher(
return true;
};
const cacheNoMatch = (db: DatabaseSync, videoId: number): void => {
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: null,
coverBlob: null,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
};
// Links the video's library entry to the TMDB title and stores its poster.
const storeLiveActionArt = async (
db: DatabaseSync,
videoId: number,
details: TmdbTitleDetails,
): Promise<boolean> => {
const row = db
.prepare(
`SELECT v.anime_id AS animeId, a.anilist_id AS anilistId
FROM imm_videos v LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.video_id = ?`,
)
.get(videoId) as { animeId: number | null; anilistId: number | null } | undefined;
if (row?.anilistId != null) return false;
if (row?.animeId) {
const link = linkAnimeToTmdbTitle(db, row.animeId, details, { mode: 'auto' });
if (link.mergedAnimeIds.length > 0) {
logger.info(
'cover-art: folded library entries %s into %d (same TMDB title)',
link.mergedAnimeIds.join(','),
link.animeId,
);
}
}
const coverBlob = details.posterUrl ? await downloadImage(details.posterUrl) : null;
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: details.posterUrl,
coverBlob,
titleRomaji: null,
titleEnglish: details.titleEnglish,
episodesTotal: details.episodesTotal,
});
logger.info(
'cover-art: linked videoId=%d to TMDB %s/%d "%s"',
videoId,
details.tmdbType,
details.tmdbId,
details.titleEnglish ?? details.titleNative ?? '',
);
return coverBlob !== null;
};
const resolveCanonicalTitle = (
db: DatabaseSync,
videoId: number,
@@ -197,7 +256,7 @@ export function createCoverArtFetcher(
`
SELECT 1 FROM imm_videos v
JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.video_id = ? AND a.media_kind != 'anime'
WHERE v.video_id = ? AND a.media_kind = 'youtube'
`,
)
.get(videoId);
@@ -235,18 +294,31 @@ export function createCoverArtFetcher(
return false;
}
// A live-action entry already knows its TMDB title; AniList has nothing
// to add and would only produce a spurious anime match.
const hasAnilistLink = Boolean(
db
.prepare(
`SELECT 1 FROM imm_videos v JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.video_id = ? AND a.anilist_id IS NOT NULL`,
)
.get(videoId),
);
const tmdbLink = getVideoTmdbLink(db, videoId);
if (tmdbLink && !hasAnilistLink) {
const details = await options.liveAction?.resolveById(tmdbLink.tmdbType, tmdbLink.tmdbId);
if (details) {
return storeLiveActionArt(db, videoId, details);
}
cacheNoMatch(db, videoId);
return false;
}
const effectiveTitle = resolveCanonicalTitle(db, videoId, canonicalTitle);
const cleaned = stripFilenameTags(effectiveTitle);
if (!cleaned) {
logger.warn('cover-art: empty title after stripping tags for videoId=%d', videoId);
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: null,
coverBlob: null,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
cacheNoMatch(db, videoId);
return false;
}
@@ -304,15 +376,16 @@ export function createCoverArtFetcher(
const selected = resolution?.media ?? null;
if (!selected) {
logger.info('cover-art: no Anilist results for "%s", caching no-match', searchBase);
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: null,
coverBlob: null,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
if (options.liveAction && !hasAnilistLink) {
for (const searchTitle of searchTitles) {
const details = await options.liveAction.resolveByTitle(searchTitle);
if (details) {
return storeLiveActionArt(db, videoId, details);
}
}
}
logger.info('cover-art: no Anilist or TMDB results for "%s", caching no-match', searchBase);
cacheNoMatch(db, videoId);
return false;
}
@@ -5395,3 +5395,91 @@ test('getVocabularySummary keeps different known-word snapshots independent', as
cleanupDbPath(dbPath);
}
});
for (const provider of ['anilist', 'tmdb'] as const) {
test(`${provider} reassignment keeps metadata and artwork on download failure, then replaces or clears both`, async () => {
const dbPath = makeDbPath();
const originalFetch = globalThis.fetch;
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const { db } = tracker as unknown as { db: DatabaseSync };
db.exec(`
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'show', 'Show', 1000, 1000);
INSERT INTO imm_videos(video_id, video_key, canonical_title, source_type, anime_id, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'local:/tmp/show.mkv', 'Show', 1, 1, 0, 1000, 1000);
`);
const tmdb = {
tmdbId: 12,
tmdbType: 'tv' as const,
titleEnglish: 'Drama',
titleNative: null,
description: 'New description',
episodesTotal: 10,
};
globalThis.fetch = async () => new Response(new Uint8Array([1, 2, 3]));
if (provider === 'anilist') {
await tracker.reassignAnimeTmdb(1, { ...tmdb, posterUrl: 'https://images.test/old' });
} else {
await tracker.reassignAnimeAnilist(1, {
anilistId: 42,
coverUrl: 'https://images.test/old',
});
}
assert.equal(await tracker.hasAnime(1), true);
assert.equal(await tracker.hasAnime(999), false);
const readMetadata = () =>
db.prepare('SELECT * FROM imm_anime WHERE anime_id = 1').get() as {
media_kind: string;
anilist_id: number | null;
tmdb_id: number | null;
};
const before = readMetadata();
const oldArt = await tracker.getAnimeCoverArt(1);
const snapshot = (value: unknown) =>
JSON.stringify(value, (key, item: unknown) => (key === '_metadata' ? undefined : item));
const reassign = (url: string | null) =>
provider === 'anilist'
? tracker!.reassignAnimeAnilist(1, { anilistId: 99, coverUrl: url })
: tracker!.reassignAnimeTmdb(1, { ...tmdb, posterUrl: url });
for (const failure of ['http', 'network']) {
globalThis.fetch = async () => {
if (failure === 'network') throw new Error('offline');
return new Response(null, { status: 503 });
};
await assert.rejects(reassign('https://images.test/new'));
assert.equal(snapshot(readMetadata()), snapshot(before));
assert.equal(snapshot(await tracker.getAnimeCoverArt(1)), snapshot(oldArt));
}
globalThis.fetch = async () => new Response(new Uint8Array([9, 8, 7]));
if (provider === 'anilist') {
// Retained sessions without lifetime summaries exercise the bootstrap
// inside the reassignment transaction.
db.exec(`INSERT INTO imm_sessions(session_uuid, video_id, started_at_ms, ended_at_ms,
status, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES ('retained-session', 1, '1000', '2000', 2, 1000, 1000, 2000)`);
}
await reassign('https://images.test/new');
const detail = readMetadata();
assert.equal(detail.media_kind, provider === 'anilist' ? 'anime' : 'live_action');
assert.equal(detail.anilist_id, provider === 'anilist' ? 99 : null);
assert.equal(detail.tmdb_id, provider === 'tmdb' ? 12 : null);
if (provider === 'anilist') {
assert.equal((await tracker.getAnimeDetail(1))?.totalActiveMs, 1000);
}
assert.deepEqual(
new Uint8Array((await tracker.getAnimeCoverArt(1))!.coverBlob!),
new Uint8Array([9, 8, 7]),
);
await reassign(null);
assert.equal(await tracker.getAnimeCoverArt(1), null);
assert.equal(readMetadata().media_kind, detail.media_kind);
} finally {
globalThis.fetch = originalFetch;
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
}
+137 -50
View File
@@ -33,6 +33,7 @@ import {
applySessionLifetimeSummary,
reconcileStaleActiveSessions,
rebuildLifetimeSummaries as rebuildLifetimeSummaryTables,
rebuildLifetimeSummariesInTransaction,
recomputeLifetimeAnimeFromMedia,
recomputeLifetimeGlobalFromSummaries,
repairLifetimeSummariesFromMedia,
@@ -90,6 +91,7 @@ import {
} from './immersion-tracker/query-library';
import {
cleanupVocabularyStats,
clearAnimeCoverArt,
getVideoDurationMs,
markVideoWatched,
upsertCoverArt,
@@ -115,7 +117,7 @@ import {
dismissAnimeMergeRecommendation,
getAnimeMergeRecommendations,
repairLegacySeasonlessAnimeRows,
resolveAnimeAnilistConflict,
resolveAnimeAnilistConflictInTransaction,
type AnimeMergeRecommendation,
} from './immersion-tracker/anime-season-repair';
import {
@@ -124,6 +126,11 @@ import {
type AnimeMergeSummary,
type VideoMoveSummary,
} from './immersion-tracker/anime-merge';
import {
linkAnimeToTmdbTitleInTransaction,
type LiveActionLinkResult,
type LiveActionTitleInput,
} from './immersion-tracker/live-action-link';
import {
buildVideoKey,
deriveCanonicalTitle,
@@ -819,6 +826,10 @@ export class ImmersionTrackerService {
return getAnimeDetail(this.db, animeId);
}
async hasAnime(animeId: number): Promise<boolean> {
return Boolean(this.db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId));
}
async getAnimeEpisodes(animeId: number): Promise<AnimeEpisodeRow[]> {
return getAnimeEpisodes(this.db, animeId);
}
@@ -1017,19 +1028,31 @@ export class ImmersionTrackerService {
coverUrl?: string | null;
},
): Promise<void> {
const coverBlob = await this.downloadReplacementCover(info.coverUrl);
this.requireWriteQueueDrained('reassigning an AniList entry');
// The user is acting on this entry, so it is the one that survives when
// another row already claims the same AniList id.
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId, {
survivor: 'target',
matchConfidence: 'manual',
});
if (repair.anilistAssignmentBlocked) return;
this.db
.prepare(
`
this.db.exec('BEGIN IMMEDIATE');
try {
this.db
.prepare('UPDATE imm_anime SET tmdb_id = NULL, tmdb_type = NULL WHERE anime_id = ?')
.run(animeId);
// The user is acting on this entry, so it is the one that survives when
// another row already claims the same AniList id.
const repair = resolveAnimeAnilistConflictInTransaction(this.db, animeId, info.anilistId, {
survivor: 'target',
matchConfidence: 'manual',
});
if (repair.anilistAssignmentBlocked) {
this.db.exec('ROLLBACK');
return;
}
this.db
.prepare(
`
UPDATE imm_anime
SET anilist_id = ?,
media_kind = 'anime',
tmdb_id = NULL,
tmdb_type = NULL,
title_romaji = COALESCE(?, title_romaji),
title_english = COALESCE(?, title_english),
title_native = COALESCE(?, title_native),
@@ -1038,46 +1061,32 @@ export class ImmersionTrackerService {
LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
)
.run(
info.anilistId,
info.titleRomaji ?? null,
info.titleEnglish ?? null,
info.titleNative ?? null,
info.episodesTotal ?? null,
info.description !== undefined ? 1 : 0,
info.description ?? null,
nowMs(),
animeId,
);
// Empty lifetime tables still need the retained-session bootstrap. Once a
// media ledger exists, only the redistributed and explicitly edited anime
// can have changed.
if (shouldBackfillLifetimeSummaries(this.db)) {
repairLifetimeSummariesFromMedia(this.db);
} else {
const affectedAnimeIds = new Set(repair.affectedAnimeIds);
affectedAnimeIds.add(animeId);
recomputeLifetimeAnimeFromMedia(this.db, [...affectedAnimeIds]);
recomputeLifetimeGlobalFromSummaries(this.db);
}
// Update cover art for all videos in this anime
if (info.coverUrl) {
const videos = this.db
.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
.all(animeId) as Array<{ video_id: number }>;
let coverBlob: Buffer | null = null;
try {
const res = await fetch(info.coverUrl);
if (res.ok) {
coverBlob = Buffer.from(await res.arrayBuffer());
}
} catch {
/* ignore */
)
.run(
info.anilistId,
info.titleRomaji ?? null,
info.titleEnglish ?? null,
info.titleNative ?? null,
info.episodesTotal ?? null,
info.description !== undefined ? 1 : 0,
info.description ?? null,
nowMs(),
animeId,
);
// Empty lifetime tables still need the retained-session bootstrap. Once a
// media ledger exists, only the redistributed and explicitly edited anime
// can have changed.
if (shouldBackfillLifetimeSummaries(this.db)) {
rebuildLifetimeSummariesInTransaction(this.db);
} else {
const affectedAnimeIds = new Set(repair.affectedAnimeIds);
affectedAnimeIds.add(animeId);
recomputeLifetimeAnimeFromMedia(this.db, [...affectedAnimeIds]);
recomputeLifetimeGlobalFromSummaries(this.db);
}
for (const v of videos) {
upsertCoverArt(this.db, v.video_id, {
if (info.coverUrl) {
this.applyCoverArtToAnimeVideos(animeId, {
anilistId: info.anilistId,
coverUrl: info.coverUrl,
coverBlob,
@@ -1085,7 +1094,85 @@ export class ImmersionTrackerService {
titleEnglish: info.titleEnglish ?? null,
episodesTotal: info.episodesTotal ?? null,
});
} else {
clearAnimeCoverArt(this.db, animeId);
}
this.db.exec('COMMIT');
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
}
/**
* Link a library entry to a TMDB title chosen in the dashboard. Every other
* entry pointing at the same title is folded into this one, and its poster
* replaces the art of every episode.
*/
async reassignAnimeTmdb(
animeId: number,
details: LiveActionTitleInput & { posterUrl: string | null },
): Promise<LiveActionLinkResult> {
const coverBlob = await this.downloadReplacementCover(details.posterUrl);
this.requireWriteQueueDrained('linking a TMDB title');
this.db.exec('BEGIN IMMEDIATE');
try {
const result = linkAnimeToTmdbTitleInTransaction(this.db, animeId, details, {
mode: 'manual',
});
if (details.posterUrl) {
this.applyCoverArtToAnimeVideos(result.animeId, {
anilistId: null,
coverUrl: details.posterUrl,
coverBlob,
titleRomaji: null,
titleEnglish: details.titleEnglish,
episodesTotal: details.episodesTotal,
});
} else {
// The user chose this title deliberately, so art from the previous link
// must not keep standing in for it.
clearAnimeCoverArt(this.db, result.animeId);
}
this.db.exec('COMMIT');
return result;
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
}
private async downloadReplacementCover(url: string | null | undefined): Promise<Buffer | null> {
if (!url) return null;
const response = await fetch(url);
if (!response.ok) throw new Error(`Cover download failed: ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
/** Stores the downloaded replacement against every episode of the entry. */
private applyCoverArtToAnimeVideos(
animeId: number,
art: {
anilistId: number | null;
coverUrl: string;
coverBlob: Buffer | null;
titleRomaji: string | null;
titleEnglish: string | null;
episodesTotal: number | null;
},
): void {
const videos = this.db
.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
.all(animeId) as Array<{ video_id: number }>;
for (const v of videos) {
upsertCoverArt(this.db, v.video_id, {
anilistId: art.anilistId,
coverUrl: art.coverUrl,
coverBlob: art.coverBlob,
titleRomaji: art.titleRomaji,
titleEnglish: art.titleEnglish,
episodesTotal: art.episodesTotal,
});
}
}
@@ -0,0 +1,309 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { Database } from '../sqlite.js';
import type { DatabaseSync } from '../sqlite.js';
import { applyPragmas, ensureSchema, getOrCreateAnimeRecord } from '../storage.js';
import { repairLegacySeasonlessAnimeRows } from '../anime-season-repair.js';
import { mergeAnimeRecords, mergeAnimeRecordsInTransaction } from '../anime-merge.js';
import { getVideoTmdbLink, linkAnimeToTmdbTitle } from '../live-action-link.js';
import { getAnimeCoverArt, getCoverArt } from '../query-library.js';
import { clearAnimeCoverArt, upsertCoverArt } from '../query-maintenance.js';
const BASE_MS = 1_700_000_000_000;
function withDb(work: (db: DatabaseSync) => void): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-live-action-link-'));
const db = new Database(path.join(dir, 'immersion.sqlite'));
try {
applyPragmas(db);
ensureSchema(db);
work(db);
} finally {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
}
function insertAnime(
db: DatabaseSync,
animeId: number,
title: string,
anilistId: number | null = null,
) {
db.prepare(
`INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, anilist_id, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(animeId, title.toLowerCase(), title, anilistId, BASE_MS, BASE_MS);
}
function insertEpisode(db: DatabaseSync, videoId: number, animeId: number, season: number | null) {
db.prepare(
`INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, parsed_title, parsed_season, parsed_episode, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, 1, 'Hanzawa Naoki', ?, ?, 1, 1440000, ?, ?)`,
).run(
videoId,
`local:/tmp/${videoId}.mkv`,
animeId,
`Ep ${videoId}`,
season,
videoId,
BASE_MS,
BASE_MS,
);
db.prepare(
`INSERT INTO imm_lifetime_media(video_id, total_sessions, total_active_ms, total_cards, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, 1, 1000, 0, 1, ?, ?, ?, ?)`,
).run(videoId, String(BASE_MS), String(BASE_MS + 1000), BASE_MS, BASE_MS);
}
interface AnimeRowView {
mediaKind: string;
tmdbId: number | null;
tmdbType: string | null;
anilistId: number | null;
titleEnglish: string | null;
titleNative: string | null;
description: string | null;
}
// Copies the selected columns so the driver's row metadata does not leak into
// deep-equality assertions.
function animeRow(db: DatabaseSync, animeId: number): AnimeRowView | undefined {
const row = db
.prepare(
`SELECT media_kind AS mediaKind, tmdb_id AS tmdbId, tmdb_type AS tmdbType, anilist_id AS anilistId,
title_english AS titleEnglish, title_native AS titleNative, description
FROM imm_anime WHERE anime_id = ?`,
)
.get(animeId) as AnimeRowView | undefined;
if (!row) return undefined;
const { mediaKind, tmdbId, tmdbType, anilistId, titleEnglish, titleNative, description } = row;
return { mediaKind, tmdbId, tmdbType, anilistId, titleEnglish, titleNative, description };
}
function animeCount(db: DatabaseSync): number {
return (db.prepare('SELECT COUNT(*) AS n FROM imm_anime').get() as { n: number }).n;
}
function videoOwner(db: DatabaseSync, videoId: number): number | null {
return (
db.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?').get(videoId) as {
animeId: number | null;
}
).animeId;
}
const HANZAWA = {
tmdbId: 61222,
tmdbType: 'tv' as const,
titleEnglish: 'Hanzawa Naoki',
titleNative: '半沢直樹',
description: 'A banker fights back.',
episodesTotal: 10,
};
test('a manual link overwrites metadata, drops the AniList link, and folds other holders in', () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki', 4242);
insertAnime(db, 2, 'Hanzawa Naoki Season 2');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 2, 2);
db.prepare(
`UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = ?, tmdb_type = 'tv', description = 'old' WHERE anime_id = 2`,
).run(HANZAWA.tmdbId);
const result = linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode: 'manual' });
assert.deepEqual(result, { animeId: 1, mergedAnimeIds: [2] });
assert.deepEqual(animeRow(db, 1), {
mediaKind: 'live_action',
tmdbId: 61222,
tmdbType: 'tv',
anilistId: null,
titleEnglish: 'Hanzawa Naoki',
titleNative: '半沢直樹',
description: 'A banker fights back.',
});
assert.equal(animeRow(db, 2), undefined);
assert.equal(animeCount(db), 1);
assert.equal(videoOwner(db, 2), 1);
assert.deepEqual(getVideoTmdbLink(db, 2), { animeId: 1, tmdbId: 61222, tmdbType: 'tv' });
});
});
test('an automatic link joins the entry that already owns the title and only fills gaps', () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki');
insertEpisode(db, 1, 1, 1);
linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, description: 'kept' }, { mode: 'manual' });
const newcomer = getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
seasonScope: 2,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
insertEpisode(db, 2, newcomer, 2);
const result = linkAnimeToTmdbTitle(db, newcomer, HANZAWA, { mode: 'auto' });
assert.deepEqual(result, { animeId: 1, mergedAnimeIds: [newcomer] });
assert.equal(animeRow(db, 1)?.description, 'kept');
assert.equal(animeCount(db), 1);
assert.equal(videoOwner(db, 2), 1);
// The merged-away season title is remembered, so the next episode of that
// season lands on the survivor without a detour through a new row.
const again = getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
seasonScope: 2,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
assert.equal(again, 1);
});
});
test('startup season repair leaves multi-season live-action entries alone', () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 1, 2);
linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode: 'manual' });
repairLegacySeasonlessAnimeRows(db);
assert.equal(animeCount(db), 1);
assert.equal(videoOwner(db, 1), 1);
assert.equal(videoOwner(db, 2), 1);
assert.equal(getVideoTmdbLink(db, 2)?.animeId, 1);
});
});
test('getVideoTmdbLink is null for anime entries and unlinked videos', () => {
withDb((db) => {
insertAnime(db, 1, 'Some Anime', 77);
insertEpisode(db, 1, 1, 1);
assert.equal(getVideoTmdbLink(db, 1), null);
assert.equal(getVideoTmdbLink(db, 99), null);
});
});
test('clearAnimeCoverArt drops every episode cover of the entry and its orphaned blob', () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki');
insertAnime(db, 2, 'Other Show');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 1, 1);
insertEpisode(db, 3, 2, 1);
const shared = Buffer.from([1, 2, 3]);
for (const videoId of [1, 2]) {
upsertCoverArt(db, videoId, {
anilistId: 4242,
coverUrl: 'https://images.test/a.jpg',
coverBlob: shared,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
}
upsertCoverArt(db, 3, {
anilistId: 99,
coverUrl: 'https://images.test/b.jpg',
coverBlob: Buffer.from([9]),
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
clearAnimeCoverArt(db, 1);
assert.equal(getAnimeCoverArt(db, 1), null);
assert.equal(getCoverArt(db, 3)?.coverBlob?.length, 1);
const blobs = (
db.prepare('SELECT COUNT(*) AS n FROM imm_cover_art_blobs').get() as { n: number }
).n;
assert.equal(blobs, 1);
});
});
for (const targetId of [1, 2, 3]) {
test(`merge rejects mixed providers before moving any source into entry ${targetId}`, () => {
withDb((db) => {
insertAnime(db, 1, 'Anime', 77);
insertAnime(db, 2, 'Drama');
insertAnime(db, 3, 'Unlinked');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 2, 1);
db.exec(
"UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 12, tmdb_type = 'tv' WHERE anime_id = 2",
);
for (const merge of [mergeAnimeRecords, mergeAnimeRecordsInTransaction]) {
assert.throws(
() => merge(db, targetId, [3, 1, 2]),
/AniList-linked and TMDB-linked library entries cannot be merged/,
);
assert.equal(animeCount(db), 3);
assert.equal(videoOwner(db, 1), 1);
assert.equal(videoOwner(db, 2), 2);
}
});
});
}
for (const mode of ['manual', 'auto'] as const) {
test(`TMDB ${mode} linking rolls back the merge when the survivor update fails`, () => {
withDb((db) => {
insertAnime(db, 1, 'New entry');
insertAnime(db, 2, 'Existing entry');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 2, 2);
db.prepare(
"UPDATE imm_anime SET tmdb_id = ?, tmdb_type = 'tv', media_kind = 'live_action' WHERE anime_id = 2",
).run(HANZAWA.tmdbId);
db.exec(`CREATE TRIGGER reject_link BEFORE UPDATE ON imm_anime
WHEN NEW.description = 'A banker fights back.'
BEGIN SELECT RAISE(ABORT, 'rejected survivor update'); END`);
assert.throws(
() => linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode }),
/rejected survivor update/,
);
assert.equal(animeCount(db), 2);
assert.equal(videoOwner(db, 1), 1);
assert.equal(videoOwner(db, 2), 2);
assert.equal(animeRow(db, 1)?.tmdbId, null);
assert.equal(animeRow(db, 2)?.tmdbId, HANZAWA.tmdbId);
});
});
}
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);
});
});
}
@@ -1,4 +1,4 @@
import type { MediaKind } from '../../../shared/media-kind';
import { shareTitleNamespace, type MediaKind } from '../../../shared/media-kind';
import type { DatabaseSync } from './sqlite';
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
import { toDbTimestamp } from './query-shared';
@@ -6,8 +6,12 @@ import { nowMs } from './time';
/** Thrown when a move names an episode or destination entry that is not there. */
export const UNKNOWN_MOVE_TARGET_MESSAGE = 'Unknown episode or target library entry';
/** Thrown when a merge or move would mix an anime entry with a YouTube channel. */
export const MEDIA_KIND_MISMATCH_MESSAGE = 'Anime and YouTube channel entries cannot be combined';
/** Thrown when a merge would combine an AniList-linked entry with a TMDB-linked one. */
export const INCOMPATIBLE_PROVIDER_MERGE_MESSAGE =
'AniList-linked and TMDB-linked library entries cannot be merged together';
/** Thrown when a merge or move would mix a YouTube channel with an anime or live-action entry. */
export const MEDIA_KIND_MISMATCH_MESSAGE =
'YouTube channels cannot be combined with anime or live-action entries';
export interface AnimeMergeSummary {
/** Library entry that owns every moved episode once the merge finishes. */
@@ -33,6 +37,9 @@ interface AnimeMetadataRow {
title_native: string | null;
episodes_total: number | null;
description: string | null;
media_kind: string;
tmdb_id: number | null;
tmdb_type: string | null;
}
function emptyMergeSummary(survivingAnimeId: number): AnimeMergeSummary {
@@ -55,7 +62,8 @@ function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow
return (db
.prepare(
`
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description,
media_kind, tmdb_id, tmdb_type
FROM imm_anime
WHERE anime_id = ?
`,
@@ -137,6 +145,12 @@ function absorbAnimeMetadata(
title_native = COALESCE(title_native, ?),
episodes_total = COALESCE(episodes_total, ?),
description = COALESCE(description, ?),
tmdb_id = COALESCE(tmdb_id, ?),
tmdb_type = CASE WHEN tmdb_id IS NULL THEN ? ELSE tmdb_type END,
media_kind = CASE
WHEN anilist_id IS NULL AND tmdb_id IS NULL AND ? IS NOT NULL THEN ?
ELSE media_kind
END,
LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
@@ -147,6 +161,10 @@ function absorbAnimeMetadata(
source.title_native,
source.episodes_total,
source.description,
source.tmdb_id,
source.tmdb_type,
source.tmdb_id,
source.media_kind,
updatedAt,
targetAnimeId,
);
@@ -172,6 +190,18 @@ export function mergeAnimeRecordsInTransaction(
return summary;
}
// Validate the whole group before moving anything, including when the
// unlinked target would inherit conflicting providers from two sources.
const metadata = [targetAnimeId, ...new Set(sourceAnimeIds)].map((id) =>
readAnimeMetadata(db, id),
);
if (
metadata.some((row) => row?.anilist_id != null) &&
metadata.some((row) => row?.tmdb_id != null)
) {
throw new Error(INCOMPATIBLE_PROVIDER_MERGE_MESSAGE);
}
const updatedAt = toDbTimestamp(nowMs());
const sourceVideosStmt = db.prepare(
'SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?',
@@ -206,8 +236,8 @@ export function mergeAnimeRecordsInTransaction(
const sourceKind = readMediaKind(db, sourceAnimeId);
if (sourceKind === null) continue;
// A channel folded into an anime would only be recreated on the next
// watch, because title lookups never cross kinds; refuse instead.
if (sourceKind !== targetKind) {
// watch, because title lookups never cross namespaces; refuse instead.
if (!shareTitleNamespace(sourceKind, targetKind)) {
throw new Error(MEDIA_KIND_MISMATCH_MESSAGE);
}
@@ -275,7 +305,8 @@ export function moveVideoToAnime(
}
const previousAnimeId = videoRow.animeId;
if (previousAnimeId !== null && readMediaKind(db, previousAnimeId) !== targetKind) {
const previousKind = previousAnimeId === null ? null : readMediaKind(db, previousAnimeId);
if (previousKind !== null && !shareTitleNamespace(previousKind, targetKind)) {
throw new Error(MEDIA_KIND_MISMATCH_MESSAGE);
}
if (previousAnimeId === targetAnimeId) {
@@ -134,7 +134,7 @@ function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null {
episodes_total,
description
FROM imm_anime
WHERE anime_id = ? AND media_kind = 'anime'
WHERE anime_id = ? AND media_kind != 'youtube'
`,
)
.get(animeId) as AnimeRow | null;
@@ -372,6 +372,18 @@ export function resolveAnimeAnilistConflict(
targetAnimeId: number,
anilistId: number,
options: AnimeAnilistConflictOptions = {},
): AnimeSeasonRepairSummary {
return runInTransaction(db, () =>
resolveAnimeAnilistConflictInTransaction(db, targetAnimeId, anilistId, options),
);
}
/** Caller owns the write transaction. */
export function resolveAnimeAnilistConflictInTransaction(
db: DatabaseSync,
targetAnimeId: number,
anilistId: number,
options: AnimeAnilistConflictOptions = {},
): AnimeSeasonRepairSummary {
if (!getAnimeRow(db, targetAnimeId)) {
const summary = emptySummary();
@@ -392,90 +404,88 @@ export function resolveAnimeAnilistConflict(
if (!conflict) {
return emptySummary();
}
if (!getAnimeRow(db, conflict.animeId)) {
const summary = emptySummary();
summary.anilistAssignmentBlocked = true;
return summary;
}
return runInTransaction(db, () => {
const targetRow = getAnimeRow(db, targetAnimeId);
if (
options.survivor !== 'target' &&
targetRow?.anilist_id != null &&
targetRow.anilist_id !== anilistId
) {
// An automatic lookup disagreeing with an existing explicit link is a
// mis-resolution, not evidence that either row should move or merge. The
// colliding id must not be assigned either: another row owns it and
// imm_anime.anilist_id is UNIQUE.
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
const isManual = options.survivor === 'target' || options.matchConfidence === 'manual';
if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) {
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId);
const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId);
if (
!isManual &&
targetSeasons.size === 1 &&
conflictSeasons.size === 1 &&
[...targetSeasons][0] !== [...conflictSeasons][0]
) {
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
if (canMergeAnilistConflict(db, targetAnimeId, conflict.animeId, anilistId, options)) {
const survivingAnimeId = options.survivor === 'target' ? targetAnimeId : conflict.animeId;
const absorbedAnimeId = survivingAnimeId === targetAnimeId ? conflict.animeId : targetAnimeId;
const merge = mergeAnimeRecordsInTransaction(db, survivingAnimeId, [absorbedAnimeId]);
const summary = emptySummary(1);
summary.movedVideos = merge.movedVideos;
summary.deletedAnimeRows = merge.mergedAnimeIds.length;
if (merge.mergedAnimeIds.length > 0) {
summary.repaired = 1;
// Only reported once a row really absorbed the other, so callers never
// follow this to an anime id that was never written.
summary.survivingAnimeId = survivingAnimeId;
summary.affectedAnimeIds.push(survivingAnimeId, absorbedAnimeId);
}
// Lifetime summaries are rebuilt by the caller off this summary, the same
// as the redistribution path below.
return summary;
}
if (shouldRecommendAnilistConflict(db, targetAnimeId, conflict.animeId, options)) {
recordAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId, anilistId);
const summary = emptySummary(1);
summary.mergeRecommended = true;
return summary;
const targetRow = getAnimeRow(db, targetAnimeId);
if (
options.survivor !== 'target' &&
targetRow?.anilist_id != null &&
targetRow.anilist_id !== anilistId
) {
// An automatic lookup disagreeing with an existing explicit link is a
// mis-resolution, not evidence that either row should move or merge. The
// colliding id must not be assigned either: another row owns it and
// imm_anime.anilist_id is UNIQUE.
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
const isManual = options.survivor === 'target' || options.matchConfidence === 'manual';
if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) {
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId);
const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId);
if (
!isManual &&
targetSeasons.size === 1 &&
conflictSeasons.size === 1 &&
[...targetSeasons][0] !== [...conflictSeasons][0]
) {
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
if (canMergeAnilistConflict(db, targetAnimeId, conflict.animeId, anilistId, options)) {
const survivingAnimeId = options.survivor === 'target' ? targetAnimeId : conflict.animeId;
const absorbedAnimeId = survivingAnimeId === targetAnimeId ? conflict.animeId : targetAnimeId;
const merge = mergeAnimeRecordsInTransaction(db, survivingAnimeId, [absorbedAnimeId]);
const summary = emptySummary(1);
summary.movedVideos = merge.movedVideos;
summary.deletedAnimeRows = merge.mergedAnimeIds.length;
if (merge.mergedAnimeIds.length > 0) {
summary.repaired = 1;
// Only reported once a row really absorbed the other, so callers never
// follow this to an anime id that was never written.
summary.survivingAnimeId = survivingAnimeId;
summary.affectedAnimeIds.push(survivingAnimeId, absorbedAnimeId);
}
// Lifetime summaries are rebuilt by the caller off this summary, the same
// as the redistribution path below.
return summary;
}
const isExactAutomaticMatch =
options.matchConfidence === 'exact' ||
(options.matchConfidence === undefined &&
hasExactStoredTitleMatch(db, targetAnimeId, conflict.animeId));
if (!isManual && !isExactAutomaticMatch) {
// Redistribution dismantles the id's current owner and hands the id to
// the target. On a weak automatic match that owner is usually the
// correctly linked card (e.g. a legitimate multi-season entry), so
// splitting it here is exactly the fuzzy false merge this gate exists to
// stop. Only exact or manual evidence may fall through.
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
if (shouldRecommendAnilistConflict(db, targetAnimeId, conflict.animeId, options)) {
recordAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId, anilistId);
const summary = emptySummary(1);
summary.mergeRecommended = true;
return summary;
}
return redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
transferAnilistToAnimeId: targetAnimeId,
overwriteTargetAnilist: true,
});
const isExactAutomaticMatch =
options.matchConfidence === 'exact' ||
(options.matchConfidence === undefined &&
hasExactStoredTitleMatch(db, targetAnimeId, conflict.animeId));
if (!isManual && !isExactAutomaticMatch) {
// Redistribution dismantles the id's current owner and hands the id to
// the target. On a weak automatic match that owner is usually the
// correctly linked card (e.g. a legitimate multi-season entry), so
// splitting it here is exactly the fuzzy false merge this gate exists to
// stop. Only exact or manual evidence may fall through.
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
return redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
transferAnilistToAnimeId: targetAnimeId,
overwriteTargetAnilist: true,
});
}
@@ -0,0 +1,179 @@
import type { DatabaseSync } from './sqlite';
import type { TmdbMediaType } from '../../../shared/media-kind';
import { mergeAnimeRecordsInTransaction } from './anime-merge';
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
import { toDbTimestamp } from './query-shared';
import { nowMs } from './time';
export interface LiveActionTitleInput {
tmdbId: number;
tmdbType: TmdbMediaType;
titleEnglish: string | null;
titleNative: string | null;
description: string | null;
episodesTotal: number | null;
}
export interface LiveActionLinkResult {
/** Library entry that carries the TMDB link once the call finishes. */
animeId: number;
/** Entries folded into `animeId` because they pointed at the same TMDB title. */
mergedAnimeIds: number[];
}
export interface LiveActionLinkOptions {
/**
* `manual`: the user picked this title, so stored titles are overwritten and
* every other holder of the TMDB id is folded into this entry.
* `auto`: an exact filename match, so gaps are filled and the entry joins an
* existing holder rather than displacing it.
*/
mode: 'manual' | 'auto';
}
export interface VideoTmdbLink {
animeId: number;
tmdbId: number;
tmdbType: TmdbMediaType;
}
function findOtherTmdbHolders(
db: DatabaseSync,
animeId: number,
input: Pick<LiveActionTitleInput, 'tmdbId' | 'tmdbType'>,
): number[] {
return (
db
.prepare(
`SELECT anime_id AS animeId
FROM imm_anime
WHERE tmdb_id = ? AND tmdb_type = ? AND anime_id != ?
ORDER BY anime_id ASC`,
)
.all(input.tmdbId, input.tmdbType, animeId) as Array<{ animeId: number }>
).map((row) => row.animeId);
}
/**
* Link a library entry to a TMDB title. Unlike AniList, a TMDB show spans all
* of its seasons, so entries that resolve to the same title are one show and
* are merged regardless of the season each was parsed with.
*/
export function linkAnimeToTmdbTitle(
db: DatabaseSync,
animeId: number,
input: LiveActionTitleInput,
options: LiveActionLinkOptions,
): LiveActionLinkResult {
db.exec('BEGIN IMMEDIATE');
try {
const result = linkAnimeToTmdbTitleInTransaction(db, animeId, input, options);
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
/** Caller owns the write transaction, including any artwork replacement. */
export function linkAnimeToTmdbTitleInTransaction(
db: DatabaseSync,
animeId: number,
input: LiveActionTitleInput,
options: LiveActionLinkOptions,
): LiveActionLinkResult {
const target = db.prepare('SELECT anilist_id FROM imm_anime WHERE anime_id = ?').get(animeId) as
| { anilist_id: number | null }
| undefined;
if (!target) throw new Error('Unknown library entry');
if (target.anilist_id !== null) {
if (options.mode === 'auto')
throw new Error('Cannot automatically replace an AniList identity');
// An explicit reassignment changes providers before compatible rows merge.
db.prepare('UPDATE imm_anime SET anilist_id = NULL WHERE anime_id = ?').run(animeId);
}
const others = findOtherTmdbHolders(db, animeId, input);
let survivor = animeId;
let mergedAnimeIds: number[] = [];
if (others.length > 0) {
if (options.mode === 'manual') {
mergedAnimeIds = mergeAnimeRecordsInTransaction(db, animeId, others).mergedAnimeIds;
} else {
// Keep the entry the user already sees; the newcomer is the transient
// "Show Season 3" row that a fresh season folder just created.
survivor = others[0]!;
mergedAnimeIds = mergeAnimeRecordsInTransaction(db, survivor, [
animeId,
...others.slice(1),
]).mergedAnimeIds;
}
}
const updatedAt = toDbTimestamp(nowMs());
if (options.mode === 'manual') {
db.prepare(
`UPDATE imm_anime
SET media_kind = 'live_action',
tmdb_id = ?,
tmdb_type = ?,
anilist_id = NULL,
title_romaji = NULL,
title_english = ?,
title_native = ?,
episodes_total = ?,
description = ?,
LAST_UPDATE_DATE = ?
WHERE anime_id = ?`,
).run(
input.tmdbId,
input.tmdbType,
input.titleEnglish,
input.titleNative,
input.episodesTotal,
input.description,
updatedAt,
survivor,
);
} else {
db.prepare(
`UPDATE imm_anime
SET media_kind = 'live_action',
tmdb_id = ?,
tmdb_type = ?,
title_english = COALESCE(title_english, ?),
title_native = COALESCE(title_native, ?),
episodes_total = COALESCE(episodes_total, ?),
description = COALESCE(description, ?),
LAST_UPDATE_DATE = ?
WHERE anime_id = ?`,
).run(
input.tmdbId,
input.tmdbType,
input.titleEnglish,
input.titleNative,
input.episodesTotal,
input.description,
updatedAt,
survivor,
);
}
recomputeLifetimeAnimeAggregatesInTransaction(db);
return { animeId: survivor, mergedAnimeIds };
}
/** The TMDB link of the live-action entry a video belongs to, if any. */
export function getVideoTmdbLink(db: DatabaseSync, videoId: number): VideoTmdbLink | null {
const row = db
.prepare(
`SELECT a.anime_id AS animeId, a.tmdb_id AS tmdbId, a.tmdb_type AS tmdbType
FROM imm_videos v
JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.video_id = ?
AND a.media_kind = 'live_action'
AND a.tmdb_id IS NOT NULL
AND a.tmdb_type IN ('tv', 'movie')`,
)
.get(videoId) as VideoTmdbLink | undefined;
return row ? { animeId: row.animeId, tmdbId: row.tmdbId, tmdbType: row.tmdbType } : null;
}
@@ -35,6 +35,9 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
a.canonical_title AS canonicalTitle,
a.media_kind AS mediaKind,
a.anilist_id AS anilistId,
a.media_kind AS mediaKind,
a.tmdb_id AS tmdbId,
a.tmdb_type AS tmdbType,
COALESCE(lm.total_sessions, 0) AS totalSessions,
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
COALESCE(lm.total_cards, 0) AS totalCards,
@@ -66,6 +69,9 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
a.canonical_title AS canonicalTitle,
a.media_kind AS mediaKind,
a.anilist_id AS anilistId,
a.media_kind AS mediaKind,
a.tmdb_id AS tmdbId,
a.tmdb_type AS tmdbType,
a.title_romaji AS titleRomaji,
a.title_english AS titleEnglish,
a.title_native AS titleNative,
@@ -331,6 +331,29 @@ export async function cleanupVocabularyStats(
};
}
/**
* Drop the cached art of every episode in a library entry. Used when a manual
* relink points at a title with no artwork, so the previous link's cover does
* not keep standing in for it.
*/
export function clearAnimeCoverArt(db: DatabaseSync, animeId: number): void {
const rows = db
.prepare(
`SELECT m.cover_blob_hash AS coverBlobHash
FROM imm_media_art m
JOIN imm_videos v ON v.video_id = m.video_id
WHERE v.anime_id = ?`,
)
.all(animeId) as Array<{ coverBlobHash: string | null }>;
if (rows.length === 0) return;
db.prepare(
'DELETE FROM imm_media_art WHERE video_id IN (SELECT video_id FROM imm_videos WHERE anime_id = ?)',
).run(animeId);
for (const hash of new Set(rows.map((row) => row.coverBlobHash))) {
cleanupUnusedCoverArtBlobHash(db, hash);
}
}
export function upsertCoverArt(
db: DatabaseSync,
videoId: number,
+44 -21
View File
@@ -1,4 +1,4 @@
import type { MediaKind } from '../../../shared/media-kind';
import { sameTitleNamespaceSql, type MediaKind } from '../../../shared/media-kind';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { parseMediaInfo } from '../../../jimaku/utils';
@@ -591,14 +591,19 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
anime_id: number;
} | null)
: null;
// Title lookups stay inside the kind's namespace: a parsed filename may land
// on a TMDB-linked live-action row, but never on a YouTube channel.
const byNormalizedTitle = db
.prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?')
.prepare(
`SELECT anime_id FROM imm_anime
WHERE normalized_title_key = ? AND ${sameTitleNamespaceSql()}`,
)
.get(normalizedTitleKey, mediaKind) as { anime_id: number } | null;
const byTitleAlias = db
.prepare(
`SELECT a.anime_id FROM imm_anime_title_aliases AS alias
JOIN imm_anime AS a ON a.anime_id = alias.anime_id
WHERE alias.normalized_title_key = ? AND a.media_kind = ?`,
WHERE alias.normalized_title_key = ? AND ${sameTitleNamespaceSql('a.media_kind')}`,
)
.get(normalizedTitleKey, mediaKind) as { anime_id: number } | null;
const existing = byAnilistId ?? byNormalizedTitle ?? byTitleAlias;
@@ -611,7 +616,11 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
UPDATE imm_anime
SET
canonical_title = COALESCE(NULLIF(?, ''), canonical_title),
anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE COALESCE(?, anilist_id) END,
anilist_id = CASE
WHEN ? = 'youtube' THEN NULL
WHEN tmdb_id IS NOT NULL THEN anilist_id
ELSE COALESCE(?, anilist_id)
END,
title_romaji = COALESCE(?, title_romaji),
title_english = COALESCE(?, title_english),
title_native = COALESCE(?, title_native),
@@ -889,13 +898,19 @@ function migrateLegacyAnimeMetadata(db: DatabaseSync): void {
}
}
// SQLite cannot drop a table-level UNIQUE constraint. Rebuild with IDs intact
// and foreign keys disabled so dependent history and manual assignments survive.
function migrateAnimeTitleUniqueness(db: DatabaseSync): void {
// SQLite cannot drop a table-level UNIQUE constraint or a column CHECK.
// Rebuild with IDs intact and foreign keys disabled so dependent history and
// manual assignments survive. Two shapes need it: the original
// `normalized_title_key UNIQUE`, and the v0.19.6 `media_kind` column whose
// CHECK only allowed 'anime' and 'youtube'.
const LEGACY_TITLE_UNIQUE_RE = /normalized_title_key TEXT NOT NULL UNIQUE/i;
const LEGACY_MEDIA_KIND_CHECK_RE = /\s*CHECK\s*\(\s*media_kind IN \('anime',\s*'youtube'\)\s*\)/i;
function migrateAnimeTableConstraints(db: DatabaseSync): void {
const schema = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'imm_anime'").get() as {
sql: string;
};
if (/normalized_title_key TEXT NOT NULL UNIQUE/i.test(schema.sql)) {
if (LEGACY_TITLE_UNIQUE_RE.test(schema.sql) || LEGACY_MEDIA_KIND_CHECK_RE.test(schema.sql)) {
const foreignKeys = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number };
const sequence = db
.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'imm_anime'")
@@ -909,10 +924,8 @@ function migrateAnimeTitleUniqueness(db: DatabaseSync): void {
/CREATE TABLE (?:IF NOT EXISTS )?["`]?imm_anime["`]?/i,
'CREATE TABLE imm_anime_new',
)
.replace(
/normalized_title_key TEXT NOT NULL UNIQUE/i,
'normalized_title_key TEXT NOT NULL',
),
.replace(LEGACY_TITLE_UNIQUE_RE, 'normalized_title_key TEXT NOT NULL')
.replace(LEGACY_MEDIA_KIND_CHECK_RE, ''),
);
db.exec(`INSERT INTO imm_anime_new SELECT * FROM imm_anime;
DROP TABLE imm_anime;
@@ -930,8 +943,11 @@ function migrateAnimeTitleUniqueness(db: DatabaseSync): void {
db.exec(`PRAGMA foreign_keys = ${foreignKeys.foreign_keys}`);
}
}
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_anime_kind_title
ON imm_anime(media_kind, normalized_title_key)`);
// v0.19.6 scoped titles per kind; anime and live-action now share one
// namespace (an entry moves between them when relinked), YouTube is separate.
db.exec(`DROP INDEX IF EXISTS idx_anime_kind_title;
CREATE UNIQUE INDEX IF NOT EXISTS idx_anime_namespace_title
ON imm_anime((media_kind = 'youtube'), normalized_title_key)`);
}
// Older builds can create channel rows with the default anime kind even after
@@ -995,17 +1011,20 @@ export function ensureSchema(db: DatabaseSync): void {
title_native TEXT,
episodes_total INTEGER,
description TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime',
tmdb_id INTEGER,
tmdb_type TEXT,
metadata_json TEXT,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT
);
`);
addColumnIfMissing(
db,
'imm_anime',
'media_kind',
"TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))",
);
// Schema 26: media_kind separates anime, live-action (TMDB link) and YouTube
// channel entries. Kinds are validated in code, not by a CHECK constraint,
// so adding one later does not need a table rebuild.
addColumnIfMissing(db, 'imm_anime', 'media_kind', "TEXT NOT NULL DEFAULT 'anime'");
addColumnIfMissing(db, 'imm_anime', 'tmdb_id', 'INTEGER');
addColumnIfMissing(db, 'imm_anime', 'tmdb_type', 'TEXT');
db.exec(`
CREATE TABLE IF NOT EXISTS imm_videos(
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1549,7 +1568,7 @@ export function ensureSchema(db: DatabaseSync): void {
);
}
migrateAnimeTitleUniqueness(db);
migrateAnimeTableConstraints(db);
classifyYoutubeChannels(db);
migrateSessionEventTimestampsToText(db);
@@ -1565,6 +1584,10 @@ export function ensureSchema(db: DatabaseSync): void {
CREATE INDEX IF NOT EXISTS idx_anime_anilist_id
ON imm_anime(anilist_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_anime_tmdb_id
ON imm_anime(tmdb_id, tmdb_type)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_videos_anime_id
ON imm_videos(anime_id)
+7 -2
View File
@@ -1,6 +1,7 @@
import type { MediaKind } from '../../../shared/media-kind';
import type { MediaKind, TmdbMediaType } from '../../../shared/media-kind';
export const SCHEMA_VERSION = 25;
// 26: live-action entries (TMDB link) and YouTube channels share the media_kind column.
export const SCHEMA_VERSION = 26;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
@@ -524,6 +525,8 @@ export interface AnimeLibraryRow {
animeId: number;
canonicalTitle: string;
anilistId: number | null;
tmdbId: number | null;
tmdbType: TmdbMediaType | null;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
@@ -538,6 +541,8 @@ export interface AnimeDetailRow {
animeId: number;
canonicalTitle: string;
anilistId: number | null;
tmdbId: number | null;
tmdbType: TmdbMediaType | null;
titleRomaji: string | null;
titleEnglish: string | null;
titleNative: string | null;
@@ -81,7 +81,7 @@ test('schema 23 channel migration preserves history and manual assignments and i
const history = getAnimeLibrary(db);
// Reproduce the previous schema, including its lack of a media kind column.
db.exec(
'DROP INDEX idx_anime_kind_title; ALTER TABLE imm_anime DROP COLUMN media_kind; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (23, 0)',
'DROP INDEX idx_anime_namespace_title; ALTER TABLE imm_anime DROP COLUMN media_kind; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (23, 0)',
);
ensureSchema(db);
ensureSchema(db);
@@ -240,6 +240,52 @@ test('title identity and aliases never cross media kinds', () => {
}
});
test('anime title lookups land on a same-named live-action entry but never on a channel', () => {
const db = new Database(':memory:');
try {
ensureSchema(db);
const dramaId = createAnime(db, 'Hanzawa Naoki');
db.prepare(
"UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 61222, tmdb_type = 'tv' WHERE anime_id = ?",
).run(dramaId);
// A later season folder parses to the same title with the default anime
// kind and must join the TMDB-linked entry rather than duplicate it.
assert.equal(
getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
anilistId: 99,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
}),
dramaId,
);
const row = db
.prepare('SELECT media_kind, anilist_id, tmdb_id FROM imm_anime WHERE anime_id = ?')
.get(dramaId) as { media_kind: string; anilist_id: number | null; tmdb_id: number };
assert.equal(row.media_kind, 'live_action');
assert.equal(row.anilist_id, null);
assert.equal(row.tmdb_id, 61222);
assert.notEqual(
getOrCreateAnimeRecord(db, {
mediaKind: 'youtube',
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
}),
dramaId,
);
} finally {
db.close();
}
});
test('schema 24 title constraint migration preserves referenced data', () => {
const db = new Database(':memory:');
try {
@@ -273,7 +319,9 @@ test('schema 24 title constraint migration preserves referenced data', () => {
title_romaji TEXT, title_english TEXT, title_native TEXT, episodes_total INTEGER,
description TEXT, metadata_json TEXT, CREATED_DATE TEXT, LAST_UPDATE_DATE TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube')));
INSERT INTO imm_anime SELECT * FROM old_anime;
INSERT INTO imm_anime SELECT anime_id, normalized_title_key, canonical_title, anilist_id,
title_romaji, title_english, title_native, episodes_total, description, metadata_json,
CREATED_DATE, LAST_UPDATE_DATE, media_kind FROM old_anime;
DROP TABLE old_anime;
DELETE FROM imm_schema_version;
INSERT INTO imm_schema_version VALUES (24, 0);
@@ -281,6 +329,10 @@ test('schema 24 title constraint migration preserves referenced data', () => {
ensureSchema(db);
ensureSchema(db);
assert.deepEqual(db.prepare('PRAGMA foreign_key_check').all(), []);
// The v0.19.6 CHECK only allowed anime and youtube; live-action must fit now.
db.prepare(
"INSERT INTO imm_anime(normalized_title_key, canonical_title, media_kind, tmdb_id, tmdb_type) VALUES ('drama', 'Drama', 'live_action', 1, 'tv')",
).run();
assert.equal(
(db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }).foreign_keys,
1,
+10 -1
View File
@@ -3,9 +3,11 @@ import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import { Readable } from 'node:stream';
import type { AnkiConnectConfig } from '../../types.js';
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
import type { TmdbClient } from './tmdb/tmdb-client.js';
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
import type { RetimedSecondarySubtitleInput } from './secondary-subtitle-sidecar.js';
import type { StatsServerMediaGenerator } from './stats-server/mining-support.js';
import { enforceStatsRequestSafety } from './stats-server/request-safety.js';
import {
registerStatsAnalyticsRoutes,
registerStatsIntegrationRoutes,
@@ -37,7 +39,10 @@ function toFetchRequest(req: IncomingMessage): Request {
method,
headers: toFetchHeaders(req.headers),
};
if (method !== 'GET' && method !== 'HEAD') {
const hasBody =
req.headers['transfer-encoding'] !== undefined ||
Number(req.headers['content-length'] ?? 0) > 0;
if (method !== 'GET' && method !== 'HEAD' && hasBody) {
init.body = Readable.toWeb(req) as BodyInit;
init.duplex = 'half';
}
@@ -125,6 +130,7 @@ export interface StatsServerConfig {
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
anilistRateLimiter?: AnilistRateLimiter;
tmdbClient?: TmdbClient;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
@@ -147,6 +153,7 @@ export function createStatsApp(
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
anilistRateLimiter?: AnilistRateLimiter;
tmdbClient?: TmdbClient;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
@@ -156,6 +163,7 @@ export function createStatsApp(
},
) {
const app = new Hono();
app.use('*', enforceStatsRequestSafety);
registerStatsAnalyticsRoutes(app, tracker, options);
registerStatsLibraryRoutes(app, tracker, options);
registerStatsIntegrationRoutes(app, tracker, options);
@@ -181,6 +189,7 @@ export async function startStatsServerWithRuntime(
getStatsMiningAlassPath: config.getStatsMiningAlassPath,
resolveRetimedSecondarySubtitleText: config.resolveRetimedSecondarySubtitleText,
anilistRateLimiter: config.anilistRateLimiter,
tmdbClient: config.tmdbClient,
addYomitanNote: config.addYomitanNote,
resolveAnkiNoteId: config.resolveAnkiNoteId,
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
@@ -1,11 +1,13 @@
import type { Hono } from 'hono';
import type { Context, Hono } from 'hono';
import type { AnkiConnectConfig } from '../../../types.js';
import {
statsJson,
type StatsAnilistSearchResult,
type StatsAnkiBrowseResponse,
} from '../../../types/stats-http-contract.js';
import { isTmdbMediaType } from '../../../shared/media-kind.js';
import type { AnilistRateLimiter } from '../anilist/rate-limiter.js';
import { TmdbApiKeyMissingError, type TmdbClient } from '../tmdb/tmdb-client.js';
import { registerStatsCoverRoutes } from '../stats-cover-routes.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import {
@@ -29,6 +31,7 @@ export function registerStatsIntegrationRoutes(
ankiConnectConfig?: AnkiConnectConfig;
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
anilistRateLimiter?: AnilistRateLimiter;
tmdbClient?: TmdbClient;
resolveAnkiNoteId?: (noteId: number) => number;
},
): void {
@@ -73,6 +76,51 @@ export function registerStatsIntegrationRoutes(
}
});
const tmdbUnavailable = (c: Context, err: unknown) => {
if (err instanceof TmdbApiKeyMissingError) {
return c.json(statsJson('error', { error: err.message }), 503);
}
return c.json(statsJson('error', { error: 'TMDB request failed' }), 502);
};
app.get('/api/stats/tmdb/search', async (c) => {
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json(statsJson('tmdbSearch', []));
const tmdbClient = options?.tmdbClient;
if (!tmdbClient) return c.json(statsJson('tmdbSearch', []));
try {
return c.json(statsJson('tmdbSearch', await tmdbClient.search(query)));
} catch (err) {
return tmdbUnavailable(c, err);
}
});
app.patch('/api/stats/anime/:animeId/tmdb', async (c) => {
const animeId = parsePositiveId(c.req.param('animeId'));
if (animeId === null) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
const tmdbId = body?.tmdbId;
if (
typeof tmdbId !== 'number' ||
!Number.isInteger(tmdbId) ||
tmdbId <= 0 ||
!isTmdbMediaType(body?.tmdbType)
) {
return c.body(null, 400);
}
if (!(await tracker.hasAnime(animeId))) return c.body(null, 404);
const tmdbClient = options?.tmdbClient;
if (!tmdbClient) return c.json(statsJson('error', { error: 'TMDB is not available' }), 503);
try {
const details = await tmdbClient.getDetails(body.tmdbType, tmdbId);
if (!details) return c.body(null, 404);
await tracker.reassignAnimeTmdb(animeId, details);
return c.json(statsJson('reassignAnimeTmdb', { ok: true }));
} catch (err) {
return tmdbUnavailable(c, err);
}
});
app.get('/api/stats/known-words', (c) => {
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json(statsJson('knownWords', []));
@@ -1,6 +1,7 @@
import type { Hono } from 'hono';
import { statsJson } from '../../../types/stats-http-contract.js';
import {
INCOMPATIBLE_PROVIDER_MERGE_MESSAGE,
MEDIA_KIND_MISMATCH_MESSAGE,
UNKNOWN_MOVE_TARGET_MESSAGE,
} from '../immersion-tracker/anime-merge.js';
@@ -252,8 +253,14 @@ export function registerStatsLibraryRoutes(
try {
summary = await tracker.mergeAnime(animeId, sourceAnimeIds);
} catch (error) {
if (error instanceof Error && error.message === MEDIA_KIND_MISMATCH_MESSAGE) {
return c.text(MEDIA_KIND_MISMATCH_MESSAGE, 409);
// Mixing providers or kinds is a rejected request, not a server fault,
// so the dashboard can explain it instead of showing a bare 500.
if (
error instanceof Error &&
(error.message === INCOMPATIBLE_PROVIDER_MERGE_MESSAGE ||
error.message === MEDIA_KIND_MISMATCH_MESSAGE)
) {
return c.json(statsJson('error', { error: error.message }), 409);
}
throw error;
}
@@ -0,0 +1,42 @@
import type { MiddlewareHandler } from 'hono';
function isLoopbackUrl(url: URL): boolean {
return (
url.protocol === 'http:' &&
!url.username &&
!url.password &&
['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
);
}
/** Protect the local API even when a browser can reach the loopback listener. */
export const enforceStatsRequestSafety: MiddlewareHandler = async (c, next) => {
const url = new URL(c.req.url);
if (!isLoopbackUrl(url)) return c.body(null, 403);
const host = c.req.header('host');
if (host !== undefined) {
if (!/^(localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(host)) {
return c.body(null, 403);
}
// Node derives the request URL from Host; Bun provides them independently.
try {
if (new URL(`http://${host}`).origin !== url.origin) return c.body(null, 403);
} catch {
return c.body(null, 403);
}
}
// Compare the serialized origin exactly. Opaque origins and malformed values
// containing credentials, paths, or multiple origins must not gain trust.
const origin = c.req.header('origin');
if (origin !== undefined && origin !== url.origin) return c.body(null, 403);
const site = c.req.header('sec-fetch-site');
if (site === 'cross-site' || site === 'same-site') return c.body(null, 403);
if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method) && c.req.raw.body !== null) {
const contentType = c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
if (contentType !== 'application/json') return c.body(null, 415);
}
await next();
};
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Database, type DatabaseSync } from '../immersion-tracker/sqlite';
import { ensureSchema } from '../immersion-tracker/storage';
import { mergeAnime } from './merge-catalog';
import { createEmptyMergeSummary } from './shared';
const identities = {
unlinked: [null, null, null],
anilist: [42, null, null],
tmdb: [null, 12, 'tv'],
otherTmdb: [null, 13, 'tv'],
} as const;
for (const [localKind, remoteKind, sameEntry] of [
['anilist', 'tmdb', false],
['tmdb', 'anilist', false],
['tmdb', 'otherTmdb', false],
['unlinked', 'tmdb', true],
['unlinked', 'anilist', true],
['tmdb', 'unlinked', true],
['tmdb', 'tmdb', true],
['anilist', 'anilist', true],
] as const) {
test(`catalog title match: ${localKind} with ${remoteKind}`, () => {
const local = new Database(':memory:');
const remote = new Database(':memory:');
const adapt = (db: DatabaseSync) => ({
query: (sql: string) => db.prepare(sql),
exec: (sql: string) => {
db.exec(sql);
},
close: () => {
db.close();
},
});
try {
for (const [db, kind] of [
[local, localKind],
[remote, remoteKind],
] as const) {
ensureSchema(db);
db.prepare(
`INSERT INTO imm_anime(normalized_title_key, canonical_title, anilist_id, tmdb_id, tmdb_type, CREATED_DATE, LAST_UPDATE_DATE)
VALUES ('same title', 'Same title', ?, ?, ?, 1000, 1000)`,
).run(...identities[kind]);
}
const summary = createEmptyMergeSummary();
const map = mergeAnime(adapt(local), adapt(remote), summary);
assert.equal(map.get(1) === 1, sameEntry);
assert.equal(summary.animeAdded, sameEntry ? 0 : 1);
const again = createEmptyMergeSummary();
assert.equal(mergeAnime(adapt(local), adapt(remote), again).get(1), map.get(1));
assert.equal(again.animeAdded, 0);
} finally {
local.close();
remote.close();
}
});
}
+55 -10
View File
@@ -1,5 +1,6 @@
import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver';
import { insertRow, tableExists, type SyncMergeSummary } from './shared';
import { sameTitleNamespaceSql } from '../../../shared/media-kind';
const ANIME_COPY_COLUMNS = [
'media_kind',
@@ -11,6 +12,8 @@ const ANIME_COPY_COLUMNS = [
'title_native',
'episodes_total',
'description',
'tmdb_id',
'tmdb_type',
'metadata_json',
'CREATED_DATE',
'LAST_UPDATE_DATE',
@@ -102,8 +105,14 @@ export function mergeAnime(
const byAnilist = local.query(
"SELECT anime_id FROM imm_anime WHERE anilist_id = ? AND media_kind = 'anime'",
);
const byTmdb = local.query(
'SELECT anime_id FROM imm_anime WHERE tmdb_id = ? AND tmdb_type = ? ORDER BY anime_id LIMIT 1',
);
// Anime and live-action rows share a title namespace; YouTube channels are
// looked up on their own, so a same-named anime and channel stay separate.
const byTitleKey = local.query(
'SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?',
`SELECT anime_id, anilist_id, tmdb_id, tmdb_type FROM imm_anime
WHERE normalized_title_key = ? AND ${sameTitleNamespaceSql()}`,
);
// A pre-classification channel can be repaired, but a genuine anime sharing
// its title must remain a separate entry.
@@ -116,16 +125,25 @@ export function mergeAnime(
const releaseChannelAnilistId = local.query(
"UPDATE imm_anime SET anilist_id = NULL WHERE media_kind = 'youtube' AND anilist_id = ?",
);
// A TMDB link only fills in when the local row is unlinked: a row already
// pinned to AniList stays anime, and vice versa, so the two link kinds never
// coexist on one entry. A channel match always becomes a channel.
const fillMissing = local.query(
`UPDATE imm_anime
SET
media_kind = ?,
anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE anilist_id END,
title_romaji = COALESCE(title_romaji, ?),
title_english = COALESCE(title_english, ?),
title_native = COALESCE(title_native, ?),
episodes_total = COALESCE(episodes_total, ?),
description = COALESCE(description, ?)
description = COALESCE(description, ?),
tmdb_id = CASE WHEN anilist_id IS NULL THEN COALESCE(tmdb_id, ?) ELSE tmdb_id END,
tmdb_type = CASE WHEN anilist_id IS NULL AND tmdb_id IS NULL THEN ? ELSE tmdb_type END,
media_kind = CASE
WHEN ? = 'youtube' THEN 'youtube'
WHEN anilist_id IS NULL AND tmdb_id IS NULL THEN ?
ELSE media_kind
END
WHERE anime_id = ?`,
);
@@ -139,10 +157,25 @@ export function mergeAnime(
// incorrectly attached one to a channel.
releaseChannelAnilistId.run(row.anilist_id);
}
const titleMatch = byTitleKey.get(row.normalized_title_key, row.media_kind) as
| SqlRow
| undefined;
const compatibleTitleMatch =
titleMatch &&
((titleMatch.anilist_id === null && titleMatch.tmdb_id === null) ||
(row.anilist_id === null && row.tmdb_id === null) ||
(titleMatch.tmdb_id === null &&
row.tmdb_id === null &&
titleMatch.anilist_id === row.anilist_id) ||
(titleMatch.anilist_id === null &&
row.anilist_id === null &&
titleMatch.tmdb_id === row.tmdb_id &&
titleMatch.tmdb_type === row.tmdb_type));
const existing = ((row.media_kind === 'anime' && row.anilist_id !== null
? byAnilist.get(row.anilist_id)
: undefined) ??
byTitleKey.get(row.normalized_title_key, row.media_kind) ??
(row.tmdb_id !== null ? byTmdb.get(row.tmdb_id, row.tmdb_type) : undefined) ??
(compatibleTitleMatch ? titleMatch : undefined) ??
(row.media_kind === 'youtube' ? legacyChannel.get(row.normalized_title_key) : undefined)) as
| SqlRow
| undefined;
@@ -150,22 +183,34 @@ export function mergeAnime(
const localId = Number(existing.anime_id);
map.set(remoteId, localId);
fillMissing.run(
row.media_kind,
row.media_kind,
row.title_romaji,
row.title_english,
row.title_native,
row.episodes_total,
row.description,
row.tmdb_id,
row.tmdb_type,
row.media_kind,
row.media_kind,
localId,
);
continue;
}
// No local row matched by anilist_id (checked first in `existing` above)
// or title key, so the remote anilist_id — if any — is free to insert as-is.
const values = ANIME_COPY_COLUMNS.map((column) =>
column === 'anilist_id' && row.media_kind !== 'anime' ? null : row[column],
);
// Conflicting providers can share a title, but the stored title key is
// unique within its namespace.
let titleKey = row.normalized_title_key;
for (let suffix = 1; byTitleKey.get(titleKey, row.media_kind); suffix += 1) {
titleKey = `${row.normalized_title_key}:sync:${suffix}`;
}
const values = ANIME_COPY_COLUMNS.map((column) => {
if (column === 'normalized_title_key') return titleKey;
// No local row matched by anilist_id (checked first in `existing` above)
// or title key, so the remote anilist_id is free to insert as-is, except
// that channels never carry one.
if (column === 'anilist_id' && row.media_kind !== 'anime') return null;
return row[column];
});
map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values));
summary.animeAdded += 1;
}
+4 -9
View File
@@ -219,13 +219,8 @@ export function scheduleStatsWindowPostShowReconciles(
}
}
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
query: Record<string, string>;
} {
return {
query: {
overlay: '1',
...(apiBaseUrl ? { apiBase: apiBaseUrl } : {}),
},
};
export function buildStatsWindowUrl(apiBaseUrl: string): string {
const url = new URL('/', apiBaseUrl);
url.searchParams.set('overlay', '1');
return url.toString();
}
+5 -14
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildStatsWindowLoadFileOptions,
buildStatsWindowUrl,
buildStatsWindowOptions,
buildStatsNativeConfirmDialogOptions,
demoteVisibleStatsWindowBelowDialogs,
@@ -168,21 +168,12 @@ test('shouldHideStatsWindowForInput matches Escape and configured bare toggle ke
);
});
test('buildStatsWindowLoadFileOptions enables overlay rendering mode', () => {
assert.deepEqual(buildStatsWindowLoadFileOptions(), {
query: {
overlay: '1',
},
});
test('buildStatsWindowUrl enables overlay rendering on the local HTTP origin', () => {
assert.equal(buildStatsWindowUrl('http://127.0.0.1:6969'), 'http://127.0.0.1:6969/?overlay=1');
});
test('buildStatsWindowLoadFileOptions includes provided stats API base URL', () => {
assert.deepEqual(buildStatsWindowLoadFileOptions('http://127.0.0.1:6123'), {
query: {
overlay: '1',
apiBase: 'http://127.0.0.1:6123',
},
});
test('buildStatsWindowUrl uses the active server port as the document origin', () => {
assert.equal(buildStatsWindowUrl('http://127.0.0.1:6123'), 'http://127.0.0.1:6123/?overlay=1');
});
test('resolveStatsWindowOuterBoundsForContent compensates for Wayland content insets', () => {
+4 -8
View File
@@ -1,10 +1,9 @@
import { BrowserWindow, dialog, ipcMain } from 'electron';
import * as path from 'path';
import { createLogger } from '../../logger.js';
import type { WindowGeometry } from '../../types.js';
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
import {
buildStatsWindowLoadFileOptions,
buildStatsWindowUrl,
buildStatsWindowOptions,
demoteVisibleStatsWindowBelowDialogs,
presentStatsWindow,
@@ -34,12 +33,10 @@ const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState();
const logger = createLogger('main:stats-window');
export interface StatsWindowOptions {
/** Absolute path to stats/dist/ directory */
staticDir: string;
/** Absolute path to the compiled preload-stats.js */
preloadPath: string;
/** Resolve the active stats API base URL */
getApiBaseUrl?: () => Promise<string> | string;
getApiBaseUrl: () => Promise<string> | string;
/** Report server startup failure through the configured notification surface. */
onStartupError?: (error: unknown) => void;
/** Resolve the active stats toggle key from config */
@@ -188,7 +185,7 @@ export async function toggleStatsOverlay(options: StatsWindowOptions): Promise<v
if (!statsWindow) {
const generation = statsWindowGeneration;
const apiBaseUrl = await Promise.resolve()
.then(() => options.getApiBaseUrl?.())
.then(() => options.getApiBaseUrl())
.catch((error: unknown) => {
options.onStartupError?.(error);
throw error;
@@ -207,8 +204,7 @@ export async function toggleStatsOverlay(options: StatsWindowOptions): Promise<v
statsWindow?.setTitle(STATS_WINDOW_TITLE);
});
const indexPath = path.join(options.staticDir, 'index.html');
statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(apiBaseUrl));
statsWindow.loadURL(buildStatsWindowUrl(apiBaseUrl));
statsWindow.on('closed', () => {
options.onVisibilityChanged?.(false);
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { BUNDLED_INTEGRATION_KEYS_FILENAME, readBundledTmdbApiKey } from './bundled-api-key.js';
test('readBundledTmdbApiKey reads the staged key and tolerates a missing or malformed file', () => {
const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-bundled-key-'));
const filePath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
try {
assert.equal(readBundledTmdbApiKey(distDir), null);
fs.writeFileSync(filePath, '{"tmdbApiKey":" abc "}');
assert.equal(readBundledTmdbApiKey(distDir), 'abc');
fs.writeFileSync(filePath, '{"tmdbApiKey":""}');
assert.equal(readBundledTmdbApiKey(distDir), null);
fs.writeFileSync(filePath, 'not json');
assert.equal(readBundledTmdbApiKey(distDir), null);
} finally {
fs.rmSync(distDir, { recursive: true, force: true });
}
});
+20
View File
@@ -0,0 +1,20 @@
import fs from 'node:fs';
import path from 'node:path';
/**
* Release builds stage a project-owned TMDB key into dist/ (see
* scripts/bundled-integration-keys.mjs). Source checkouts and CI builds have no
* such file, and TMDB lookups then depend on the user's own `tmdb.apiKey`.
*/
export const BUNDLED_INTEGRATION_KEYS_FILENAME = 'bundled-integration-keys.json';
export function readBundledTmdbApiKey(distDir: string): string | null {
try {
const raw = fs.readFileSync(path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME), 'utf8');
const parsed = JSON.parse(raw) as { tmdbApiKey?: unknown };
const key = typeof parsed.tmdbApiKey === 'string' ? parsed.tmdbApiKey.trim() : '';
return key.length > 0 ? key : null;
} catch {
return null;
}
}
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createLiveActionMetadataResolver, titlesMatch } from './live-action-resolver.js';
import {
TmdbApiKeyMissingError,
type TmdbClient,
type TmdbSearchResult,
type TmdbTitleDetails,
} from './tmdb-client.js';
const silentLogger = { info: () => {}, warn: () => {} };
function searchResult(over: Partial<TmdbSearchResult> & { tmdbId: number }): TmdbSearchResult {
return {
tmdbType: 'tv',
title: 'Title',
originalTitle: 'Title',
originalLanguage: 'ja',
overview: null,
posterUrl: null,
year: null,
isAnimation: false,
...over,
};
}
function details(over: Partial<TmdbTitleDetails> & { tmdbId: number }): TmdbTitleDetails {
return {
tmdbType: 'tv',
titleEnglish: null,
titleNative: null,
description: null,
posterUrl: null,
episodesTotal: null,
year: null,
originalLanguage: 'ja',
isAnimation: false,
allTitles: [],
...over,
};
}
test('titlesMatch ignores case, width, and punctuation but not extra words', () => {
assert.equal(titlesMatch('Hanzawa Naoki', ['HANZAWA NAOKI!']), true);
assert.equal(titlesMatch('半沢直樹', ['半沢直樹']), true);
assert.equal(titlesMatch('Hanzawa Naoki', ['Hanzawa Naoki Season 2']), false);
assert.equal(titlesMatch('', ['']), false);
});
test('resolveByTitle only accepts a Japanese non-animated result whose known titles match exactly', async () => {
const detailCalls: number[] = [];
const client: TmdbClient = {
async search() {
return [
searchResult({ tmdbId: 1, title: 'Hanzawa Naoki', originalLanguage: 'ko' }),
searchResult({ tmdbId: 2, title: 'Hanzawa Naoki', isAnimation: true }),
searchResult({ tmdbId: 3, title: 'Hanzawa Naoki: The Movie' }),
searchResult({ tmdbId: 4, title: 'Hanzawa Naoki' }),
];
},
async getDetails(_type, tmdbId) {
detailCalls.push(tmdbId);
if (tmdbId === 3) return details({ tmdbId: 3, allTitles: ['Hanzawa Naoki: The Movie'] });
if (tmdbId === 4) return details({ tmdbId: 4, allTitles: ['Hanzawa Naoki', '半沢直樹'] });
return null;
},
};
const resolver = createLiveActionMetadataResolver(client, silentLogger);
const resolved = await resolver.resolveByTitle('hanzawa naoki');
assert.equal(resolved?.tmdbId, 4);
assert.deepEqual(detailCalls, [3, 4]);
});
test('resolveByTitle returns null when nothing matches or the key is missing', async () => {
const noMatch: TmdbClient = {
async search() {
return [searchResult({ tmdbId: 1, title: 'Something Else' })];
},
async getDetails() {
return details({ tmdbId: 1, allTitles: ['Something Else'] });
},
};
assert.equal(
await createLiveActionMetadataResolver(noMatch, silentLogger).resolveByTitle('Hanzawa Naoki'),
null,
);
let infoCount = 0;
const noKey: TmdbClient = {
async search() {
throw new TmdbApiKeyMissingError();
},
async getDetails() {
throw new TmdbApiKeyMissingError();
},
};
const resolver = createLiveActionMetadataResolver(noKey, {
info: () => {
infoCount += 1;
},
warn: () => {},
});
assert.equal(await resolver.resolveByTitle('Hanzawa Naoki'), null);
assert.equal(await resolver.resolveById('tv', 1), null);
assert.equal(infoCount, 1);
});
@@ -0,0 +1,75 @@
import { normalizeTitleIdentity } from '../../utils/title-normalization';
import type { TmdbMediaType } from '../../../shared/media-kind';
import { TmdbApiKeyMissingError, type TmdbClient, type TmdbTitleDetails } from './tmdb-client';
const MAX_DETAIL_LOOKUPS = 3;
/**
* Resolves live-action titles for the automatic cover-art path. Anime is
* AniList's job, so only non-animated Japanese-language results qualify, and
* a candidate must match the parsed title exactly under one of the names TMDB
* knows for it. Fuzzy search hits are never trusted on their own: a stray
* filename would otherwise pin the wrong show to a library entry.
*/
export interface LiveActionMetadataResolver {
resolveByTitle(title: string): Promise<TmdbTitleDetails | null>;
resolveById(tmdbType: TmdbMediaType, tmdbId: number): Promise<TmdbTitleDetails | null>;
}
interface Logger {
info(msg: string, ...args: unknown[]): void;
warn(msg: string, ...args: unknown[]): void;
}
export function titlesMatch(candidate: string, knownTitles: Iterable<string>): boolean {
const key = normalizeTitleIdentity(candidate);
if (!key) return false;
for (const known of knownTitles) {
if (normalizeTitleIdentity(known) === key) return true;
}
return false;
}
export function createLiveActionMetadataResolver(
client: TmdbClient,
logger: Logger,
): LiveActionMetadataResolver {
let warnedMissingKey = false;
const guard = async <T>(work: () => Promise<T>): Promise<T | null> => {
try {
return await work();
} catch (err) {
if (err instanceof TmdbApiKeyMissingError) {
if (!warnedMissingKey) {
warnedMissingKey = true;
logger.info('tmdb: no API key configured, skipping live-action metadata lookups');
}
return null;
}
logger.warn('tmdb: lookup failed: %s', err instanceof Error ? err.message : String(err));
return null;
}
};
return {
resolveByTitle(title) {
return guard(async () => {
const results = await client.search(title);
const candidates = results
.filter((result) => result.originalLanguage === 'ja' && !result.isAnimation)
.slice(0, MAX_DETAIL_LOOKUPS);
for (const candidate of candidates) {
const details = await client.getDetails(candidate.tmdbType, candidate.tmdbId);
if (details && !details.isAnimation && titlesMatch(title, details.allTitles)) {
return details;
}
}
return null;
});
},
resolveById(tmdbType, tmdbId) {
return guard(() => client.getDetails(tmdbType, tmdbId));
},
};
}
+335
View File
@@ -0,0 +1,335 @@
import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { TmdbConfig } from '../../../types/integrations';
import {
TmdbApiKeyMissingError,
createTmdbClient,
createTmdbApiKeyResolver,
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,
headers: { 'content-type': 'application/json' },
});
}
function captureFetch(handler: (url: URL, init?: RequestInit) => Response) {
const calls: Array<{ url: URL; init?: RequestInit }> = [];
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input));
calls.push({ url, init });
return handler(url, init);
}) as typeof fetch;
return { calls, fetchImpl };
}
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 (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 (t) => {
const { command } = commandFixture(t);
assert.equal(await resolveTmdbApiKey({}, 'bundled'), 'bundled');
assert.equal(await resolveTmdbApiKey({ apiKey: 'mine' }, 'bundled'), 'mine');
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 () => {
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
const client = createTmdbClient({ resolveApiKey: async () => null, fetch: fetchImpl });
await assert.rejects(client.search('半沢直樹'), TmdbApiKeyMissingError);
assert.equal(calls.length, 0);
});
test('search sends a v3 key as a query parameter and drops people from multi results', async () => {
const { calls, fetchImpl } = captureFetch(() =>
jsonResponse({
results: [
{ media_type: 'person', id: 1, name: 'Sakai Masato' },
{
media_type: 'tv',
id: 61222,
name: 'Hanzawa Naoki',
original_name: '半沢直樹',
original_language: 'ja',
overview: 'A banker fights back.',
poster_path: '/hanzawa.jpg',
first_air_date: '2013-07-07',
genre_ids: [18],
},
{
media_type: 'movie',
id: 9,
title: 'Anime Film',
original_title: 'アニメ映画',
original_language: 'ja',
release_date: '2020-01-01',
genre_ids: [16],
},
],
}),
);
const client = createTmdbClient({ resolveApiKey: async () => 'v3key', fetch: fetchImpl });
const results = await client.search(' 半沢直樹 ');
assert.equal(calls.length, 1);
const url = calls[0]!.url;
assert.equal(url.pathname, '/3/search/multi');
assert.equal(url.searchParams.get('query'), '半沢直樹');
assert.equal(url.searchParams.get('api_key'), 'v3key');
assert.equal((calls[0]!.init?.headers as Record<string, string>).Authorization, undefined);
assert.deepEqual(results, [
{
tmdbId: 61222,
tmdbType: 'tv',
title: 'Hanzawa Naoki',
originalTitle: '半沢直樹',
originalLanguage: 'ja',
overview: 'A banker fights back.',
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
year: 2013,
isAnimation: false,
},
{
tmdbId: 9,
tmdbType: 'movie',
title: 'Anime Film',
originalTitle: 'アニメ映画',
originalLanguage: 'ja',
overview: null,
posterUrl: null,
year: 2020,
isAnimation: true,
},
]);
});
test('a v4 read token travels as a bearer header instead of api_key', async () => {
const v4Token = ['eyJ', 'test-header', '.payload', '.sig'].join('');
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
const client = createTmdbClient({
resolveApiKey: async () => v4Token,
fetch: fetchImpl,
});
await client.search('x');
assert.equal(calls[0]!.url.searchParams.has('api_key'), false);
assert.equal(
(calls[0]!.init?.headers as Record<string, string>).Authorization,
`Bearer ${v4Token}`,
);
});
test('getDetails folds translations and alternative titles into the normalized shape', async () => {
const { calls, fetchImpl } = captureFetch(() =>
jsonResponse({
id: 61222,
name: 'Hanzawa Naoki',
original_name: '半沢直樹',
original_language: 'ja',
overview: 'A banker fights back.',
poster_path: '/hanzawa.jpg',
first_air_date: '2013-07-07',
number_of_episodes: 10,
genres: [{ id: 18, name: 'Drama' }],
alternative_titles: { results: [{ iso_3166_1: 'JP', title: 'Hanzawa Naoki Season 1' }] },
translations: {
translations: [
{ iso_639_1: 'en', data: { name: 'Hanzawa Naoki', overview: 'A banker fights back.' } },
{ iso_639_1: 'ja', data: { name: '半沢直樹', overview: '銀行員の物語' } },
],
},
}),
);
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
const details = await client.getDetails('tv', 61222);
assert.equal(calls[0]!.url.pathname, '/3/tv/61222');
assert.equal(
calls[0]!.url.searchParams.get('append_to_response'),
'alternative_titles,translations',
);
assert.deepEqual(details, {
tmdbId: 61222,
tmdbType: 'tv',
titleEnglish: 'Hanzawa Naoki',
titleNative: '半沢直樹',
description: 'A banker fights back.',
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
episodesTotal: 10,
year: 2013,
originalLanguage: 'ja',
isAnimation: false,
allTitles: ['Hanzawa Naoki', '半沢直樹', 'Hanzawa Naoki Season 1'],
});
});
test('getDetails falls back to the Japanese overview and counts a movie as one episode', async () => {
const { fetchImpl } = captureFetch(() =>
jsonResponse({
id: 5,
title: '半沢直樹',
original_title: '半沢直樹',
original_language: 'ja',
overview: '',
release_date: '2019-03-01',
translations: {
translations: [{ iso_639_1: 'ja', data: { title: '半沢直樹', overview: 'あらすじ' } }],
},
}),
);
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
const details = await client.getDetails('movie', 5);
assert.equal(details?.titleEnglish, null);
assert.equal(details?.titleNative, '半沢直樹');
assert.equal(details?.description, 'あらすじ');
assert.equal(details?.episodesTotal, 1);
});
test('getDetails returns null for an unknown id', async () => {
const { fetchImpl } = captureFetch(() => jsonResponse({ status_message: 'nope' }, 404));
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
assert.equal(await client.getDetails('tv', 1), null);
});
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(
() => config,
() => 'bundled',
),
fetch: fetchImpl,
});
await Promise.all([client.search('a'), client.search('b')]);
await client.getDetails('tv', 1);
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
assert.ok(calls.every(({ url }) => url.searchParams.get('api_key') === 'command-key'));
config = { ...config, apiKey: 'literal' };
await client.search('c');
assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'literal');
config = { ...config, apiKey: '' };
await client.search('d');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
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');
});
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: 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',
);
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');
});
}
+311
View File
@@ -0,0 +1,311 @@
import * as childProcess from 'node:child_process';
import type { TmdbMediaType } from '../../../shared/media-kind';
import type { TmdbConfig } from '../../../types/integrations';
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;
export interface TmdbTitleDetails {
tmdbId: number;
tmdbType: TmdbMediaType;
titleEnglish: string | null;
titleNative: string | null;
/** English synopsis, falling back to the Japanese one. */
description: string | null;
posterUrl: string | null;
episodesTotal: number | null;
year: number | null;
originalLanguage: string;
isAnimation: boolean;
/** Every name TMDB knows for the title, used for exact-title matching. */
allTitles: string[];
}
export interface TmdbClient {
search(query: string): Promise<TmdbSearchResult[]>;
getDetails(tmdbType: TmdbMediaType, tmdbId: number): Promise<TmdbTitleDetails | null>;
}
export class TmdbApiKeyMissingError extends Error {
constructor() {
super('TMDB API key not configured. Set tmdb.apiKey or tmdb.apiKeyCommand.');
this.name = 'TmdbApiKeyMissingError';
}
}
export class TmdbRequestError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = 'TmdbRequestError';
}
}
function execCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
childProcess.exec(command, { timeout: 10_000 }, (err, stdout) => {
if (err) {
reject(err);
return;
}
resolve(stdout);
});
});
}
/**
* Resolves the key in priority order: the user's literal `apiKey`, then the
* output of `apiKeyCommand`, then the key bundled into release builds.
*/
export async function resolveTmdbApiKey(
config: TmdbConfig | undefined,
bundledKey: string | null = null,
): Promise<string | null> {
const literal = config?.apiKey?.trim();
if (literal) return literal;
const command = config?.apiKeyCommand?.trim();
if (command) {
try {
const key = (await execCommand(command)).trim();
if (key.length > 0) return key;
} catch {
/* fall through to the bundled key */
}
}
return bundledKey;
}
/** Cache successful command output until either credential setting changes. */
export function createTmdbApiKeyResolver(
getConfig: () => TmdbConfig | undefined,
getBundledKey: () => string | null = () => null,
): () => Promise<string | null> {
let state:
| {
apiKey: string | undefined;
apiKeyCommand: string | undefined;
pending: Promise<string | null> | null;
retryAfterMs: number;
}
| undefined;
return async () => {
const config = getConfig();
if (
!state ||
state.apiKey !== config?.apiKey ||
state.apiKeyCommand !== config?.apiKeyCommand
) {
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();
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;
return key ?? getBundledKey();
};
}
interface RawSearchItem {
media_type?: string;
id?: number;
name?: string;
original_name?: string;
title?: string;
original_title?: string;
original_language?: string;
overview?: string;
poster_path?: string | null;
first_air_date?: string;
release_date?: string;
genre_ids?: number[];
}
interface RawTranslation {
iso_639_1?: string;
data?: { name?: string; title?: string; overview?: string };
}
interface RawDetails extends RawSearchItem {
number_of_episodes?: number;
genres?: Array<{ id?: number }>;
alternative_titles?: { results?: Array<{ title?: string }>; titles?: Array<{ title?: string }> };
translations?: { translations?: RawTranslation[] };
}
function nonEmpty(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function yearOf(date: string | undefined): number | null {
const year = Number.parseInt(date?.slice(0, 4) ?? '', 10);
return Number.isFinite(year) && year > 0 ? year : null;
}
function posterUrlOf(path: string | null | undefined): string | null {
return path ? `${TMDB_POSTER_BASE_URL}${path}` : null;
}
function mediaTypeOf(value: unknown): TmdbMediaType | null {
return value === 'tv' || value === 'movie' ? value : null;
}
function normalizeSearchItem(item: RawSearchItem): TmdbSearchResult | null {
const tmdbType = mediaTypeOf(item.media_type);
if (!tmdbType || typeof item.id !== 'number') return null;
const title = nonEmpty(item.name) ?? nonEmpty(item.title);
const originalTitle = nonEmpty(item.original_name) ?? nonEmpty(item.original_title) ?? title;
if (!title || !originalTitle) return null;
return {
tmdbId: item.id,
tmdbType,
title,
originalTitle,
originalLanguage: item.original_language ?? '',
overview: nonEmpty(item.overview),
posterUrl: posterUrlOf(item.poster_path),
year: yearOf(item.first_air_date ?? item.release_date),
isAnimation: (item.genre_ids ?? []).includes(ANIMATION_GENRE_ID),
};
}
function normalizeDetails(tmdbType: TmdbMediaType, raw: RawDetails): TmdbTitleDetails | null {
if (typeof raw.id !== 'number') return null;
const localizedTitle = nonEmpty(raw.name) ?? nonEmpty(raw.title);
const originalTitle = nonEmpty(raw.original_name) ?? nonEmpty(raw.original_title);
const originalLanguage = raw.original_language ?? '';
const translations = raw.translations?.translations ?? [];
const translationFor = (language: string) =>
translations.find((entry) => entry.iso_639_1 === language)?.data;
const english = translationFor('en');
const japanese = translationFor('ja');
const englishTitle =
nonEmpty(english?.name) ??
nonEmpty(english?.title) ??
(localizedTitle && localizedTitle !== originalTitle ? localizedTitle : null);
const nativeTitle =
originalLanguage === 'ja'
? originalTitle
: (nonEmpty(japanese?.name) ?? nonEmpty(japanese?.title));
const alternativeTitles = [
...(raw.alternative_titles?.results ?? []),
...(raw.alternative_titles?.titles ?? []),
].map((entry) => nonEmpty(entry.title));
const translatedTitles = translations.map(
(entry) => nonEmpty(entry.data?.name) ?? nonEmpty(entry.data?.title),
);
const allTitles = [
...new Set(
[
localizedTitle,
originalTitle,
englishTitle,
nativeTitle,
...translatedTitles,
...alternativeTitles,
].filter((title): title is string => Boolean(title)),
),
];
return {
tmdbId: raw.id,
tmdbType,
titleEnglish: englishTitle,
titleNative: nativeTitle,
description:
nonEmpty(raw.overview) ?? nonEmpty(english?.overview) ?? nonEmpty(japanese?.overview),
posterUrl: posterUrlOf(raw.poster_path),
episodesTotal:
tmdbType === 'movie'
? 1
: typeof raw.number_of_episodes === 'number' && raw.number_of_episodes > 0
? raw.number_of_episodes
: null,
year: yearOf(raw.first_air_date ?? raw.release_date),
originalLanguage,
isAnimation: (raw.genres ?? []).some((genre) => genre.id === ANIMATION_GENRE_ID),
allTitles,
};
}
// TMDB issues two kinds of credential: a short v3 key that travels as a query
// parameter and a long v4 read token (a JWT) that goes in the Authorization
// header. Users paste whichever the settings page showed them.
function isV4Token(apiKey: string): boolean {
return apiKey.startsWith('eyJ');
}
export function createTmdbClient(deps: {
resolveApiKey: () => Promise<string | null>;
fetch?: typeof fetch;
baseUrl?: string;
}): TmdbClient {
const fetchImpl = deps.fetch ?? fetch;
const baseUrl = (deps.baseUrl ?? TMDB_API_BASE_URL).replace(/\/+$/, '');
async function request<T>(path: string, params: Record<string, string>): Promise<T | null> {
const apiKey = await deps.resolveApiKey();
if (!apiKey) throw new TmdbApiKeyMissingError();
const url = new URL(`${baseUrl}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const headers: Record<string, string> = { Accept: 'application/json' };
if (isV4Token(apiKey)) {
headers.Authorization = `Bearer ${apiKey}`;
} else {
url.searchParams.set('api_key', apiKey);
}
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
if (res.status === 404) return null;
if (!res.ok) {
throw new TmdbRequestError(
`TMDB request failed: ${res.status} ${res.statusText}`,
res.status,
);
}
return (await res.json()) as T;
}
return {
async search(query) {
const trimmed = query.trim();
if (!trimmed) return [];
const payload = await request<{ results?: RawSearchItem[] }>('/search/multi', {
query: trimmed,
include_adult: 'false',
language: 'en-US',
page: '1',
});
return (payload?.results ?? [])
.map(normalizeSearchItem)
.filter((item): item is TmdbSearchResult => item !== null);
},
async getDetails(tmdbType, tmdbId) {
const raw = await request<RawDetails>(`/${tmdbType}/${tmdbId}`, {
language: 'en-US',
append_to_response: 'alternative_titles,translations',
});
return raw ? normalizeDetails(tmdbType, raw) : null;
},
};
}
+54 -103
View File
@@ -49,10 +49,7 @@ import {
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
updateLinuxMpvFullscreenOverlayRefreshBurst,
} from './main/runtime/linux-mpv-fullscreen-overlay-refresh';
import {
resolveLinuxVisibleOverlayWindowModeAction,
type LinuxVisibleOverlayWindowMode,
} from './main/runtime/linux-visible-overlay-window-mode';
import { createLinuxOverlayModeRuntime } from './main/runtime/linux-overlay-mode-runtime';
import { shouldRunLinuxOverlayZOrderKeepAlive } from './main/runtime/linux-overlay-zorder-keepalive';
import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus';
import { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff';
@@ -430,6 +427,9 @@ import {
} from './core/services/anilist/anilist-updater';
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
import { createLiveActionMetadataResolver } from './core/services/tmdb/live-action-resolver';
import { createTmdbClient, createTmdbApiKeyResolver } from './core/services/tmdb/tmdb-client';
import { readBundledTmdbApiKey } from './core/services/tmdb/bundled-api-key';
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
@@ -964,6 +964,8 @@ const reportFatalError = createFatalErrorReporter({
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
// Release builds stage a project TMDB key next to the compiled main process.
const bundledTmdbApiKey = readBundledTmdbApiKey(__dirname);
const statsPreloadPath = path.join(__dirname, 'preload-stats.js');
const statsServerRuntime = createStatsServerRuntime({
userDataPath: USER_DATA_PATH,
@@ -990,6 +992,7 @@ const statsServerRuntime = createStatsServerRuntime({
},
getYomitanAnkiDeckName: () => getCurrentYomitanAnkiDeckNameForRuntime(),
getAnilistRateLimiter: () => anilistRateLimiter,
getBundledTmdbApiKey: () => bundledTmdbApiKey,
resolveAnkiNoteId: (noteId) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
trackDuplicateNoteIdsForNote: (noteId, duplicateNoteIds) => {
appState.ankiIntegration?.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
@@ -1688,6 +1691,17 @@ const anilistRateLimiter = createAnilistRateLimiter();
const statsCoverArtFetcher = createCoverArtFetcher(
anilistRateLimiter,
createLogger('main:stats-cover-art'),
{
liveAction: createLiveActionMetadataResolver(
createTmdbClient({
resolveApiKey: createTmdbApiKeyResolver(
() => configService.getConfig().tmdb,
() => bundledTmdbApiKey,
),
}),
createLogger('main:tmdb'),
),
},
);
const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler());
const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler());
@@ -1982,11 +1996,27 @@ let lastObservedTimePos = 0;
let lastObservedPrimarySubtitleTrackId: number | null = null;
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
null;
let linuxVisibleOverlayWindowMode: LinuxVisibleOverlayWindowMode = 'managed';
let linuxTrackedMpvFullscreen = false;
let linuxTrackedMpvFullscreenChangedAtMs = 0;
let linuxVisibleOverlayOwnerBindingKey: string | null = null;
let linuxVisibleOverlayWindowModeSwitchToken = 0;
const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime({
isEnabled: shouldRunLinuxOverlayZOrderKeepAlive,
isVisible: () => overlayManager.getVisibleOverlayVisible(),
getWindow: () => overlayManager.getMainWindow(),
clearWindow: () => overlayManager.setMainWindow(null),
createWindow: () => {
visibleOverlayInteractionRuntime.resetVisibleOverlayInputState();
createMainWindow();
},
refreshWindow: () => {
const trackedGeometry = overlayGeometryRuntime.getCurrentTrackedOverlayGeometry();
if (trackedGeometry) overlayManager.setOverlayWindowBounds(trackedGeometry);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
void ensureOverlayMpvSubtitlesHidden();
if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
},
now: Date.now,
logDebug: (message) => logger.debug(message),
});
let subtitleSidebarRequestedOpen = false;
const SEEK_THRESHOLD_SECONDS = 3;
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
@@ -2746,7 +2776,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
},
hideNonNativeOverlayWhenTargetUnfocused: () =>
shouldRunLinuxOverlayZOrderKeepAlive() &&
linuxVisibleOverlayWindowMode === 'fullscreen-override',
linuxOverlayModeRuntime.mode === 'fullscreen-override',
resolveFallbackBounds: () => {
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
@@ -2792,9 +2822,9 @@ const visibleOverlayInteractionRuntime = createVisibleOverlayInteractionRuntime(
getBackendOverride: () => appState.backendOverride,
getInitialArgs: () => appState.initialArgs,
getOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode,
getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode,
setLinuxVisibleOverlayOwnerBindingKey: (key) => {
linuxVisibleOverlayOwnerBindingKey = key;
linuxOverlayModeRuntime.ownerBindingKey = key;
},
bindVisibleOverlayToTrackedX11Window: (window) =>
overlayGeometryRuntime.bindVisibleOverlayToTrackedX11Window(window),
@@ -2952,7 +2982,8 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
startTime: range.startTime,
endTime: range.endTime,
}),
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
openModal: (payload, signal) =>
openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload, signal),
onPreviewEnded: (reviewId) => {
// The review may live in either overlay window; the renderer ignores foreign review ids.
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
@@ -3989,6 +4020,7 @@ const {
clearWindowsVisibleOverlayForegroundPollLoop: () =>
visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
linuxOverlayModeRuntime.cancelPendingTransition();
cancelLinuxMpvFullscreenOverlayRefreshBurst = null;
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
},
@@ -4123,7 +4155,6 @@ const immersionTrackerStartupMainDeps: Parameters<
// Register stats overlay toggle IPC handler (idempotent)
registerStatsOverlayToggle({
staticDir: statsDistPath,
preloadPath: statsPreloadPath,
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
onStartupError: (error) =>
@@ -4709,7 +4740,7 @@ const {
},
overlayVisibilityRuntime,
syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) =>
syncLinuxVisibleOverlayMpvFullscreenMode(nextFullscreen),
linuxOverlayModeRuntime.sync(nextFullscreen),
getOverlayInteractionActive: () =>
visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() ||
visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(),
@@ -5021,14 +5052,14 @@ const overlayGeometryRuntime = createOverlayGeometryRuntime({
getTrackedWindowNativeId: () => appState.windowTracker?.getTargetWindowNativeId?.(),
getStatsOverlayVisible: () => appState.statsOverlayVisible,
getOverlayForegroundSeparateWindows: () => getOverlayForegroundSeparateWindows(),
getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode,
getLinuxTrackedMpvFullscreen: () => linuxTrackedMpvFullscreen,
getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxTrackedMpvFullscreenChangedAtMs,
getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode,
getLinuxTrackedMpvFullscreen: () => linuxOverlayModeRuntime.fullscreen,
getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxOverlayModeRuntime.fullscreenChangedAtMs,
syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen) =>
syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen),
getLinuxVisibleOverlayOwnerBindingKey: () => linuxVisibleOverlayOwnerBindingKey,
linuxOverlayModeRuntime.sync(fullscreen),
getLinuxVisibleOverlayOwnerBindingKey: () => linuxOverlayModeRuntime.ownerBindingKey,
setLinuxVisibleOverlayOwnerBindingKey: (key) => {
linuxVisibleOverlayOwnerBindingKey = key;
linuxOverlayModeRuntime.ownerBindingKey = key;
},
clearVisibleOverlayX11OwnerBinding: (window) =>
visibleOverlayInteractionRuntime.clearVisibleOverlayX11OwnerBinding(window),
@@ -5113,85 +5144,6 @@ function createMainWindow(): BrowserWindow {
return window;
}
function createLinuxVisibleOverlayWindowForCurrentMode(token: number, fullscreen: boolean): void {
if (token !== linuxVisibleOverlayWindowModeSwitchToken) {
return;
}
if (!overlayManager.getVisibleOverlayVisible()) {
return;
}
const existingWindow = overlayManager.getMainWindow();
if (existingWindow && !existingWindow.isDestroyed()) {
return;
}
visibleOverlayInteractionRuntime.resetVisibleOverlayInputState();
createMainWindow();
const trackedGeometry = overlayGeometryRuntime.getCurrentTrackedOverlayGeometry();
if (trackedGeometry) {
overlayManager.setOverlayWindowBounds(trackedGeometry);
}
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
void ensureOverlayMpvSubtitlesHidden();
if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
logger.debug(
`Switched Linux visible overlay window mode to ${linuxVisibleOverlayWindowMode} for mpv fullscreen=${fullscreen}`,
);
}
function syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen: boolean): void {
if (!shouldRunLinuxOverlayZOrderKeepAlive()) {
return;
}
if (linuxTrackedMpvFullscreen !== fullscreen) {
linuxTrackedMpvFullscreenChangedAtMs = Date.now();
}
linuxTrackedMpvFullscreen = fullscreen;
const currentWindow = overlayManager.getMainWindow();
const hasLiveWindow = Boolean(currentWindow && !currentWindow.isDestroyed());
const action = resolveLinuxVisibleOverlayWindowModeAction({
currentMode: linuxVisibleOverlayWindowMode,
fullscreen,
hasLiveWindow,
visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(),
});
linuxVisibleOverlayWindowMode = action.nextMode;
linuxVisibleOverlayOwnerBindingKey = null;
linuxVisibleOverlayWindowModeSwitchToken += 1;
const token = linuxVisibleOverlayWindowModeSwitchToken;
if (!action.shouldCreateWindow && !action.shouldDestroyCurrentWindow) {
return;
}
const previousWindow = currentWindow;
if (action.shouldDestroyCurrentWindow && previousWindow && !previousWindow.isDestroyed()) {
previousWindow.once('closed', () => {
if (overlayManager.getMainWindow() === previousWindow) {
overlayManager.setMainWindow(null);
}
if (action.createWindowTiming === 'after-current-destroyed') {
createLinuxVisibleOverlayWindowForCurrentMode(token, fullscreen);
}
});
previousWindow.hide();
previousWindow.destroy();
}
if (!action.shouldCreateWindow) {
logger.debug(
`Recorded Linux visible overlay window mode ${action.nextMode} for hidden mpv fullscreen=${fullscreen}`,
);
return;
}
if (action.createWindowTiming === 'now') {
createLinuxVisibleOverlayWindowForCurrentMode(token, fullscreen);
}
}
function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler();
if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) {
@@ -5506,7 +5458,6 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
await dispatchSessionActionCore(request, {
toggleStatsOverlay: async () =>
await toggleStatsOverlayWindow({
staticDir: statsDistPath,
preloadPath: statsPreloadPath,
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
onStartupError: (error) =>
@@ -6358,8 +6309,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
getLinuxX11FullscreenOverlay: () =>
shouldRunLinuxOverlayZOrderKeepAlive() &&
linuxTrackedMpvFullscreen &&
linuxVisibleOverlayWindowMode === 'fullscreen-override',
linuxOverlayModeRuntime.fullscreen &&
linuxOverlayModeRuntime.mode === 'fullscreen-override',
onVisibleWindowBlurred: () =>
visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh(),
onVisibleWindowFocused: () =>
+6 -3
View File
@@ -453,7 +453,7 @@ test('Linux visible overlay recreation clears stale input state before creating
const source = readMainSource();
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
const actionBlock = source.match(
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
/const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime\(\{[\s\S]*?createWindow: \(\) => \{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
const resetBlock = runtimeSource.match(
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
@@ -472,7 +472,7 @@ test('Linux visible overlay recreation clears stale input state before creating
test('Linux visible overlay recreation avoids display fallback before tracked geometry exists', () => {
const source = readMainSource();
const actionBlock = source.match(
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
/const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime\(\{[\s\S]*?refreshWindow: \(\) => \{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(actionBlock);
@@ -480,7 +480,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
actionBlock,
/const trackedGeometry = overlayGeometryRuntime\.getCurrentTrackedOverlayGeometry\(\);/,
);
assert.match(actionBlock, /if \(trackedGeometry\) \{/);
assert.match(
actionBlock,
/if \(trackedGeometry\) overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/,
);
assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/);
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
});
@@ -8,13 +8,10 @@ import {
createBuildRestoreWindowsOnActivateMainDepsHandler,
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
} from '../app-lifecycle-main-activate';
import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from '../protocol-url-handlers-main-deps';
import { registerProtocolUrlHandlers } from '../protocol-url-handlers';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type RegisterProtocolUrlHandlersMainDeps = Parameters<
typeof createBuildRegisterProtocolUrlHandlersMainDepsHandler
>[0];
type RegisterProtocolUrlHandlersMainDeps = Parameters<typeof registerProtocolUrlHandlers>[0];
type OnWillQuitCleanupDeps = Parameters<typeof createBuildOnWillQuitCleanupDepsHandler>[0];
type ShouldRestoreWindowsOnActivateMainDeps = Parameters<
typeof createBuildShouldRestoreWindowsOnActivateMainDepsHandler
@@ -40,10 +37,6 @@ export type StartupLifecycleComposerResult = ComposerOutputs<{
export function composeStartupLifecycleHandlers(
options: StartupLifecycleComposerOptions,
): StartupLifecycleComposerResult {
const registerProtocolUrlHandlersMainDeps = createBuildRegisterProtocolUrlHandlersMainDepsHandler(
options.registerProtocolUrlHandlersMainDeps,
)();
const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler(
createBuildOnWillQuitCleanupDepsHandler(options.onWillQuitCleanupMainDeps)(),
);
@@ -58,9 +51,9 @@ export function composeStartupLifecycleHandlers(
return {
registerProtocolUrlHandlers: () =>
registerProtocolUrlHandlers(registerProtocolUrlHandlersMainDeps),
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
registerProtocolUrlHandlers(options.registerProtocolUrlHandlersMainDeps),
onWillQuitCleanup: onWillQuitCleanupHandler,
shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler,
restoreWindowsOnActivate: restoreWindowsOnActivateHandler,
};
}
-1
View File
@@ -13,4 +13,3 @@ export * from '../anilist-state';
export * from '../anilist-token-refresh';
export * from '../anilist-token-refresh-main-deps';
export * from '../protocol-url-handlers';
export * from '../protocol-url-handlers-main-deps';
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { test } from 'node:test';
import { createLinuxOverlayModeRuntime } from './linux-overlay-mode-runtime';
class TestWindow extends EventEmitter {
destroyed = false;
hidden = false;
isDestroyed() {
return this.destroyed;
}
hide() {
this.hidden = true;
}
destroy() {
this.destroyed = true;
}
finishClose() {
this.emit('closed');
}
}
function fixture() {
const initial = new TestWindow();
const state: { window: TestWindow | null; visible: boolean; creates: number; refreshes: number } =
{
window: initial,
visible: true,
creates: 0,
refreshes: 0,
};
const runtime = createLinuxOverlayModeRuntime({
isEnabled: () => true,
isVisible: () => state.visible,
getWindow: () => state.window,
clearWindow: () => {
state.window = null;
},
createWindow: () => {
state.creates += 1;
state.window = new TestWindow();
},
refreshWindow: () => {
state.refreshes += 1;
},
now: () => 42,
logDebug: () => {},
});
return { initial, state, runtime };
}
test('Linux mode transition waits for close before replacing and refreshing the window', () => {
const { initial, state, runtime } = fixture();
runtime.ownerBindingKey = 'old-owner';
runtime.sync(true);
assert.equal(runtime.mode, 'fullscreen-override');
assert.equal(runtime.fullscreenChangedAtMs, 42);
assert.equal(runtime.ownerBindingKey, null);
assert.equal(initial.hidden, true);
assert.equal(state.creates, 0);
initial.finishClose();
assert.equal(state.creates, 1);
assert.equal(state.refreshes, 1);
runtime.sync(true);
assert.equal(state.creates, 1);
});
test('an older close callback cannot clear or replace a newer overlay', () => {
const { initial, state, runtime } = fixture();
runtime.sync(true);
runtime.sync(false);
const replacement = state.window;
assert.equal(state.creates, 1);
initial.finishClose();
assert.equal(state.window, replacement);
assert.equal(state.creates, 1);
assert.equal(runtime.mode, 'managed');
});
test('hiding or cancelling a transition prevents delayed window creation', () => {
for (const cancel of [false, true]) {
const { initial, state, runtime } = fixture();
runtime.sync(true);
if (cancel) runtime.cancelPendingTransition();
else state.visible = false;
initial.finishClose();
assert.equal(state.creates, 0);
assert.equal(state.window, null);
state.visible = true;
runtime.sync(true);
assert.equal(state.creates, 1);
}
});
@@ -0,0 +1,94 @@
import type { BrowserWindow } from 'electron';
import {
resolveLinuxVisibleOverlayWindowModeAction,
type LinuxVisibleOverlayWindowMode,
} from './linux-visible-overlay-window-mode';
type OverlayWindow = Pick<BrowserWindow, 'isDestroyed' | 'hide' | 'destroy'> & {
once: (event: 'closed', listener: () => void) => unknown;
};
export function createLinuxOverlayModeRuntime<Window extends OverlayWindow>(deps: {
isEnabled: () => boolean;
isVisible: () => boolean;
getWindow: () => Window | null;
clearWindow: () => void;
createWindow: () => void;
refreshWindow: () => void;
now: () => number;
logDebug: (message: string) => void;
}) {
let mode: LinuxVisibleOverlayWindowMode = 'managed';
let fullscreen = false;
let fullscreenChangedAtMs = 0;
let ownerBindingKey: string | null = null;
let generation = 0;
function createWindowForMode(token: number, nextFullscreen: boolean): void {
if (token !== generation || !deps.isVisible()) return;
const existing = deps.getWindow();
if (existing && !existing.isDestroyed()) return;
deps.createWindow();
deps.refreshWindow();
deps.logDebug(
`Switched Linux visible overlay window mode to ${mode} for mpv fullscreen=${nextFullscreen}`,
);
}
function sync(nextFullscreen: boolean): void {
if (!deps.isEnabled()) return;
if (fullscreen !== nextFullscreen) fullscreenChangedAtMs = deps.now();
fullscreen = nextFullscreen;
const current = deps.getWindow();
const action = resolveLinuxVisibleOverlayWindowModeAction({
currentMode: mode,
fullscreen,
hasLiveWindow: Boolean(current && !current.isDestroyed()),
visibleOverlayVisible: deps.isVisible(),
});
mode = action.nextMode;
ownerBindingKey = null;
const token = ++generation;
if (!action.shouldCreateWindow && !action.shouldDestroyCurrentWindow) return;
if (action.shouldDestroyCurrentWindow && current && !current.isDestroyed()) {
current.once('closed', () => {
if (deps.getWindow() === current) deps.clearWindow();
if (action.createWindowTiming === 'after-current-destroyed') {
createWindowForMode(token, nextFullscreen);
}
});
current.hide();
current.destroy();
}
if (!action.shouldCreateWindow) {
deps.logDebug(
`Recorded Linux visible overlay window mode ${action.nextMode} for hidden mpv fullscreen=${fullscreen}`,
);
return;
}
if (action.createWindowTiming === 'now') createWindowForMode(token, nextFullscreen);
}
return {
get mode() {
return mode;
},
get fullscreen() {
return fullscreen;
},
get fullscreenChangedAtMs() {
return fullscreenChangedAtMs;
},
get ownerBindingKey() {
return ownerBindingKey;
},
set ownerBindingKey(key: string | null) {
ownerBindingKey = key;
},
sync,
cancelPendingTransition: () => {
generation += 1;
},
};
}
@@ -20,11 +20,13 @@ export async function openMediaTimingReviewModal(
logWarn: (message: string) => void;
},
payload: MediaTimingReviewOpenPayload,
signal?: AbortSignal,
): Promise<boolean> {
return await retryOverlayModalOpen(
{ waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn },
{
modal: MODAL,
signal,
// The review renderer regularly needs more than the 1.5 s the other modals allow; a
// premature retry re-sends the payload and reloads the waveform for nothing.
timeoutMs: 4_000,
@@ -8,6 +8,7 @@ import type {
RemoteMediaWindowSource,
} from '../../core/services/remote-media-window-cache';
import type { MediaTimingPreviewSession } from '../../core/services/media-timing-preview';
import { openMediaTimingReviewModal } from './media-timing-review-open';
type MediaTimingPreviewSessionLike = Pick<MediaTimingPreviewSession, 'start'>;
import {
@@ -16,6 +17,20 @@ import {
createMediaTimingReviewRuntime,
} from './media-timing-review';
function createDeferred<T>() {
let settle: ((value: T) => void) | null = null;
const promise = new Promise<T>((resolve) => {
settle = resolve;
});
return {
promise,
resolve(value: T): void {
if (!settle) throw new Error('deferred promise is unavailable');
settle(value);
},
};
}
describe('buildMediaTimingReviewPayload', () => {
test('starts from the padded range and leaves two seconds to drag on each side', () => {
const payload = buildMediaTimingReviewPayload(
@@ -764,6 +779,206 @@ test('disposing an open review settles it with original timing and restores play
]);
});
for (const pendingSetup of ['properties', 'video-source'] as const) {
test(`disposing pending ${pendingSetup} cancels side effects and permits a fresh review`, async () => {
const setupGate = createDeferred<void>();
const commands: Array<Array<string | number>> = [];
let blockSetup = true;
let modalOpenCalls = 0;
let previewCreateCalls = 0;
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => {
if (blockSetup && pendingSetup === 'properties') await setupGate.promise;
return name === 'pause' ? false : name === 'duration' ? 100 : null;
},
send: ({ command }) => commands.push(command),
}),
resolveVideoSource: async () => {
if (blockSetup && pendingSetup === 'video-source') await setupGate.promise;
return { path: '/video/show.mkv' };
},
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => {
previewCreateCalls += 1;
return {
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
};
},
openModal: async (payload) => {
modalOpenCalls += 1;
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
return true;
},
showStatus: () => undefined,
});
const request = {
kind: 'word' as const,
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
screenshotEnabled: true,
};
const pending = runtime.requestReview(request);
let pendingSettled = false;
void pending.finally(() => {
pendingSettled = true;
});
await Promise.resolve();
await runtime.dispose();
assert.equal(pendingSettled, true);
assert.deepEqual(await pending, { action: 'use-original' });
assert.deepEqual(commands, []);
assert.equal(modalOpenCalls, 0);
assert.equal(previewCreateCalls, 0);
setupGate.resolve();
await Promise.resolve();
blockSetup = false;
assert.deepEqual(await runtime.requestReview(request), { action: 'use-original' });
assert.equal(modalOpenCalls, 1);
assert.equal(previewCreateCalls, 1);
});
}
test('disposing during modal acknowledgement prevents the real opener from retrying', async () => {
const waiting = createDeferred<void>();
const acknowledgement = createDeferred<boolean>();
const commands: Array<Array<string | number>> = [];
let sendCalls = 0;
let previewDisposeCalls = 0;
let opening: Promise<boolean> | undefined;
const runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'pause' ? false : null),
send: ({ command }) => commands.push(command),
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => {
previewDisposeCalls += 1;
},
}),
openModal: (payload, signal) => {
opening = openMediaTimingReviewModal(
{
ensureOverlayStartupPrereqs: () => {},
ensureOverlayWindowsReadyForVisibilityActions: () => {},
sendToActiveOverlayWindow: () => {
sendCalls += 1;
return true;
},
waitForModalOpen: () => {
waiting.resolve();
return acknowledgement.promise;
},
logWarn: () => {},
},
payload,
signal,
);
return opening;
},
showStatus: () => {},
});
const pending = runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
});
await waiting.promise;
await runtime.dispose();
assert.deepEqual(await pending, { action: 'use-original' });
acknowledgement.resolve(false);
assert.equal(await opening, false);
assert.equal(sendCalls, 1);
assert.equal(previewDisposeCalls, 1);
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
});
test('disposing owns a preview session whose startup is still pending', async () => {
const openedPayload = createDeferred<MediaTimingReviewOpenPayload>();
const previewStarted = createDeferred<void>();
const previewStartGate = createDeferred<void>();
let previewDisposeCalls = 0;
const runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'duration' ? 100 : null),
send: () => undefined,
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => {
previewStarted.resolve();
await previewStartGate.promise;
},
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => {
previewDisposeCalls += 1;
},
}),
openModal: async (payload) => {
openedPayload.resolve(payload);
return true;
},
showStatus: () => undefined,
});
const pending = runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
});
await openedPayload.promise;
await previewStarted.promise;
await runtime.dispose();
assert.deepEqual(await pending, { action: 'use-original' });
assert.equal(previewDisposeCalls, 0);
previewStartGate.resolve();
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(previewDisposeCalls, 1);
});
test('media timing review forwards the hidden player finishing a preview to the modal', async () => {
const endedReviewIds: string[] = [];
const playback: { ended?: () => void } = {};
+125 -32
View File
@@ -78,6 +78,15 @@ interface ActiveReview {
resolve: (decision: MediaTimingReviewDecision) => void;
}
interface ReviewRequestLifecycle {
signal: AbortSignal;
cancelled: Promise<void>;
settled: Promise<void>;
isCancelled(): boolean;
cancel(): void;
markSettled(): void;
}
export interface MediaTimingReviewRuntimeDeps {
getMpvClient: () => ReviewMpvClient | null;
getCurrentMediaPath: () => string | null;
@@ -101,7 +110,7 @@ export interface MediaTimingReviewRuntimeDeps {
next: MediaTimingReviewContextLine[];
};
decisionTimeoutMs?: number;
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
openModal: (payload: MediaTimingReviewOpenPayload, signal: AbortSignal) => Promise<boolean>;
/** Tells the modal that the hidden player finished the previewed clip. */
onPreviewEnded?: (reviewId: string) => void;
showStatus: (message: string) => void;
@@ -118,6 +127,33 @@ function booleanProperty(value: unknown): boolean | null {
return null;
}
function createReviewRequestLifecycle(): ReviewRequestLifecycle {
const controller = new AbortController();
let resolveCancellation: (() => void) | null = null;
let resolveSettled: (() => void) | null = null;
const cancellation = new Promise<void>((resolve) => {
resolveCancellation = resolve;
});
const settled = new Promise<void>((resolve) => {
resolveSettled = resolve;
});
return {
signal: controller.signal,
cancelled: cancellation,
settled,
isCancelled: () => controller.signal.aborted,
cancel: () => {
if (controller.signal.aborted) return;
controller.abort();
resolveCancellation?.();
},
markSettled: () => {
resolveSettled?.();
resolveSettled = null;
},
};
}
/**
* Picks the subtitle lines adjacent to the mined range that the review modal can pull
* onto the card. Parsed cues cover both directions; when none are loaded (e.g. the
@@ -245,7 +281,7 @@ export function buildMediaTimingReviewPayload(
export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDeps) {
let active: ActiveReview | null = null;
let reviewInProgress = false;
let currentRequest: ReviewRequestLifecycle | null = null;
let pendingPauseRestore: ReviewMpvClient | null = null;
function restorePendingPlayback(): void {
@@ -311,14 +347,12 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
const previous = review.preview;
const session = deps.createPreviewSession();
session.onPlaybackEnded(() => {
if (active === review && review.preview?.session === started) {
deps.onPreviewEnded?.(review.payload.reviewId);
}
});
const { audioTrackId, ...previewOptions } = review.previewOptions;
const started = session
.start({
const startSession = async (): Promise<PreviewSession> => {
if (active !== review) {
throw new Error('This timing review is no longer active.');
}
await session.start({
mediaPath,
...previewOptions,
// A cached window keeps one audio stream, so mpv's track id from the source no longer applies.
@@ -327,19 +361,30 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
: audioTrackId !== undefined
? { audioTrackId }
: {}),
})
.then(() => session)
.catch((error) => {
});
return session;
};
const started = Promise.resolve()
.then(startSession)
.catch((error: unknown) => {
session.dispose();
throw error;
});
review.preview = { path: mediaPath, session: started };
session.onPlaybackEnded(() => {
if (active === review && review.preview?.session === started) {
deps.onPreviewEnded?.(review.payload.reviewId);
}
});
void started.catch(() => {});
if (previous) void previous.session.then((old) => old.dispose()).catch(() => {});
return started;
}
async function runReview(request: MediaTimingReviewRequest): Promise<MediaTimingReviewDecision> {
async function runReview(
request: MediaTimingReviewRequest,
lifecycle: ReviewRequestLifecycle,
): Promise<MediaTimingReviewDecision> {
const mpvClient = deps.getMpvClient();
const mediaPath =
deps.getCurrentMediaPath()?.trim() || mpvClient?.currentVideoPath?.trim() || '';
@@ -348,18 +393,30 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
return { action: 'use-original' };
}
const setupPromise = Promise.all([
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
deps.resolveMediaSource?.().catch(() => null) ?? null,
request.screenshotEnabled ? (deps.resolveVideoSource?.().catch(() => null) ?? null) : null,
]);
const setup = await Promise.race([
setupPromise.then((values) => ({ kind: 'ready' as const, values })),
lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
]);
if (setup.kind === 'cancelled' || lifecycle.isCancelled()) {
return { action: 'use-original' };
}
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] =
await Promise.all([
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
deps.resolveMediaSource?.().catch(() => null) ?? null,
request.screenshotEnabled ? (deps.resolveVideoSource?.().catch(() => null) ?? null) : null,
]);
setup.values;
const pauseState = booleanProperty(pauseRaw);
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
pendingPauseRestore = pauseState === false ? mpvClient : null;
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
if (lifecycle.isCancelled()) {
restorePendingPlayback();
return { action: 'use-original' };
}
let contextLines: ReturnType<NonNullable<typeof deps.getSubtitleContextLines>> | undefined;
try {
@@ -423,9 +480,22 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
endTime: payload.timelineEndTime,
}).catch(() => {});
const opened = await deps.openModal(payload).catch(() => false);
if (lifecycle.isCancelled() || active !== review) {
await cleanupActiveReview(review);
return { action: 'use-original' };
}
const openModal = deps.openModal(payload, lifecycle.signal).catch(() => false);
const openResult = await Promise.race([
openModal.then((opened) => ({ kind: 'opened' as const, opened })),
lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
]);
if (openResult.kind === 'cancelled' || lifecycle.isCancelled() || active !== review) {
await cleanupActiveReview(review);
return { action: 'use-original' };
}
const { opened } = openResult;
if (!opened) {
await cleanupActiveReview();
await cleanupActiveReview(review);
deps.showStatus('Timing review could not open. Using the original subtitle timing.');
return { action: 'use-original' };
}
@@ -434,33 +504,43 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
() => resolveDecision({ action: 'use-original' }),
Math.max(0, deps.decisionTimeoutMs ?? REVIEW_DECISION_TIMEOUT_MS),
);
let decision: MediaTimingReviewDecision;
let decision: MediaTimingReviewDecision = { action: 'use-original' };
try {
decision = await decisionPromise;
const decisionResult = await Promise.race([
decisionPromise.then((value) => ({ kind: 'decided' as const, value })),
lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
]);
if (decisionResult.kind === 'decided') {
decision = decisionResult.value;
}
} finally {
clearTimeout(decisionWatchdog);
}
await cleanupActiveReview();
await cleanupActiveReview(review);
return decision;
}
async function requestReview(
request: MediaTimingReviewRequest,
): Promise<MediaTimingReviewDecision> {
if (active || reviewInProgress) {
if (active || currentRequest) {
deps.showStatus('Finish the current timing review before mining another card.');
return { action: 'use-original' };
}
reviewInProgress = true;
const lifecycle = createReviewRequestLifecycle();
currentRequest = lifecycle;
try {
return await runReview(request);
return await runReview(request, lifecycle);
} catch {
await cleanupActiveReview();
restorePendingPlayback();
deps.showStatus('Timing review failed. Using the original subtitle timing.');
return { action: 'use-original' };
} finally {
reviewInProgress = false;
if (currentRequest === lifecycle) {
currentRequest = null;
}
lifecycle.markSettled();
}
}
@@ -603,9 +683,18 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
try {
const previewSession = current.preview ? await current.preview.session : null;
if (active !== current) {
return staleReviewResult();
}
await previewSession?.stop();
if (active !== current) {
return staleReviewResult();
}
return { ok: true };
} catch (error) {
if (active !== current) {
return staleReviewResult();
}
return {
ok: false,
message: `Could not stop preview: ${error instanceof Error ? error.message : String(error)}`,
@@ -641,8 +730,9 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
return { ok: true };
}
async function cleanupActiveReview(): Promise<void> {
async function cleanupActiveReview(expected?: ActiveReview): Promise<void> {
const current = active;
if (expected && current !== expected) return;
active = null;
if (!current) return;
deps.clearFrameCache?.();
@@ -653,9 +743,12 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
async function dispose(): Promise<void> {
const request = currentRequest;
request?.cancel();
active?.resolve({ action: 'use-original' });
await cleanupActiveReview();
restorePendingPlayback();
await request?.settled;
}
return {
@@ -1,6 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { openOverlayHostedModal } from './overlay-hosted-modal-open';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
test('retryOverlayModalOpen skips the first send when already aborted', async () => {
const controller = new AbortController();
controller.abort();
const unexpectedCall = () => assert.fail('aborted open must not send or wait');
assert.equal(
await retryOverlayModalOpen(
{ waitForModalOpen: unexpectedCall, logWarn: unexpectedCall },
{
modal: 'media-timing-review',
timeoutMs: 4_000,
retryWarning: 'retry',
sendOpen: unexpectedCall,
signal: controller.signal,
},
),
false,
);
});
for (const abortOnWait of [1, 2]) {
test(`retryOverlayModalOpen rejects an acknowledgement aborted during wait ${abortOnWait}`, async () => {
const controller = new AbortController();
let waitCalls = 0;
let sendCalls = 0;
const opened = await retryOverlayModalOpen(
{
waitForModalOpen: async () => {
waitCalls += 1;
if (waitCalls === abortOnWait) {
controller.abort();
return true;
}
return false;
},
logWarn: () => {},
},
{
modal: 'media-timing-review',
timeoutMs: 4_000,
retryWarning: 'retry',
sendOpen: () => {
sendCalls += 1;
return true;
},
signal: controller.signal,
},
);
assert.equal(opened, false);
assert.equal(sendCalls, abortOnWait);
assert.equal(waitCalls, abortOnWait);
});
}
test('retryOverlayModalOpen still retries other modals without a signal', async () => {
let sendCalls = 0;
const opened = await retryOverlayModalOpen(
{ waitForModalOpen: async () => sendCalls === 2, logWarn: () => {} },
{
modal: 'runtime-options',
timeoutMs: 1_500,
retryWarning: 'retry',
sendOpen: () => {
sendCalls += 1;
return true;
},
},
);
assert.equal(opened, true);
assert.equal(sendCalls, 2);
});
test('openOverlayHostedModal ensures overlay readiness before sending the open event', () => {
const calls: string[] = [];
@@ -38,20 +38,24 @@ export async function retryOverlayModalOpen(
timeoutMs: number;
retryWarning: string;
sendOpen: () => boolean;
signal?: AbortSignal;
},
): Promise<boolean> {
if (!input.sendOpen()) {
if (input.signal?.aborted || !input.sendOpen()) {
return false;
}
if (await deps.waitForModalOpen(input.modal, input.timeoutMs)) {
const opened = await deps.waitForModalOpen(input.modal, input.timeoutMs);
if (input.signal?.aborted) return false;
if (opened) {
return true;
}
deps.logWarn(input.retryWarning);
if (!input.sendOpen()) {
if (input.signal?.aborted || !input.sendOpen()) {
return false;
}
return await deps.waitForModalOpen(input.modal, input.timeoutMs);
const retryOpened = await deps.waitForModalOpen(input.modal, input.timeoutMs);
return !input.signal?.aborted && retryOpened;
}
@@ -1,29 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from './protocol-url-handlers-main-deps';
test('protocol url handlers main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildRegisterProtocolUrlHandlersMainDepsHandler({
registerOpenUrl: () => calls.push('open-register'),
registerSecondInstance: () => calls.push('second-register'),
handleAnilistSetupProtocolUrl: () => true,
findAnilistSetupDeepLinkArgvUrl: () => 'subminer://anilist-setup',
logUnhandledOpenUrl: (rawUrl) => calls.push(`open:${rawUrl}`),
logUnhandledSecondInstanceUrl: (rawUrl) => calls.push(`second:${rawUrl}`),
})();
deps.registerOpenUrl(() => {});
deps.registerSecondInstance(() => {});
assert.equal(deps.handleAnilistSetupProtocolUrl('subminer://anilist-setup'), true);
assert.equal(deps.findAnilistSetupDeepLinkArgvUrl(['x']), 'subminer://anilist-setup');
deps.logUnhandledOpenUrl('subminer://noop');
deps.logUnhandledSecondInstanceUrl('subminer://noop');
assert.deepEqual(calls, [
'open-register',
'second-register',
'open:subminer://noop',
'second:subminer://noop',
]);
});
@@ -1,16 +0,0 @@
import type { registerProtocolUrlHandlers } from './protocol-url-handlers';
type RegisterProtocolUrlHandlersMainDeps = Parameters<typeof registerProtocolUrlHandlers>[0];
export function createBuildRegisterProtocolUrlHandlersMainDepsHandler(
deps: RegisterProtocolUrlHandlersMainDeps,
) {
return (): RegisterProtocolUrlHandlersMainDeps => ({
registerOpenUrl: (listener) => deps.registerOpenUrl(listener),
registerSecondInstance: (listener) => deps.registerSecondInstance(listener),
handleAnilistSetupProtocolUrl: (rawUrl: string) => deps.handleAnilistSetupProtocolUrl(rawUrl),
findAnilistSetupDeepLinkArgvUrl: (argv: string[]) => deps.findAnilistSetupDeepLinkArgvUrl(argv),
logUnhandledOpenUrl: (rawUrl: string) => deps.logUnhandledOpenUrl(rawUrl),
logUnhandledSecondInstanceUrl: (rawUrl: string) => deps.logUnhandledSecondInstanceUrl(rawUrl),
});
}
+9
View File
@@ -5,6 +5,7 @@ import {
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
} from '../../core/services';
import { startStatsServer, type StatsServer } from '../../core/services/stats-server';
import { createTmdbClient, createTmdbApiKeyResolver } from '../../core/services/tmdb/tmdb-client';
import { createLogger } from '../../logger';
import type { ResolvedConfig } from '../../types/config';
import type { AppState } from '../state';
@@ -46,6 +47,8 @@ export interface StatsServerRuntimeDeps {
getAnilistRateLimiter: () => NonNullable<
Parameters<typeof startStatsServer>[0]['anilistRateLimiter']
>;
/** Project TMDB key staged into release builds; null for source builds. */
getBundledTmdbApiKey?: () => string | null;
resolveAnkiNoteId: (noteId: number) => number;
trackDuplicateNoteIdsForNote: (noteId: number, duplicateNoteIds: number[]) => void;
resolveSentenceSearchHeadwords: (term: string) => Promise<string[]>;
@@ -155,6 +158,12 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
deps.getResolvedConfig().secondarySub.secondarySubLanguages,
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
anilistRateLimiter: deps.getAnilistRateLimiter(),
tmdbClient: createTmdbClient({
resolveApiKey: createTmdbApiKeyResolver(
() => deps.getResolvedConfig().tmdb,
() => deps.getBundledTmdbApiKey?.() ?? null,
),
}),
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
addYomitanNote: async (word: string) => {
+15 -7
View File
@@ -125,18 +125,21 @@ test('prerelease workflow builds and uploads all release platforms', () => {
assert.ok(executableRunLines(publish).includes('release/package-size-*.json'));
});
test('release callers pass only the declared macOS signing secrets to packaging', () => {
const secrets = [
test('release callers pass only the declared packaging secrets', () => {
const signingSecrets = [
'CSC_LINK',
'CSC_KEY_PASSWORD',
'APPLE_ID',
'APPLE_APP_SPECIFIC_PASSWORD',
'APPLE_TEAM_ID',
];
assert.deepEqual(
parsedPackageWorkflow.on?.workflow_call?.secrets,
Object.fromEntries(secrets.map((name) => [name, { required: true }])),
);
// The bundled TMDB key is optional: artifacts stay valid without it and
// users fall back to their own tmdb.apiKey.
const optionalSecrets = ['SUBMINER_TMDB_API_KEY'];
assert.deepEqual(parsedPackageWorkflow.on?.workflow_call?.secrets, {
...Object.fromEntries(signingSecrets.map((name) => [name, { required: true }])),
...Object.fromEntries(optionalSecrets.map((name) => [name, { required: false }])),
});
for (const workflow of [
parsedPrereleaseWorkflow,
readWorkflow(resolve(__dirname, '../.github/workflows/release.yml')),
@@ -144,7 +147,12 @@ test('release callers pass only the declared macOS signing secrets to packaging'
assert.equal(workflow.jobs?.package?.uses, './.github/workflows/package-release.yml');
assert.deepEqual(
workflow.jobs?.package?.secrets,
Object.fromEntries(secrets.map((name) => [name, '${{ secrets.' + name + ' }}'])),
Object.fromEntries(
[...signingSecrets, ...optionalSecrets].map((name) => [
name,
'${{ secrets.' + name + ' }}',
]),
),
);
}
});
+36 -2
View File
@@ -1,3 +1,37 @@
export const MEDIA_KINDS = ['anime', 'youtube'] as const;
/**
* Library entry classification shared by the tracker, the stats HTTP layer and
* the stats SPA. Anime entries link to AniList, live-action entries link to
* TMDB, and YouTube entries are channels grouping tracked videos.
*/
export const MEDIA_KINDS = ['anime', 'live_action', 'youtube'] as const;
export type MediaKind = (typeof MEDIA_KINDS)[number];
export const TMDB_MEDIA_TYPES = ['tv', 'movie'] as const;
export type TmdbMediaType = (typeof TMDB_MEDIA_TYPES)[number];
export function isMediaKind(value: unknown): value is MediaKind {
return typeof value === 'string' && (MEDIA_KINDS as readonly string[]).includes(value);
}
export function isTmdbMediaType(value: unknown): value is TmdbMediaType {
return typeof value === 'string' && (TMDB_MEDIA_TYPES as readonly string[]).includes(value);
}
/**
* Anime and live-action entries share one title namespace: both come from the
* filename/Jellyfin parser and an entry switches between them when it is
* relinked from AniList to TMDB or back. YouTube channels are a separate
* namespace, so a channel never combines with a series entry and a same-named
* anime and channel stay separate.
*/
export function shareTitleNamespace(a: MediaKind, b: MediaKind): boolean {
return (a === 'youtube') === (b === 'youtube');
}
/**
* SQL predicate matching rows in the same title namespace as the bound kind
* parameter (the SQL twin of `shareTitleNamespace`).
*/
export function sameTitleNamespaceSql(column = 'media_kind'): string {
return `(${column} = 'youtube') = (? = 'youtube')`;
}
+14 -1
View File
@@ -7,6 +7,9 @@ import { createLogger, setLogLevel } from './logger';
import { ImmersionTrackerService } from './core/services/immersion-tracker-service';
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
import { createLiveActionMetadataResolver } from './core/services/tmdb/live-action-resolver';
import { createTmdbClient, createTmdbApiKeyResolver } from './core/services/tmdb/tmdb-client';
import { readBundledTmdbApiKey } from './core/services/tmdb/bundled-api-key';
import { startStatsServer } from './core/services/stats-server';
import {
removeBackgroundStatsServerState,
@@ -124,6 +127,7 @@ const daemonUserDataPath = userDataPath;
const statePath = path.join(userDataPath, 'stats-daemon.json');
const knownWordCachePath = path.join(userDataPath, 'known-words-cache.json');
const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
const bundledTmdbApiKey = readBundledTmdbApiKey(__dirname);
const wordHelperScriptPath = path.join(__dirname, 'stats-word-helper.js');
let tracker: ImmersionTrackerService | null = null;
@@ -202,8 +206,16 @@ async function main(): Promise<void> {
},
},
});
const tmdbClient = createTmdbClient({
resolveApiKey: createTmdbApiKeyResolver(
() => configService.reloadConfig().tmdb,
() => bundledTmdbApiKey,
),
});
tracker.setCoverArtFetcher(
createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art')),
createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art'), {
liveAction: createLiveActionMetadataResolver(tmdbClient, createLogger('stats-daemon:tmdb')),
}),
);
statsServer = await startStatsServer({
@@ -212,6 +224,7 @@ async function main(): Promise<void> {
tracker,
knownWordCachePath,
getAnkiConnectConfig: () => configService.reloadConfig().ankiConnect,
tmdbClient,
getYomitanAnkiDeckName: async () =>
await readStatsYomitanDeckName({
helperScriptPath: wordHelperScriptPath,
+6
View File
@@ -12,6 +12,7 @@ import type {
ImmersionTrackingRetentionMode,
ImmersionTrackingRetentionPreset,
TsukihimeConfig,
TmdbConfig,
JellyfinConfig,
JimakuConfig,
JimakuLanguagePreference,
@@ -160,6 +161,7 @@ export interface Config {
/** @deprecated Use tsukihime. */
animetosho?: TsukihimeConfig;
tsukihime?: TsukihimeConfig;
tmdb?: TmdbConfig;
anilist?: AnilistConfig;
yomitan?: YomitanConfig;
jellyfin?: JellyfinConfig;
@@ -333,6 +335,10 @@ export interface ResolvedConfig {
apiBaseUrl: string;
maxSearchResults: number;
};
tmdb: {
apiKey: string;
apiKeyCommand: string;
};
anilist: {
enabled: boolean;
accessToken: string;
+6
View File
@@ -287,3 +287,9 @@ export interface TsukihimeConfig {
apiBaseUrl?: string;
maxSearchResults?: number;
}
/** TMDB (The Movie Database) access for live-action drama and movie metadata. */
export interface TmdbConfig {
apiKey?: string;
apiKeyCommand?: string;
}
+24
View File
@@ -27,6 +27,7 @@ import type {
WatchTimePerAnime,
WordDetailData,
} from './stats-wire';
import type { TmdbMediaType } from '../shared/media-kind';
export type StatsTrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
export type StatsTrendGroupBy = 'day' | 'month';
@@ -78,6 +79,25 @@ export interface StatsAnilistSearchResult {
title: { romaji: string | null; english: string | null; native: string | null } | null;
}
export interface StatsTmdbSearchResult {
tmdbId: number;
tmdbType: TmdbMediaType;
/** English title when TMDB has one, otherwise the original title. */
title: string;
originalTitle: string;
originalLanguage: string;
overview: string | null;
posterUrl: string | null;
year: number | null;
/** True when TMDB tags the title with the Animation genre. */
isAnimation: boolean;
}
export interface StatsTmdbAssignment {
tmdbId: number;
tmdbType: TmdbMediaType;
}
export interface StatsAnilistAssignment {
anilistId: number;
titleRomaji?: string | null;
@@ -209,11 +229,13 @@ export interface StatsJsonResponseMap {
moveVideoToAnime: StatsMoveVideoResponse;
dismissAnimeMergeRecommendation: StatsOkResponse;
anilistSearch: StatsAnilistSearchResult[];
tmdbSearch: StatsTmdbSearchResult[];
knownWords: string[];
knownWordsSummary: StatsKnownWordsSummary;
animeKnownWordsSummary: StatsKnownWordsSummary;
mediaKnownWordsSummary: StatsKnownWordsSummary;
reassignAnimeAnilist: StatsOkResponse;
reassignAnimeTmdb: StatsOkResponse;
coverImages: StatsCoverImagesData;
episodeDetail: EpisodeDetailData;
ankiBrowse: StatsAnkiBrowseResponse;
@@ -302,6 +324,8 @@ export interface StatsHttpClient {
getMediaKnownWordsSummary: (videoId: number) => Promise<StatsKnownWordsSummary>;
searchAnilist: (query: string) => Promise<StatsAnilistSearchResult[]>;
reassignAnimeAnilist: (animeId: number, info: StatsAnilistAssignment) => Promise<void>;
searchTmdb: (query: string) => Promise<StatsTmdbSearchResult[]>;
reassignAnimeTmdb: (animeId: number, info: StatsTmdbAssignment) => Promise<void>;
mineCard: (params: StatsMineCardParams) => Promise<StatsMineCardResponse>;
ankiBrowse: (noteId: number) => Promise<void>;
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
+6 -1
View File
@@ -1,4 +1,5 @@
import type { MediaKind } from '../shared/media-kind';
import type { MediaKind, TmdbMediaType } from '../shared/media-kind';
export interface SessionSummary {
sessionId: number;
canonicalTitle: string | null;
@@ -245,6 +246,8 @@ export interface AnimeLibraryItem {
animeId: number;
canonicalTitle: string;
anilistId: number | null;
tmdbId: number | null;
tmdbType: TmdbMediaType | null;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
@@ -267,6 +270,8 @@ export interface AnimeDetailData {
animeId: number;
canonicalTitle: string;
anilistId: number | null;
tmdbId: number | null;
tmdbType: TmdbMediaType | null;
titleRomaji: string | null;
titleEnglish: string | null;
titleNative: string | null;