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:
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user