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:
2026-09-18 23:31:35 -07:00
co-authored by Claude Fable 5.1
parent dd76782d30
commit 2b33d32879
68 changed files with 2339 additions and 68 deletions
+24
View File
@@ -27,6 +27,7 @@ import type {
WatchTimePerAnime,
WordDetailData,
} from './stats-wire';
import type { TmdbMediaType } from '../shared/media-kind';
export type StatsTrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
export type StatsTrendGroupBy = 'day' | 'month';
@@ -78,6 +79,25 @@ export interface StatsAnilistSearchResult {
title: { romaji: string | null; english: string | null; native: string | null } | null;
}
export interface StatsTmdbSearchResult {
tmdbId: number;
tmdbType: TmdbMediaType;
/** English title when TMDB has one, otherwise the original title. */
title: string;
originalTitle: string;
originalLanguage: string;
overview: string | null;
posterUrl: string | null;
year: number | null;
/** True when TMDB tags the title with the Animation genre. */
isAnimation: boolean;
}
export interface StatsTmdbAssignment {
tmdbId: number;
tmdbType: TmdbMediaType;
}
export interface StatsAnilistAssignment {
anilistId: number;
titleRomaji?: string | null;
@@ -209,11 +229,13 @@ export interface StatsJsonResponseMap {
moveVideoToAnime: StatsMoveVideoResponse;
dismissAnimeMergeRecommendation: StatsOkResponse;
anilistSearch: StatsAnilistSearchResult[];
tmdbSearch: StatsTmdbSearchResult[];
knownWords: string[];
knownWordsSummary: StatsKnownWordsSummary;
animeKnownWordsSummary: StatsKnownWordsSummary;
mediaKnownWordsSummary: StatsKnownWordsSummary;
reassignAnimeAnilist: StatsOkResponse;
reassignAnimeTmdb: StatsOkResponse;
coverImages: StatsCoverImagesData;
episodeDetail: EpisodeDetailData;
ankiBrowse: StatsAnkiBrowseResponse;
@@ -302,6 +324,8 @@ export interface StatsHttpClient {
getMediaKnownWordsSummary: (videoId: number) => Promise<StatsKnownWordsSummary>;
searchAnilist: (query: string) => Promise<StatsAnilistSearchResult[]>;
reassignAnimeAnilist: (animeId: number, info: StatsAnilistAssignment) => Promise<void>;
searchTmdb: (query: string) => Promise<StatsTmdbSearchResult[]>;
reassignAnimeTmdb: (animeId: number, info: StatsTmdbAssignment) => Promise<void>;
mineCard: (params: StatsMineCardParams) => Promise<StatsMineCardResponse>;
ankiBrowse: (noteId: number) => Promise<void>;
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;