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
+38 -4
View File
@@ -6,6 +6,7 @@ interface AnimeHeaderProps {
anilistEntries: AnilistEntry[];
coverRetryToken?: number;
onChangeAnilist?: () => void;
onChangeTmdb?: () => void;
onDeleteAnime?: () => void;
isDeletingAnime?: boolean;
}
@@ -34,6 +35,7 @@ export function AnimeHeader({
anilistEntries,
coverRetryToken = 0,
onChangeAnilist,
onChangeTmdb,
onDeleteAnime,
isDeletingAnime = false,
}: AnimeHeaderProps) {
@@ -43,7 +45,12 @@ export function AnimeHeader({
const uniqueAltTitles = [...new Set(altTitles)];
const hasMultipleEntries = anilistEntries.length > 1;
const coverCacheToken = (detail.anilistId ?? 0) * 1_000_000 + coverRetryToken;
const isLiveAction = detail.mediaKind === 'live_action';
const tmdbUrl =
detail.tmdbId && detail.tmdbType
? `https://www.themoviedb.org/${detail.tmdbType}/${detail.tmdbId}`
: null;
const coverCacheToken = (detail.anilistId ?? detail.tmdbId ?? 0) * 1_000_000 + coverRetryToken;
return (
<div className="flex gap-4">
@@ -60,11 +67,28 @@ export function AnimeHeader({
{uniqueAltTitles.join(' · ')}
</div>
)}
<div className="text-sm text-ctp-subtext0 mt-2">
{detail.episodeCount} episode{detail.episodeCount !== 1 ? 's' : ''}
<div className="text-sm text-ctp-subtext0 mt-2 flex items-center gap-2">
<span>
{detail.episodeCount} episode{detail.episodeCount !== 1 ? 's' : ''}
</span>
{isLiveAction && (
<span className="px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wide bg-ctp-peach/15 text-ctp-peach">
{detail.tmdbType === 'movie' ? 'Movie' : 'Live action'}
</span>
)}
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{anilistEntries.length > 0 ? (
{tmdbUrl && (
<a
href={tmdbUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
>
View on TMDB <span className="text-[10px]">{'\u2197'}</span>
</a>
)}
{isLiveAction ? null : anilistEntries.length > 0 ? (
hasMultipleEntries ? (
anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />)
) : (
@@ -99,6 +123,16 @@ export function AnimeHeader({
: 'Link to AniList'}
</button>
)}
{onChangeTmdb && (
<button
type="button"
onClick={onChangeTmdb}
title="Search TMDB and link this title to a live-action drama or movie"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-overlay2 hover:bg-ctp-surface2 hover:text-ctp-subtext0 transition-colors"
>
{tmdbUrl ? 'Change TMDB Title' : 'Link to TMDB'}
</button>
)}
{onDeleteAnime && (
<button
type="button"