feat(stats): add TMDB metadata for live-action dramas in the Library (#252)

This commit is contained in:
2026-09-21 00:10:00 -07:00
committed by GitHub
parent 4dd30f44d1
commit 1508863dbb
76 changed files with 3595 additions and 238 deletions
@@ -11,6 +11,8 @@ test('AnimeCard includes linked AniList id in cover URLs to avoid stale library
animeId: 42,
canonicalTitle: 'Test Anime',
anilistId: 21699,
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 && (
@@ -20,6 +20,8 @@ test('AnimeHeader uses the linked AniList id to avoid stale cached cover art', (
animeId: 42,
canonicalTitle: 'Test Anime',
anilistId: 21699,
tmdbId: null,
tmdbType: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
+23 -7
View File
@@ -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';
@@ -20,11 +21,11 @@ interface AnimeDetailViewProps {
/** Called after the whole library entry is deleted, so the caller can refresh. */
onAnimeDeleted?: () => void;
/**
* Called after the AniList link changes. The library list caches the old
* anilistId (and with it the cover URL), so it has to refetch or the grid
* keeps showing the previous title's art.
* Called after the AniList or TMDB link changes. The library list caches the
* old provider ids (and with them the cover URL and media kind), so it has to
* refetch or the grid keeps showing the previous title's art.
*/
onAnilistRelinked?: () => void;
onProviderRelinked?: () => void;
/** Called after an episode is reassigned to another entry. */
onEpisodeMoved?: () => void;
}
@@ -151,11 +152,12 @@ export function AnimeDetailView({
onNavigateToWord,
onOpenEpisodeDetail,
onAnimeDeleted,
onAnilistRelinked,
onProviderRelinked,
onEpisodeMoved,
}: 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}
/>
@@ -238,7 +241,7 @@ export function AnimeDetailView({
/>
<AnimeWatchChart animeId={animeId} />
<AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} />
{detail.mediaKind === 'anime' && showAnilistSelector && (
{detail.mediaKind !== 'youtube' && showAnilistSelector && (
<AnilistSelector
animeId={animeId}
initialQuery={detail.canonicalTitle}
@@ -247,7 +250,20 @@ export function AnimeDetailView({
setShowAnilistSelector(false);
setCoverRetryToken((value) => value + 1);
reload();
onAnilistRelinked?.();
onProviderRelinked?.();
}}
/>
)}
{showTmdbSelector && (
<TmdbSelector
animeId={animeId}
initialQuery={detail.canonicalTitle}
onClose={() => setShowTmdbSelector(false)}
onLinked={() => {
setShowTmdbSelector(false);
setCoverRetryToken((value) => value + 1);
reload();
onProviderRelinked?.();
}}
/>
)}
@@ -50,6 +50,8 @@ function libraryItem(animeId: number, title: string): AnimeLibraryItem {
animeId,
canonicalTitle: title,
anilistId: null,
tmdbId: null,
tmdbType: null,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 0,
@@ -10,6 +10,8 @@ const DETAIL: AnimeDetailData['detail'] = {
animeId: 3,
canonicalTitle: 'Project Radio Noise Season 2',
anilistId: 20661,
tmdbId: null,
tmdbType: null,
titleRomaji: 'Toaru Kagaku no Railgun S',
titleEnglish: 'A Certain Scientific Railgun S',
titleNative: null,
@@ -71,6 +73,46 @@ test('confirmAnimeDelete spells out how much data the entry deletion removes', a
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/);
});
test('YouTube channel headers show videos and omit all AniList controls', () => {
const markup = renderToStaticMarkup(
<AnimeHeader
+40 -5
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) {
@@ -44,7 +46,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">
@@ -61,13 +68,31 @@ export function AnimeHeader({
{uniqueAltTitles.join(' · ')}
</div>
)}
<div className="text-sm text-ctp-subtext0 mt-2">
{isYoutube ? 'YouTube channel · ' : ''}
{detail.episodeCount} {isYoutube ? 'video' : 'episode'}
{detail.episodeCount !== 1 ? 's' : ''}
<div className="text-sm text-ctp-subtext0 mt-2 flex items-center gap-2">
<span>
{isYoutube ? 'YouTube channel · ' : ''}
{detail.episodeCount} {isYoutube ? 'video' : '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">
{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>
)}
{!isYoutube &&
!isLiveAction &&
(anilistEntries.length > 0 ? (
hasMultipleEntries ? (
anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />)
@@ -103,6 +128,16 @@ export function AnimeHeader({
: 'Link to AniList'}
</button>
)}
{!isYoutube && 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"
@@ -11,6 +11,27 @@ interface AnimeMergeDialogProps {
onMerged: (survivingAnimeId: number) => void;
}
const PROVIDER_CONFLICT_MESSAGE =
'AniList-linked and TMDB-linked entries cannot be merged together. Relink one of them first.';
const KIND_CONFLICT_MESSAGE =
'YouTube channels cannot be merged with anime or live-action entries.';
/** Merging an AniList entry with a TMDB entry is rejected by the server (409), as is mixing in a channel. */
function hasProviderConflict(entries: AnimeLibraryItem[]): boolean {
return (
entries.some((entry) => entry.anilistId !== null) &&
entries.some((entry) => entry.tmdbId !== null)
);
}
function describeMergeError(err: unknown, entries: AnimeLibraryItem[]): string {
const message = err instanceof Error ? err.message : '';
if (/^Stats API error: 409\b/.test(message)) {
return hasProviderConflict(entries) ? PROVIDER_CONFLICT_MESSAGE : KIND_CONFLICT_MESSAGE;
}
return message || 'Failed to merge these entries.';
}
/** Biggest entry first: the one most likely to carry the right title and art. */
function pickDefaultKeeper(entries: AnimeLibraryItem[]): number {
const best = [...entries].sort(
@@ -26,6 +47,7 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
const [keeperId, setKeeperId] = useState(() => pickDefaultKeeper(entries));
const [merging, setMerging] = useState(false);
const [error, setError] = useState<string | null>(null);
const providerConflict = hasProviderConflict(entries);
const totalEpisodes = entries.reduce((sum, entry) => sum + entry.episodeCount, 0);
const totalCards = entries.reduce((sum, entry) => sum + entry.totalCards, 0);
@@ -42,14 +64,14 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
const sourceAnimeIds = entries
.map((entry) => entry.animeId)
.filter((animeId) => animeId !== keeperId);
if (sourceAnimeIds.length === 0) return;
if (sourceAnimeIds.length === 0 || providerConflict) return;
setMerging(true);
setError(null);
try {
const result = await apiClient.mergeAnime(keeperId, sourceAnimeIds);
onMerged(result.animeId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to merge these entries.');
setError(describeMergeError(err, entries));
setMerging(false);
}
};
@@ -120,7 +142,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">
@@ -138,7 +160,11 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
</div>
<div className="p-4 border-t border-ctp-surface1 space-y-2">
{error ? (
{providerConflict ? (
<div role="alert" className="text-xs text-ctp-peach">
{PROVIDER_CONFLICT_MESSAGE}
</div>
) : error ? (
<div role="alert" className="text-xs text-ctp-red">
{error}
</div>
@@ -150,7 +176,7 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
</div>
<button
type="button"
disabled={merging}
disabled={merging || providerConflict}
onClick={() => void handleMerge()}
className="px-3 py-1.5 rounded-lg bg-ctp-blue/15 border border-ctp-blue/40 text-xs text-ctp-blue hover:bg-ctp-blue/25 transition-colors disabled:opacity-50"
>
@@ -5,6 +5,7 @@ import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import type { AnimeLibraryItem, StatsMergeAnimeResponse } from '../../types/stats';
import { AnimeMergeDialog } from './AnimeMergeDialog';
import { AnimeTab } from './AnimeTab';
interface TestWindow extends Window {
@@ -49,6 +50,8 @@ function libraryItem(animeId: number, title: string, episodeCount: number): Anim
animeId,
canonicalTitle: title,
anilistId: null,
tmdbId: null,
tmdbType: null,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 1,
@@ -165,6 +168,52 @@ test('AnimeTab merges the selected duplicate entries into the chosen keeper', as
}
});
test('AnimeMergeDialog refuses to merge an AniList entry with a TMDB entry', async () => {
const uninstallDom = installDom();
const originalMerge = apiClient.mergeAnime;
let mergeCalls = 0;
apiClient.mergeAnime = (async () => {
mergeCalls += 1;
throw new Error('unexpected merge');
}) as typeof apiClient.mergeAnime;
const anime = { ...libraryItem(1, 'Show', 2), anilistId: 100 };
const drama = {
...libraryItem(2, 'Show', 1),
mediaKind: 'live_action' as const,
tmdbId: 200,
tmdbType: 'tv' as const,
};
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
<AnimeMergeDialog entries={[anime, drama]} onClose={() => {}} onMerged={() => {}} />,
);
});
const merge = findButton(container, 'Merge Entries') as HTMLButtonElement;
assert.equal(merge.disabled, true);
assert.match(container.textContent ?? '', /cannot be merged together/);
await act(async () => {
merge.click();
});
assert.equal(mergeCalls, 0);
await act(async () => {
root.unmount();
});
} finally {
apiClient.mergeAnime = originalMerge;
uninstallDom();
}
});
test('AnimeTab keeps a suggested duplicate visible until it is reviewed and merged', async () => {
const uninstallDom = installDom();
const original = {
@@ -49,6 +49,8 @@ function libraryItem(anilistId: number | null): AnimeLibraryItem {
animeId: 7,
canonicalTitle: 'Test Anime Season 2',
anilistId,
tmdbId: null,
tmdbType: null,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 0,
@@ -66,6 +68,8 @@ function detailData(anilistId: number | null): AnimeDetailData {
animeId: 7,
canonicalTitle: 'Test Anime Season 2',
anilistId,
tmdbId: null,
tmdbType: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
@@ -180,6 +184,43 @@ test('AnimeTab refetches the library after the AniList entry is relinked', async
}
});
test('AnimeTab watch time follows the displayed kind filter', async () => {
const uninstallDom = installDom();
const original = apiClient.getAnimeLibrary;
apiClient.getAnimeLibrary = async () => [
{ ...libraryItem(42), totalActiveMs: 3600000 },
{
...libraryItem(null),
animeId: 8,
canonicalTitle: 'Drama',
mediaKind: 'live_action',
tmdbId: 12,
tmdbType: 'tv',
totalActiveMs: 7200000,
},
];
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
try {
await act(async () => {
root.render(<AnimeTab />);
});
assert.match(container.textContent ?? '', /2 titles · 3h/);
await act(async () => {
findButton(container, 'Live Action').click();
});
assert.match(container.textContent ?? '', /1 title · 2h/);
assert.equal(findButton(container, 'Live Action').getAttribute('aria-pressed'), 'true');
} finally {
await act(async () => {
root.unmount();
});
apiClient.getAnimeLibrary = original;
uninstallDom();
}
});
test('Library kind filter separates YouTube channels from anime and updates totals', async () => {
const uninstallDom = installDom();
const original = {
+25 -5
View File
@@ -1,4 +1,8 @@
import { MEDIA_KINDS, type MediaKind } from '../../../../src/shared/media-kind';
import {
MEDIA_KINDS,
shareTitleNamespace,
type MediaKind,
} from '../../../../src/shared/media-kind';
import { useState, useMemo, useEffect } from 'react';
import { useAnimeLibrary } from '../../hooks/useAnimeLibrary';
import { formatDuration } from '../../lib/formatters';
@@ -21,6 +25,13 @@ const GRID_CLASSES: Record<LibraryCardSize, string> = {
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
};
const KIND_LABELS: Record<MediaKind | 'all', string> = {
all: 'All Titles',
anime: 'Anime',
live_action: 'Live Action',
youtube: 'YouTube',
};
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
{ key: 'lastWatched', label: 'Last Watched' },
{ key: 'watchTime', label: 'Watch Time' },
@@ -121,7 +132,11 @@ export function AnimeTab({
const checkedEntries = checkedAnimeIds
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
.filter((entry): entry is (typeof anime)[number] => entry !== undefined);
const mixedKindsChecked = new Set(checkedEntries.map((entry) => entry.mediaKind)).size > 1;
// Anime and live-action entries may be combined (the server only rejects
// conflicting AniList/TMDB links); channels never mix with either.
const mixedKindsChecked = checkedEntries.some(
(entry) => !shareTitleNamespace(entry.mediaKind, checkedEntries[0]!.mediaKind),
);
const hydratedRecommendations = recommendations
.map((recommendation) => ({
...recommendation,
@@ -153,7 +168,7 @@ export function AnimeTab({
: undefined
}
onAnimeDeleted={reload}
onAnilistRelinked={reload}
onProviderRelinked={reload}
onEpisodeMoved={reload}
/>
);
@@ -185,7 +200,7 @@ export function AnimeTab({
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{kind === 'all' ? 'All Titles' : kind === 'youtube' ? 'YouTube' : 'Anime'}
{KIND_LABELS[kind]}
</button>
))}
</div>
@@ -269,7 +284,7 @@ export function AnimeTab({
{checkedEntries.length === 0
? 'Pick the duplicate entries to combine'
: mixedKindsChecked
? 'Anime and YouTube channels cannot be combined'
? 'YouTube channels cannot be combined with other titles'
: `${checkedEntries.length} selected`}
</div>
<button
@@ -301,6 +316,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}
@@ -67,6 +67,8 @@ function libraryItem(animeId: number, title: string): AnimeLibraryItem {
animeId,
canonicalTitle: title,
anilistId: null,
tmdbId: null,
tmdbType: null,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 0,
@@ -4,7 +4,7 @@ import { formatDuration } from '../../lib/formatters';
import { useModalFocus } from '../../hooks/useModalFocus';
import { AnimeCoverImage } from './AnimeCoverImage';
import type { AnimeLibraryItem } from '../../types/stats';
import type { MediaKind } from '../../../../src/shared/media-kind';
import { shareTitleNamespace, type MediaKind } from '../../../../src/shared/media-kind';
interface LibraryEntryPickerProps {
heading: string;
@@ -73,7 +73,7 @@ export function LibraryEntryPicker({
const term = query.trim().toLowerCase();
return (entries ?? [])
.filter((entry) => !excluded.has(entry.animeId))
.filter((entry) => mediaKind === undefined || entry.mediaKind === mediaKind)
.filter((entry) => mediaKind === undefined || shareTitleNamespace(entry.mediaKind, mediaKind))
.filter((entry) => !term || entry.canonicalTitle.toLowerCase().includes(term))
.sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
}, [entries, excluded, mediaKind, query]);
@@ -149,7 +149,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,387 @@
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();
}
});
test('TmdbSelector reports a failed link as a link problem, not a search failure', async () => {
const uninstallDom = installDom();
const original = {
searchTmdb: apiClient.searchTmdb,
reassignAnimeTmdb: apiClient.reassignAnimeTmdb,
};
apiClient.searchTmdb = (async () => [HANZAWA]) as typeof apiClient.searchTmdb;
apiClient.reassignAnimeTmdb = (async () => {
throw new Error('Stats API error: 404');
}) 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"
onClose={() => {}}
onLinked={() => {}}
/>,
);
});
const pick = [...container.querySelectorAll('button')].find((button) =>
/Select/.test(button.textContent ?? ''),
);
assert.ok(pick);
await act(async () => {
pick.click();
});
assert.match(container.textContent ?? '', /TMDB has no details for this title/);
assert.doesNotMatch(container.textContent ?? '', /search failed/);
// The results stay on screen so the user can pick another one.
assert.match(container.textContent ?? '', /半沢直樹/);
await act(async () => {
root.unmount();
});
} finally {
apiClient.searchTmdb = original.searchTmdb;
apiClient.reassignAnimeTmdb = original.reassignAnimeTmdb;
uninstallDom();
}
});
test('TmdbSelector cannot be dismissed while a link is in flight', async () => {
const uninstallDom = installDom();
const original = {
searchTmdb: apiClient.searchTmdb,
reassignAnimeTmdb: apiClient.reassignAnimeTmdb,
};
let finishLink: () => void = () => {};
let closed = 0;
apiClient.searchTmdb = (async () => [HANZAWA]) as typeof apiClient.searchTmdb;
apiClient.reassignAnimeTmdb = (() =>
new Promise<void>((resolve) => {
finishLink = resolve;
})) 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"
onClose={() => {
closed += 1;
}}
onLinked={() => {}}
/>,
);
});
const pick = [...container.querySelectorAll('button')].find((button) =>
/Select/.test(button.textContent ?? ''),
);
assert.ok(pick);
await act(async () => {
pick.click();
});
const close = [...container.querySelectorAll('button')].find((button) =>
/✕/.test(button.textContent ?? ''),
) as HTMLButtonElement | undefined;
assert.ok(close);
assert.equal(close.disabled, true);
await act(async () => {
(container.firstElementChild as HTMLElement).click();
});
assert.equal(closed, 0);
await act(async () => {
finishLink();
});
await act(async () => {
root.unmount();
});
} finally {
apiClient.searchTmdb = original.searchTmdb;
apiClient.reassignAnimeTmdb = original.reassignAnimeTmdb;
uninstallDom();
}
});
for (const staleFailure of [false, true]) {
test(`TmdbSelector ignores superseded ${staleFailure ? 'errors' : 'results'} and loading changes`, async () => {
const uninstallDom = installDom();
const originalSearch = apiClient.searchTmdb;
const requests: Array<{
resolve: (results: StatsTmdbSearchResult[]) => void;
reject: (error: Error) => void;
}> = [];
apiClient.searchTmdb = () =>
new Promise((resolve, reject) => {
requests.push({ resolve, reject });
});
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
try {
const render = (initialQuery: string) =>
root.render(
<TmdbSelector
animeId={1}
initialQuery={initialQuery}
onClose={() => {}}
onLinked={() => {}}
/>,
);
await act(async () => {
render('First title');
});
await act(async () => {
render('Second title');
});
assert.equal(requests.length, 2);
await act(async () => {
if (staleFailure) requests[0]!.reject(new Error('stale error'));
else requests[0]!.resolve([HANZAWA]);
});
assert.match(container.textContent ?? '', /Searching/);
assert.doesNotMatch(container.textContent ?? '', /Hanzawa|failed/);
await act(async () => {
requests[1]!.resolve([HANZAWA]);
});
assert.match(container.textContent ?? '', /Hanzawa/);
assert.doesNotMatch(container.textContent ?? '', /Searching/);
await act(async () => {
render('Third title');
});
await act(async () => {
render('');
});
await act(async () => {
requests[2]!.resolve([HANZAWA]);
});
assert.doesNotMatch(container.textContent ?? '', /Hanzawa|Searching/);
} finally {
await act(async () => {
root.unmount();
});
apiClient.searchTmdb = originalSearch;
uninstallDom();
}
});
}
test('TmdbSelector invalidates requests as soon as the user edits or clears the query', async () => {
const uninstallDom = installDom();
const originalSearch = apiClient.searchTmdb;
const requests: Array<(results: StatsTmdbSearchResult[]) => void> = [];
apiClient.searchTmdb = () =>
new Promise((resolve) => {
requests.push(resolve);
});
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
try {
await act(async () => {
root.render(
<TmdbSelector animeId={1} initialQuery="First" onClose={() => {}} onLinked={() => {}} />,
);
});
const input = container.querySelector('input');
assert.ok(input);
const setValue = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value',
)?.set;
assert.ok(setValue);
const edit = async (value: string) => {
await act(async () => {
setValue.call(input, value);
input.dispatchEvent(new window.Event('input', { bubbles: true }));
input.dispatchEvent(new window.KeyboardEvent('keyup', { bubbles: true }));
});
};
await edit('Second');
await act(async () => {
requests[0]!([HANZAWA]);
});
assert.doesNotMatch(container.textContent ?? '', /Hanzawa|No results/);
assert.match(container.textContent ?? '', /Searching/);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 450));
});
assert.equal(requests.length, 2);
await edit('');
assert.doesNotMatch(container.textContent ?? '', /Searching/);
await act(async () => {
requests[1]!([HANZAWA]);
});
assert.doesNotMatch(container.textContent ?? '', /Hanzawa|Searching/);
} finally {
await act(async () => {
root.unmount();
});
apiClient.searchTmdb = originalSearch;
uninstallDom();
}
});
+199
View File
@@ -0,0 +1,199 @@
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;
}
const MISSING_KEY_MESSAGE =
'TMDB API key not configured. Set tmdb.apiKey or tmdb.apiKeyCommand in your config.';
function statusOf(err: unknown): number | null {
const match = err instanceof Error ? /^Stats API error: (\d{3})\b/.exec(err.message) : null;
return match ? Number(match[1]) : null;
}
// The stats API answers a missing key with 503 and the message from config.
function describeSearchError(err: unknown): string {
if (statusOf(err) === 503) return MISSING_KEY_MESSAGE;
return 'TMDB search failed. Check your connection and API key.';
}
// The link route answers 404 when TMDB has no details for the picked id or
// when the library entry itself is gone.
function describeLinkError(err: unknown): string {
switch (statusOf(err)) {
case 503:
return MISSING_KEY_MESSAGE;
case 404:
return 'TMDB has no details for this title. Pick another result or refresh the Library.';
default:
return 'Linking to TMDB failed. Check your connection and try again.';
}
}
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);
const searchSequenceRef = useRef(0);
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);
return () => {
searchSequenceRef.current += 1;
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [initialQuery, animeId]);
const doSearch = async (q: string) => {
const sequence = ++searchSequenceRef.current;
const searchQuery = normalizeAnilistSearchQuery(q);
if (!searchQuery) {
setResults([]);
setError(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const nextResults = await apiClient.searchTmdb(searchQuery);
if (sequence === searchSequenceRef.current) setResults(nextResults);
} catch (err) {
if (sequence !== searchSequenceRef.current) return;
setResults([]);
setError(describeSearchError(err));
} finally {
if (sequence === searchSequenceRef.current) setLoading(false);
}
};
const handleInput = (value: string) => {
searchSequenceRef.current += 1;
setQuery(value);
setResults([]);
setError(null);
const hasQuery = Boolean(normalizeAnilistSearchQuery(value));
setLoading(hasQuery);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!hasQuery) return;
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(describeLinkError(err));
setLinking(null);
}
};
// Dismissing mid-link would leave the caller unaware of a relink that is
// still going to land, so the backdrop and close button wait for it.
const handleDismiss = () => {
if (linking === null) onClose();
};
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]"
onClick={handleDismiss}
>
<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={handleDismiss}
disabled={linking !== null}
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none disabled:opacity-50"
>
{'✕'}
</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,
@@ -240,6 +241,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',
+2
View File
@@ -118,6 +118,8 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
canonicalTitle: 'Anime',
mediaKind: 'anime',
anilistId: null,
tmdbId: null,
tmdbType: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,