mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 05:16:27 -07:00
feat(stats): add TMDB metadata for live-action dramas in the Library
Anime covers come from AniList, which has no live-action titles, so dramas and movies showed blank cards with no description and split across season folders. Library entries now carry a media kind plus a TMDB link, and the cover-art fetcher falls back to TMDB when AniList has no match, accepting only a Japanese non-animated title whose TMDB names match the parsed title exactly. Entries that resolve to the same TMDB title merge into one card regardless of season, and a Link to TMDB action in the detail view covers anything the automatic match missed. Release builds bundle a project-owned TMDB key injected from the SUBMINER_TMDB_API_KEY secret at build time; tmdb.apiKey/apiKeyCommand override it and are required when running from source. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import type { TmdbMediaType } from '../../../shared/media-kind';
|
||||
import { mergeAnimeRecords } from './anime-merge';
|
||||
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 {
|
||||
const others = findOtherTmdbHolders(db, animeId, input);
|
||||
let survivor = animeId;
|
||||
let mergedAnimeIds: number[] = [];
|
||||
if (others.length > 0) {
|
||||
if (options.mode === 'manual') {
|
||||
mergedAnimeIds = mergeAnimeRecords(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 = mergeAnimeRecords(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,
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user