feat(stats): add library entry merge and episode move

- Add multi-select "Merge Selected" flow to fold duplicate library cards into one, preserving sessions, mined cards, and watch time
- Add per-episode "move to another entry" action for reassigning stray episodes, pruning the source entry when emptied
- Auto-merge library entries that resolve to the same AniList id when their parsed seasons are compatible
- Add mergeAnime/moveVideoToAnime service methods, stats-server routes, and HTTP contract types
This commit is contained in:
2026-08-10 22:52:34 -07:00
parent 47b5903392
commit 9c503fc67d
19 changed files with 1629 additions and 9 deletions
@@ -25,6 +25,8 @@ interface AnimeDetailViewProps {
* keeps showing the previous title's art.
*/
onAnilistRelinked?: () => void;
/** Called after an episode is reassigned to another entry. */
onEpisodeMoved?: () => void;
}
type Range = 14 | 30 | 90;
@@ -150,6 +152,7 @@ export function AnimeDetailView({
onOpenEpisodeDetail,
onAnimeDeleted,
onAnilistRelinked,
onEpisodeMoved,
}: AnimeDetailViewProps) {
const { data, loading, error, reload } = useAnimeDetail(animeId);
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
@@ -223,6 +226,13 @@ export function AnimeDetailView({
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
<EpisodeList
episodes={episodes}
animeId={animeId}
onEpisodeMoved={(removedPreviousAnime) => {
onEpisodeMoved?.();
// The last episode taking the entry with it leaves nothing to show.
if (removedPreviousAnime) onBack();
else reload();
}}
onOpenDetail={onOpenEpisodeDetail ? (videoId) => onOpenEpisodeDetail(videoId) : undefined}
/>
<AnimeWatchChart animeId={animeId} />