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
@@ -10,6 +10,9 @@ test('AnimeCard includes linked AniList id in cover URLs to avoid stale library
animeId: 42,
canonicalTitle: 'Test Anime',
anilistId: 21699,
mediaKind: 'anime',
tmdbId: null,
tmdbType: null,
totalSessions: 1,
totalActiveMs: 600_000,
totalCards: 0,
+1 -1
View File
@@ -29,7 +29,7 @@ export function AnimeCard({
<AnimeCoverImage
animeId={anime.animeId}
title={anime.canonicalTitle}
coverRetryToken={anime.anilistId ?? 0}
coverRetryToken={anime.anilistId ?? anime.tmdbId ?? 0}
className="w-full aspect-[3/4] rounded-t-lg transition-transform duration-200 group-hover:scale-105"
/>
{selectable && (
@@ -19,6 +19,9 @@ test('AnimeHeader uses the linked AniList id to avoid stale cached cover art', (
animeId: 42,
canonicalTitle: 'Test Anime',
anilistId: 21699,
mediaKind: 'anime',
tmdbId: null,
tmdbType: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
@@ -7,6 +7,7 @@ import { AnimeHeader } from './AnimeHeader';
import { EpisodeList } from './EpisodeList';
import { AnimeWordList } from './AnimeWordList';
import { AnilistSelector } from './AnilistSelector';
import { TmdbSelector } from './TmdbSelector';
import { AnimeOverviewStats } from './AnimeOverviewStats';
import { CHART_THEME } from '../../lib/chart-theme';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
@@ -156,6 +157,7 @@ export function AnimeDetailView({
}: AnimeDetailViewProps) {
const { data, loading, error, reload } = useAnimeDetail(animeId);
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
const [showTmdbSelector, setShowTmdbSelector] = useState(false);
const [coverRetryToken, setCoverRetryToken] = useState(0);
const [isDeletingAnime, setIsDeletingAnime] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -219,6 +221,7 @@ export function AnimeDetailView({
anilistEntries={anilistEntries ?? []}
coverRetryToken={coverRetryToken}
onChangeAnilist={() => setShowAnilistSelector(true)}
onChangeTmdb={() => setShowTmdbSelector(true)}
onDeleteAnime={() => void handleDeleteAnime()}
isDeletingAnime={isDeletingAnime}
/>
@@ -250,6 +253,19 @@ export function AnimeDetailView({
}}
/>
)}
{showTmdbSelector && (
<TmdbSelector
animeId={animeId}
initialQuery={detail.canonicalTitle}
onClose={() => setShowTmdbSelector(false)}
onLinked={() => {
setShowTmdbSelector(false);
setCoverRetryToken((value) => value + 1);
reload();
onAnilistRelinked?.();
}}
/>
)}
</div>
);
}
@@ -49,6 +49,9 @@ function libraryItem(animeId: number, title: string): AnimeLibraryItem {
animeId,
canonicalTitle: title,
anilistId: null,
mediaKind: 'anime',
tmdbId: null,
tmdbType: null,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 0,
@@ -9,6 +9,9 @@ const DETAIL: AnimeDetailData['detail'] = {
animeId: 3,
canonicalTitle: 'Project Radio Noise Season 2',
anilistId: 20661,
mediaKind: 'anime',
tmdbId: null,
tmdbType: null,
titleRomaji: 'Toaru Kagaku no Railgun S',
titleEnglish: 'A Certain Scientific Railgun S',
titleNative: null,
@@ -69,3 +72,43 @@ test('confirmAnimeDelete spells out how much data the entry deletion removes', a
assert.match(seen[1] ?? '', /3 episodes/);
assert.match(seen[0] ?? '', /every session and stat/);
});
test('AnimeHeader shows TMDB actions for a live-action entry and hides AniList links', () => {
const markup = renderToStaticMarkup(
<AnimeHeader
detail={{
...DETAIL,
anilistId: null,
mediaKind: 'live_action',
tmdbId: 61222,
tmdbType: 'tv',
titleRomaji: null,
titleEnglish: 'Hanzawa Naoki',
titleNative: '半沢直樹',
description: 'A banker fights back.',
}}
anilistEntries={[]}
onChangeAnilist={() => {}}
onChangeTmdb={() => {}}
/>,
);
assert.match(markup, /https:\/\/www\.themoviedb\.org\/tv\/61222/);
assert.match(markup, /Change TMDB Title/);
assert.match(markup, /Live action/);
assert.match(markup, /A banker fights back\./);
assert.doesNotMatch(markup, /anilist\.co/);
});
test('AnimeHeader offers to link an unlinked anime entry to TMDB', () => {
const markup = renderToStaticMarkup(
<AnimeHeader
detail={{ ...DETAIL, anilistId: null }}
anilistEntries={[]}
onChangeTmdb={() => {}}
/>,
);
assert.match(markup, /Link to TMDB/);
assert.doesNotMatch(markup, /themoviedb\.org/);
});
+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"
@@ -120,7 +120,7 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
<AnimeCoverImage
animeId={entry.animeId}
title={entry.canonicalTitle}
coverRetryToken={entry.anilistId ?? 0}
coverRetryToken={entry.anilistId ?? entry.tmdbId ?? 0}
className="w-10 h-14 rounded shrink-0"
/>
<div className="min-w-0 flex-1">
@@ -48,6 +48,9 @@ function libraryItem(animeId: number, title: string, episodeCount: number): Anim
animeId,
canonicalTitle: title,
anilistId: null,
mediaKind: 'anime',
tmdbId: null,
tmdbType: null,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 1,
@@ -48,6 +48,9 @@ function libraryItem(anilistId: number | null): AnimeLibraryItem {
animeId: 7,
canonicalTitle: 'Test Anime Season 2',
anilistId,
mediaKind: 'anime',
tmdbId: null,
tmdbType: null,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 0,
@@ -64,6 +67,9 @@ function detailData(anilistId: number | null): AnimeDetailData {
animeId: 7,
canonicalTitle: 'Test Anime Season 2',
anilistId,
mediaKind: 'anime',
tmdbId: null,
tmdbType: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
+29 -3
View File
@@ -13,6 +13,7 @@ import { AnimeMergeDialog } from './AnimeMergeDialog';
import { DuplicateReviewStrip } from './DuplicateReviewStrip';
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
type KindFilter = 'all' | 'anime' | 'live_action';
const GRID_CLASSES: Record<LibraryCardSize, string> = {
sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11',
@@ -20,6 +21,12 @@ const GRID_CLASSES: Record<LibraryCardSize, string> = {
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
};
const KIND_OPTIONS: { key: KindFilter; label: string }[] = [
{ key: 'all', label: 'All Titles' },
{ key: 'anime', label: 'Anime' },
{ key: 'live_action', label: 'Live Action' },
];
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
{ key: 'lastWatched', label: 'Last Watched' },
{ key: 'watchTime', label: 'Watch Time' },
@@ -68,6 +75,7 @@ export function AnimeTab({
} = useAnimeLibrary();
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
const [kindFilter, setKindFilter] = useState<KindFilter>('all');
const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
readLibraryCardSizePreference(
getLibraryCardSizeStorage(typeof window === 'undefined' ? null : window),
@@ -108,11 +116,12 @@ export function AnimeTab({
}, [initialAnimeId, onClearInitialAnime]);
const filtered = useMemo(() => {
const byKind = kindFilter === 'all' ? anime : anime.filter((a) => a.mediaKind === kindFilter);
const base = search.trim()
? anime.filter((a) => a.canonicalTitle.toLowerCase().includes(search.toLowerCase()))
: anime;
? byKind.filter((a) => a.canonicalTitle.toLowerCase().includes(search.toLowerCase()))
: byKind;
return sortAnime(base, sortKey);
}, [anime, search, sortKey]);
}, [anime, search, sortKey, kindFilter]);
const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0);
const checkedEntries = checkedAnimeIds
@@ -163,6 +172,18 @@ export function AnimeTab({
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
<select
value={kindFilter}
onChange={(e) => setKindFilter(e.target.value as KindFilter)}
aria-label="Filter by media kind"
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-2 py-2 text-sm text-ctp-text focus:outline-none focus:border-ctp-blue"
>
{KIND_OPTIONS.map((opt) => (
<option key={opt.key} value={opt.key}>
{opt.label}
</option>
))}
</select>
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value as SortKey)}
@@ -258,6 +279,11 @@ export function AnimeTab({
</div>
)}
<p className="text-[11px] text-ctp-overlay2 pt-2">
Cover art and synopses come from AniList and TMDB. This product uses the TMDB API but is not
endorsed or certified by TMDB.
</p>
{showMergeDialog && mergeEntries.length >= 2 && (
<AnimeMergeDialog
entries={mergeEntries}
@@ -66,6 +66,9 @@ function libraryItem(animeId: number, title: string): AnimeLibraryItem {
animeId,
canonicalTitle: title,
anilistId: null,
mediaKind: 'anime',
tmdbId: null,
tmdbType: null,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 0,
@@ -144,7 +144,7 @@ export function LibraryEntryPicker({
<AnimeCoverImage
animeId={entry.animeId}
title={entry.canonicalTitle}
coverRetryToken={entry.anilistId ?? 0}
coverRetryToken={entry.anilistId ?? entry.tmdbId ?? 0}
className="w-10 h-14 rounded shrink-0"
/>
<div className="min-w-0 flex-1">
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import type { StatsTmdbSearchResult } from '../../types/stats';
import { TmdbSelector } from './TmdbSelector';
interface TestWindow extends Window {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
function installDom(): () => void {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousHTMLElement = globalThis.HTMLElement;
const previousISReactActEnvironment = (
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT;
const window = new Window() as TestWindow;
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
return () => {
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
Object.defineProperty(globalThis, 'document', {
value: previousDocument,
configurable: true,
});
Object.defineProperty(globalThis, 'HTMLElement', {
value: previousHTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = previousISReactActEnvironment;
};
}
const HANZAWA: StatsTmdbSearchResult = {
tmdbId: 61222,
tmdbType: 'tv',
title: 'Hanzawa Naoki',
originalTitle: '半沢直樹',
originalLanguage: 'ja',
overview: null,
posterUrl: null,
year: 2013,
isAnimation: false,
};
test('TmdbSelector searches the normalized title and links the picked result by id', async () => {
const uninstallDom = installDom();
const original = {
searchTmdb: apiClient.searchTmdb,
reassignAnimeTmdb: apiClient.reassignAnimeTmdb,
};
const searchCalls: string[] = [];
const linkCalls: Array<[number, { tmdbId: number; tmdbType: string }]> = [];
let linked = 0;
apiClient.searchTmdb = (async (query: string) => {
searchCalls.push(query);
return [HANZAWA];
}) as typeof apiClient.searchTmdb;
apiClient.reassignAnimeTmdb = (async (animeId: number, info) => {
linkCalls.push([animeId, info]);
}) as typeof apiClient.reassignAnimeTmdb;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
<TmdbSelector
animeId={9}
initialQuery="Hanzawa Naoki Season 2"
onClose={() => {}}
onLinked={() => {
linked += 1;
}}
/>,
);
});
assert.deepEqual(searchCalls, ['Hanzawa Naoki']);
assert.match(container.textContent ?? '', /半沢直樹/);
assert.match(container.textContent ?? '', /TV · 2013/);
const pick = [...container.querySelectorAll('button')].find((button) =>
/Select/.test(button.textContent ?? ''),
);
assert.ok(pick);
await act(async () => {
pick.click();
});
assert.deepEqual(linkCalls, [[9, { tmdbId: 61222, tmdbType: 'tv' }]]);
assert.equal(linked, 1);
await act(async () => {
root.unmount();
});
} finally {
apiClient.searchTmdb = original.searchTmdb;
apiClient.reassignAnimeTmdb = original.reassignAnimeTmdb;
uninstallDom();
}
});
test('TmdbSelector explains a missing API key instead of showing "No results"', async () => {
const uninstallDom = installDom();
const originalSearch = apiClient.searchTmdb;
apiClient.searchTmdb = (async () => {
throw new Error('Stats API error: 503 {"error":"TMDB API key not configured."}');
}) as typeof apiClient.searchTmdb;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
<TmdbSelector
animeId={1}
initialQuery="Hanzawa Naoki"
onClose={() => {}}
onLinked={() => {}}
/>,
);
});
assert.match(container.textContent ?? '', /tmdb\.apiKey/);
assert.doesNotMatch(container.textContent ?? '', /No results/);
await act(async () => {
root.unmount();
});
} finally {
apiClient.searchTmdb = originalSearch;
uninstallDom();
}
});
+154
View File
@@ -0,0 +1,154 @@
import { useState, useEffect, useRef } from 'react';
import { apiClient } from '../../lib/api-client';
import { normalizeAnilistSearchQuery } from '../../lib/anilist-search-query';
import type { StatsTmdbSearchResult } from '../../types/stats';
interface TmdbSelectorProps {
animeId: number;
initialQuery: string;
onClose: () => void;
onLinked: () => void;
}
// The stats API answers a missing key with 503 and the message from config.
function describeSearchError(err: unknown): string {
const message = err instanceof Error ? err.message : '';
if (/\b503\b/.test(message)) {
return 'TMDB API key not configured. Set tmdb.apiKey or tmdb.apiKeyCommand in your config.';
}
return 'TMDB search failed. Check your connection and API key.';
}
export function TmdbSelector({ animeId, initialQuery, onClose, onLinked }: TmdbSelectorProps) {
const [query, setQuery] = useState(() => normalizeAnilistSearchQuery(initialQuery));
const [results, setResults] = useState<StatsTmdbSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [linking, setLinking] = useState<number | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
inputRef.current?.focus();
const normalizedInitialQuery = normalizeAnilistSearchQuery(initialQuery);
setQuery(normalizedInitialQuery);
setResults([]);
setError(null);
setLoading(false);
setLinking(null);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (normalizedInitialQuery) void doSearch(normalizedInitialQuery);
}, [initialQuery, animeId]);
const doSearch = async (q: string) => {
const searchQuery = normalizeAnilistSearchQuery(q);
if (!searchQuery) {
setResults([]);
return;
}
setLoading(true);
setError(null);
try {
setResults(await apiClient.searchTmdb(searchQuery));
} catch (err) {
setResults([]);
setError(describeSearchError(err));
}
setLoading(false);
};
const handleInput = (value: string) => {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => void doSearch(value), 400);
};
const handleSelect = async (media: StatsTmdbSearchResult) => {
setLinking(media.tmdbId);
setError(null);
try {
await apiClient.reassignAnimeTmdb(animeId, {
tmdbId: media.tmdbId,
tmdbType: media.tmdbType,
});
onLinked();
} catch (err) {
setError(describeSearchError(err));
setLinking(null);
}
};
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" onClick={onClose}>
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
<div
className="relative bg-ctp-base border border-ctp-surface1 rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] flex flex-col animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 border-b border-ctp-surface1">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">Select TMDB Title</h3>
<button
type="button"
onClick={onClose}
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
>
{'✕'}
</button>
</div>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleInput(e.target.value)}
placeholder="Search TMDB for a drama or movie..."
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
</div>
<div className="flex-1 overflow-y-auto p-2">
{loading && <div className="text-xs text-ctp-overlay2 p-3">Searching...</div>}
{error && <div className="text-xs text-ctp-red p-3">{error}</div>}
{!loading && !error && results.length === 0 && query.trim() && (
<div className="text-xs text-ctp-overlay2 p-3">No results</div>
)}
{results.map((media) => (
<button
key={`${media.tmdbType}-${media.tmdbId}`}
type="button"
disabled={linking !== null}
onClick={() => void handleSelect(media)}
className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-ctp-surface0 transition-colors text-left disabled:opacity-50"
>
{media.posterUrl ? (
<img
src={media.posterUrl}
alt=""
className="w-10 h-14 rounded object-cover shrink-0 bg-ctp-surface1"
/>
) : (
<div className="w-10 h-14 rounded bg-ctp-surface1 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm text-ctp-text truncate">{media.title}</div>
{media.originalTitle !== media.title && (
<div className="text-xs text-ctp-subtext0 truncate">{media.originalTitle}</div>
)}
<div className="text-xs text-ctp-overlay2 mt-0.5">
{media.tmdbType === 'movie' ? 'Movie' : 'TV'}
{media.year ? ` · ${media.year}` : ''}
{media.isAnimation ? ' · Animation' : ''}
</div>
</div>
{linking === media.tmdbId ? (
<span className="text-xs text-ctp-blue shrink-0">Linking...</span>
) : (
<span className="text-xs text-ctp-overlay2 shrink-0">Select</span>
)}
</button>
))}
</div>
</div>
</div>
);
}
+10
View File
@@ -15,6 +15,7 @@ import type {
StatsMergeAnimeResponse,
StatsMoveVideoRequest,
StatsMoveVideoResponse,
StatsTmdbAssignment,
StatsTrendGroupBy,
StatsTrendRange,
StatsVideoWatchedRequest,
@@ -256,6 +257,15 @@ export const apiClient = {
body: JSON.stringify(info),
});
},
searchTmdb: (query: string) =>
fetchJson('tmdbSearch', `/api/stats/tmdb/search?q=${encodeURIComponent(query)}`),
reassignAnimeTmdb: async (animeId: number, info: StatsTmdbAssignment): Promise<void> => {
await fetchResponse(`/api/stats/anime/${animeId}/tmdb`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(info satisfies StatsTmdbAssignment),
});
},
mineCard: async (params: StatsMineCardParams): Promise<StatsMineCardResponse> => {
const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, {
method: 'POST',
+3
View File
@@ -117,6 +117,9 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
animeId: 1,
canonicalTitle: 'Anime',
anilistId: null,
mediaKind: 'anime',
tmdbId: null,
tmdbType: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,