mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
Merge remote-tracking branch 'origin/main' into t3code/update-jellyfin-authentication
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user