mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-27 11:35:23 -07:00
feat(stats): add TMDB metadata for live-action dramas in the Library (#252)
This commit is contained in:
1 parent
4dd30f44d1
commit
1508863dbb
76 files changed
+3595
-238
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user