mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
feat(stats): add TMDB metadata for live-action dramas in the Library (#252)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user