- {selectedSeasonQuery.isLoading ? (
+ {selectedSeasonQuery.isPending || selectedSeasonQuery.isFetching ? (
Array.from({ length: 4 }).map((_, index) => (
{!searchActive && seasonEpisodes.length === 0
- ? "No episodes for this season."
+ ? selectedSeasonQuery.isError
+ ? "Couldn't load episodes for this season. Try again."
+ : "No episodes for this season."
: "No episodes match your search."}
) : (
displayedList.map(({ episode, seasonNumber: epSeason }) => {
const active = isRowSelected(episode, epSeason);
- const animeDisplay = useAnimeSeasonGroups
- ? toAnimeDisplayCoords(animeSegments, episode.episode_number)
- : null;
+ const animeDisplay =
+ splitCour && epSeason === selectedSeason
+ ? toAnimeDisplayCoords(animeSegments, episode.episode_number)
+ : null;
const badgeEpisodeNumber =
animeDisplay?.episodeNumber ?? episode.episode_number;
const labelSeasonNumber = animeDisplay?.seasonNumber ?? epSeason;
+ const seasonHeading = animeDisplay
+ ? showTmdbSeasonSelect
+ ? `Part ${labelSeasonNumber}`
+ : `Season ${labelSeasonNumber}`
+ : `Season ${labelSeasonNumber}`;
const thumbnailUrl = resolveEpisodeThumbnailUrl({
stillPath: episode.still_path,
kitsuUrl: resolveKitsuThumbnail(episode, epSeason),
@@ -769,11 +954,11 @@ export function HeroTvEpisodePanel({
{searchActive ||
- (useAnimeSeasonGroups
+ (splitCour
? labelSeasonNumber !== selectedAnimeSegment + 1
: epSeason !== selectedSeason) ? (
- Season {labelSeasonNumber}
+ {seasonHeading}
) : null}
window.removeEventListener("scroll", toggleVisibility);
}, []);
diff --git a/apps/web/components/live/live-tv-player.tsx b/apps/web/components/live/live-tv-player.tsx
index 625ff134..974e305e 100644
--- a/apps/web/components/live/live-tv-player.tsx
+++ b/apps/web/components/live/live-tv-player.tsx
@@ -1,5 +1,6 @@
"use client";
+import dynamic from "next/dynamic";
import {
isHLSProvider,
MediaAnnouncer,
@@ -15,6 +16,7 @@ import { LiveChannelSidebar } from "@/components/live/live-channel-sidebar";
import { LiveTvGuideProvider } from "@/components/live/live-tv-guide-context";
import { LiveVideoLayout } from "@/components/live/live-video-layout";
import { useAdaptiveLiveHls } from "@/hooks/use-adaptive-live-hls";
+import { useMoviPreview } from "@/hooks/use-movi-preview";
import { mergeScrapeHlsClientAuthConfig } from "@/lib/api/scrape-hls-client-auth";
import { buildLiveHlsConfig } from "@/lib/live/adaptive-hls";
import { buildLiveChannelShareUrl } from "@/lib/live/channel-slugs";
@@ -23,6 +25,14 @@ import { cn } from "@/lib/utils";
import "./live-hls-player.css";
+const MoviLivePlayer = dynamic(
+ () =>
+ import("@/components/live/movi-live-player").then(
+ (module) => module.MoviLivePlayer,
+ ),
+ { ssr: false, loading: () => null },
+);
+
type LiveGuideCategory = LiveChannelsResponse["categories"][number];
type LiveTvPlayerProps = {
@@ -54,7 +64,17 @@ const configureHlsProvider = (
provider.config = mergeScrapeHlsClientAuthConfig(config);
};
-export function LiveTvPlayer({
+export function LiveTvPlayer(props: LiveTvPlayerProps) {
+ const moviPreview = useMoviPreview();
+
+ if (moviPreview) {
+ return ;
+ }
+
+ return ;
+}
+
+function VidstackLiveTvPlayer({
categories,
channels,
loadingMoreChannels = false,
diff --git a/apps/web/components/live/movi-live-player.tsx b/apps/web/components/live/movi-live-player.tsx
new file mode 100644
index 00000000..ff2bb584
--- /dev/null
+++ b/apps/web/components/live/movi-live-player.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+
+import { LiveChannelSidebar } from "@/components/live/live-channel-sidebar";
+import { LiveTvGuideProvider } from "@/components/live/live-tv-guide-context";
+import { useAdaptiveLiveHls } from "@/hooks/use-adaptive-live-hls";
+import { buildLiveChannelShareUrl } from "@/lib/live/channel-slugs";
+import type { LiveChannel, LiveChannelsResponse } from "@/lib/live/types";
+import { loadMoviCompat } from "@/lib/player/load-player";
+import { buildMoviScrapePlaybackHeaders } from "@/lib/player/movi-scrape-headers";
+import {
+ applyMoviSource,
+ disposeMoviPlayer,
+ type MoviPlayerElement,
+} from "@/lib/player/player-element";
+import {
+ getMoviVideoElement,
+ isMoviVideoPlaybackReady,
+ resetMoviProgressObservation,
+} from "@/lib/player/player-playback-ready";
+import { cn } from "@/lib/utils";
+
+type LiveGuideCategory = LiveChannelsResponse["categories"][number];
+
+type MoviLivePlayerProps = {
+ categories: LiveGuideCategory[];
+ channels: LiveChannel[];
+ loadingMoreChannels?: boolean;
+ onCategoryChange: (categoryId: string) => void;
+ onRefresh: () => void;
+ onQueryChange: (query: string) => void;
+ onSelectChannel: (channel: LiveChannel) => void;
+ playUrl: string | null;
+ poster?: string | null;
+ query: string;
+ refreshing: boolean;
+ selectedCategory: string;
+ selectedChannel: LiveChannel | null;
+ selectedChannelId: string | null;
+};
+
+function MoviLiveStream({
+ playUrl,
+ poster,
+ title,
+ playerKey,
+ onPlaying,
+}: {
+ playUrl: string;
+ poster?: string | null;
+ title?: string;
+ playerKey: string;
+ onPlaying: () => void;
+}) {
+ const containerRef = useRef(null);
+ const playerRef = useRef(null);
+ const onPlayingRef = useRef(onPlaying);
+ onPlayingRef.current = onPlaying;
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) {
+ return undefined;
+ }
+
+ let cancelled = false;
+
+ void loadMoviCompat()
+ .then(() => {
+ if (cancelled) {
+ return;
+ }
+
+ disposeMoviPlayer(playerRef.current);
+ playerRef.current = null;
+ container.replaceChildren();
+
+ const el = document.createElement("movi-player") as MoviPlayerElement;
+ playerRef.current = el;
+ el.setAttribute("presentation", "native");
+ el.setAttribute("data-movi-harness", "live");
+ el.className = "nyumat-movi-live-player h-full w-full";
+
+ const headers = buildMoviScrapePlaybackHeaders(playUrl);
+ if (Object.keys(headers).length > 0) {
+ el.headers = headers;
+ }
+
+ applyMoviSource(el, playUrl, poster, title);
+ el.addEventListener("playing", () => {
+ onPlayingRef.current();
+ });
+ el.addEventListener("loadeddata", () => {
+ const video = getMoviVideoElement(el);
+ if (video && isMoviVideoPlaybackReady(video)) {
+ onPlayingRef.current();
+ }
+ });
+
+ container.appendChild(el);
+ resetMoviProgressObservation(el);
+ })
+ .catch(() => undefined);
+
+ return () => {
+ cancelled = true;
+ disposeMoviPlayer(playerRef.current);
+ if (playerRef.current) {
+ resetMoviProgressObservation(playerRef.current);
+ }
+ playerRef.current = null;
+ };
+ }, [playUrl, playerKey, poster, title]);
+
+ return ;
+}
+
+export function MoviLivePlayer({
+ categories,
+ channels,
+ loadingMoreChannels = false,
+ onCategoryChange,
+ onRefresh,
+ onQueryChange,
+ onSelectChannel,
+ playUrl,
+ poster,
+ query,
+ refreshing,
+ selectedCategory,
+ selectedChannel,
+ selectedChannelId,
+}: MoviLivePlayerProps) {
+ const { playerKey, onPlaying } = useAdaptiveLiveHls(playUrl);
+
+ const handlePlaying = useCallback(() => {
+ onPlaying();
+ }, [onPlaying]);
+
+ const shareUrl = selectedChannel
+ ? buildLiveChannelShareUrl(selectedChannel)
+ : null;
+
+ return (
+
+
+
+
+
+ {playUrl ? (
+ <>
+
+
+ Live
+
+ >
+ ) : (
+
+ Select a channel
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/media/controls/player-engine-switch.tsx b/apps/web/components/media/controls/player-engine-switch.tsx
new file mode 100644
index 00000000..787b27c8
--- /dev/null
+++ b/apps/web/components/media/controls/player-engine-switch.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { usePlayerEngine } from "@/hooks/use-movi-preview";
+import { prefetchMoviScrapePlayer } from "@/lib/scrape/prefetch-scrape-player";
+import { cn } from "@/lib/utils";
+
+type PlayerEngineSwitchProps = {
+ className?: string;
+};
+
+export function PlayerEngineSwitch({ className }: PlayerEngineSwitchProps) {
+ const { isMovi, setPlayerEngine } = usePlayerEngine();
+
+ const handleCheckedChange = (checked: boolean) => {
+ const next = checked ? "vidstack" : "movi";
+ if (next === "movi") {
+ prefetchMoviScrapePlayer();
+ }
+ setPlayerEngine(next);
+ };
+
+ return (
+
+
+
+ Movi
+
+ Vidstack
+
+
+ );
+}
diff --git a/apps/web/components/media/controls/server-selector.tsx b/apps/web/components/media/controls/server-selector.tsx
index dbeb3146..fef68dcb 100644
--- a/apps/web/components/media/controls/server-selector.tsx
+++ b/apps/web/components/media/controls/server-selector.tsx
@@ -427,7 +427,7 @@ export function ServerSelector({
? scrapeServer
: videoServers.find((s) => s.id === serverId);
if (!server) return;
- setSelectedServer(server);
+ setSelectedServer(server, { userInitiated: true });
onServerSelect?.();
};
diff --git a/apps/web/components/media/direct-stream-player.tsx b/apps/web/components/media/direct-stream-player.tsx
index 9de3bd9a..77858a9e 100644
--- a/apps/web/components/media/direct-stream-player.tsx
+++ b/apps/web/components/media/direct-stream-player.tsx
@@ -18,8 +18,15 @@ import type {
ScrapeQuality,
ScrapeSubtitle,
} from "@/lib/scrape/types";
+import { useMoviPreview } from "@/hooks/use-movi-preview";
import { cn } from "@/lib/utils";
+const MoviScrapePlayer = lazy(() =>
+ import("@/components/media/movi-scrape-player").then((module) => ({
+ default: module.MoviScrapePlayer,
+ })),
+);
+
const ScrapeHlsPlayer = dynamic(
() =>
import("@/components/media/scrape-hls-player").then(
@@ -79,6 +86,7 @@ function DirectStreamPlayerInstance({
onMediaReady,
onEnded,
}: DirectStreamPlayerProps) {
+ const moviPreview = useMoviPreview();
const [engine, setEngine] = useState(() =>
selectDirectPlaybackEngine(
playback,
@@ -168,33 +176,38 @@ function DirectStreamPlayerInstance({
);
}
+ const HlsPlayer = moviPreview ? MoviScrapePlayer : ScrapeHlsPlayer;
+
return (
-
+
+
+
);
}
diff --git a/apps/web/components/media/mal-list-controls.tsx b/apps/web/components/media/mal-list-controls.tsx
index 22187c02..de9b03fb 100644
--- a/apps/web/components/media/mal-list-controls.tsx
+++ b/apps/web/components/media/mal-list-controls.tsx
@@ -2,7 +2,7 @@
import Image from "next/image";
import { Loader2, Star } from "lucide-react";
-import { useCallback, useMemo } from "react";
+import { useCallback, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -27,6 +27,7 @@ import {
applyMalListIndexPatch,
findMalListIndexItem,
malEntryFromIndexItem,
+ shouldApplyMalPatchResponse,
type MalEntryOptimisticPatch,
type MalListIndex,
type MalListIndexItem,
@@ -74,6 +75,7 @@ export function MalListControls({
onUpdated,
}: MalListControlsProps) {
const queryClient = useQueryClient();
+ const patchGenerationRef = useRef(0);
const malListIndexKey = ["mal-list-index"] as const;
const malStatusQuery = useMalSyncStatus();
const malConnected = malStatusQuery.data?.connected === true;
@@ -133,6 +135,7 @@ export function MalListControls({
const patchEntry = useCallback(
async (patch: MalEntryOptimisticPatch) => {
+ const patchGeneration = ++patchGenerationRef.current;
const previousEntry =
queryClient.getQueryData(queryKey);
if (!previousEntry) {
@@ -169,10 +172,28 @@ export function MalListControls({
}
const data = (await response.json()) as { entry: MalEntryResponse };
+ if (
+ !shouldApplyMalPatchResponse(
+ patchGeneration,
+ patchGenerationRef.current,
+ )
+ ) {
+ return;
+ }
+
queryClient.setQueryData(queryKey, data.entry);
void queryClient.invalidateQueries({ queryKey: malListIndexKey });
onUpdated?.();
} catch (error) {
+ if (
+ !shouldApplyMalPatchResponse(
+ patchGeneration,
+ patchGenerationRef.current,
+ )
+ ) {
+ return;
+ }
+
queryClient.setQueryData(queryKey, previousEntry);
if (previousIndex) {
queryClient.setQueryData(malListIndexKey, previousIndex);
diff --git a/apps/web/components/media/media-client.tsx b/apps/web/components/media/media-client.tsx
index 2ca63674..11af6931 100644
--- a/apps/web/components/media/media-client.tsx
+++ b/apps/web/components/media/media-client.tsx
@@ -22,6 +22,7 @@ import { EpisodeIndicator } from "@/components/watchlist/watchlist";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import useMedia from "@/hooks/useMedia";
import { sortWithProfilePathFirst } from "@/lib/media-poster-path";
+import { resolveCastPersonHref } from "@/lib/cast-person-href";
import { cn } from "@/lib/utils";
import type { Genre, MediaItem, ProductionCountry } from "@/lib/domain/typings";
import { Actor, Movie, TvShow, Video } from "@/lib/domain/typings";
@@ -298,10 +299,6 @@ export function CastCarousel({ cast }: CastCarouselProps) {
if (!cast.length) return null;
const castWithImagesFirst = sortWithProfilePathFirst(cast);
- const handlePersonMouseEnter = (person: Actor) => {
- router.prefetch(`/person/${person.id}`);
- };
-
return (
Cast
@@ -314,42 +311,69 @@ export function CastCarousel({ cast }: CastCarouselProps) {
className="w-full"
>
- {castWithImagesFirst.slice(0, 20).map((person: Actor) => (
-
- router.push(`/person/${person.id}`)}
- onMouseEnter={() => handlePersonMouseEnter(person)}
+ {castWithImagesFirst.slice(0, 20).map((person: Actor) => {
+ const personHref = resolveCastPersonHref(person);
+ const handlePersonClick = () => {
+ if (!personHref) return;
+ router.push(personHref);
+ };
+ const handlePersonMouseEnter = () => {
+ if (!personHref) return;
+ router.prefetch(personHref);
+ };
+
+ return (
+
-
- {person.profile_path ? (
-
- ) : (
-
-
-
+
{
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ handlePersonClick();
+ }
+ }
+ : undefined
+ }
+ role={personHref ? "link" : undefined}
+ tabIndex={personHref ? 0 : undefined}
+ >
+
+ {person.profile_path ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {person.name}
+
+
+ {person.character}
+
+
-
-
- {person.name}
-
-
- {person.character}
-
-
-
-
- ))}
+
+ );
+ })}
diff --git a/apps/web/components/media/media-shared.tsx b/apps/web/components/media/media-shared.tsx
index b8cf1e6f..5f013dad 100644
--- a/apps/web/components/media/media-shared.tsx
+++ b/apps/web/components/media/media-shared.tsx
@@ -9,6 +9,7 @@ import {
} from "@/components/ui/tooltip";
import { Icons } from "@/components/icons";
import { pages } from "@/config/pages";
+import { resolveCastPersonHref } from "@/lib/cast-person-href";
import {
hasPosterPath,
sortWithProfilePathFirst,
@@ -154,6 +155,7 @@ type MediaCastCardProps = {
name: string;
profile_path?: string | null;
character: string;
+ href?: string | null;
};
export const mediaCastGridClass =
@@ -164,8 +166,10 @@ export const MediaCastCard: React.FC
= ({
name,
profile_path,
character,
-}) => (
-
+ href,
+}) => {
+ const personHref = resolveCastPersonHref({ id, href, profile_path });
+ const card = (
= ({
-
-);
+ );
+
+ if (!personHref) {
+ return card;
+ }
+
+ return (
+
+ {card}
+
+ );
+};
export const MediaCrewCard: React.FC = ({
id,
name,
profile_path,
job,
-}) => (
-
+}) => {
+ const personHref = resolveCastPersonHref({ id, profile_path });
+ const card = (
= ({
{job}
-
-);
+ );
+
+ if (!personHref) {
+ return card;
+ }
+
+ return (
+
+ {card}
+
+ );
+};
interface MediaCreditsListProps {
cast?: Cast[];
diff --git a/apps/web/components/media/movi-direct-player.tsx b/apps/web/components/media/movi-direct-player.tsx
new file mode 100644
index 00000000..714a0ac1
--- /dev/null
+++ b/apps/web/components/media/movi-direct-player.tsx
@@ -0,0 +1,496 @@
+"use client";
+
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type ComponentProps,
+} from "react";
+
+import { MoviStreamPlayer } from "@/components/media/movi-stream-player";
+import { useDirectPlaybackOrchestrator } from "@/hooks/use-direct-playback-orchestrator";
+import { useMoviPlaybackTrackPreferences } from "@/hooks/use-movi-playback-track-preferences";
+import { usePlaybackProgress } from "@/hooks/use-playback-progress";
+import type { EngineErrorKind } from "@/lib/direct/playbackFailure";
+import { loadMoviCompat, loadMoviFull } from "@/lib/player/load-player";
+import {
+ readMoviCanCast,
+ readMoviPresentationMode,
+} from "@/lib/player/movi-host-api";
+import { buildMoviScrapePlaybackHeaders } from "@/lib/player/movi-scrape-headers";
+import {
+ applyMoviSource,
+ disposeMoviPlayer,
+ type MoviPlayerElement,
+} from "@/lib/player/player-element";
+import {
+ getMoviVideoElement,
+ isMoviPlayerMakingProgress,
+ isMoviVideoPlaybackReady,
+ resetMoviProgressObservation,
+} from "@/lib/player/player-playback-ready";
+import { createMediaReadyHandler } from "@/lib/playback/media-ready";
+import type { PlaybackProgressKey } from "@/lib/playback/progress-storage";
+import {
+ registerPlaybackProgressFlush,
+ unregisterPlaybackProgressFlush,
+} from "@/lib/playback/progress-flush";
+import {
+ decidePlaybackAutoStart,
+ hlsStartPosition,
+} from "@/lib/playback/playbackStart";
+import { preferredAudioLangForTranslation } from "@/lib/scrape/anime/audio-preference";
+import { useAppSettingsStore } from "@/lib/stores/app-settings-store";
+import { useEpisodeStore } from "@/lib/stores/episode-store";
+import { useServerStore } from "@/lib/stores/server-store";
+import {
+ engineSourceUrl,
+ type DirectPlaybackEngine,
+ type DirectStream,
+} from "@nyumatflix/playback";
+import { cn } from "@/lib/utils";
+
+type MoviDirectPlayerProps = {
+ stream: DirectStream;
+ candidates?: DirectStream[];
+ title?: string;
+ poster?: string | null;
+ className?: string;
+ progressKey: PlaybackProgressKey;
+ onStreamFailed: () => void;
+ onMediaReady?: () => void;
+ onEnded?: () => Promise;
+};
+
+type EngineErrorDetail = {
+ kind: EngineErrorKind;
+};
+
+const MOVI_STALL_TIMEOUT_MS = 180_000;
+const MOVI_STALL_POLL_MS = 5_000;
+
+function MoviNativeHlsPlayer({
+ stream,
+ sourceUrl,
+ title,
+ poster,
+ progressKey,
+ onError,
+ onMediaReady,
+ onEnded,
+ className,
+ preferredAudioLang,
+ preferEnglishSubtitles,
+}: {
+ stream: DirectStream;
+ sourceUrl: string;
+ title?: string;
+ poster?: string | null;
+ progressKey: PlaybackProgressKey;
+ onError: (detail: EngineErrorDetail) => void;
+ onMediaReady?: () => void;
+ onEnded?: () => Promise;
+ className?: string;
+ preferredAudioLang?: string;
+ preferEnglishSubtitles?: boolean;
+}) {
+ const containerRef = useRef(null);
+ const playerRef = useRef(null);
+ const [player, setPlayer] = useState(null);
+ const readyRef = useRef(false);
+ const startedRef = useRef(false);
+ const resumedRef = useRef(false);
+ const lastProgressRef = useRef(Date.now());
+ const { resumeTime, persist, persistImmediate } =
+ usePlaybackProgress(progressKey);
+ const startAt = hlsStartPosition(resumeTime);
+
+ const markMediaReady = useCallback(() => {
+ createMediaReadyHandler(onMediaReady, readyRef)();
+ }, [onMediaReady]);
+
+ useMoviPlaybackTrackPreferences(player, progressKey, sourceUrl, {
+ preferredAudioLang,
+ preferEnglishSubtitles,
+ });
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) {
+ return undefined;
+ }
+
+ let cancelled = false;
+ let stallTimer: number | undefined;
+
+ void loadMoviCompat()
+ .then(() => {
+ if (cancelled || playerRef.current) {
+ return;
+ }
+
+ const el = document.createElement("movi-player") as MoviPlayerElement;
+ playerRef.current = el;
+ setPlayer(el);
+ el.setAttribute("presentation", "native");
+ el.setAttribute("data-movi-harness", "direct-hls");
+ el.className = "nyumat-movi-direct-hls h-full w-full";
+
+ const headers = buildMoviScrapePlaybackHeaders(sourceUrl);
+ if (Object.keys(headers).length > 0) {
+ el.headers = headers;
+ }
+
+ applyMoviSource(el, sourceUrl, poster, title ?? stream.name);
+
+ const attemptStart = () => {
+ const video = getMoviVideoElement(el);
+ const isCurrentlyPlaying =
+ video instanceof HTMLVideoElement
+ ? video.paused === false
+ : el.playing === true;
+ const decision = decidePlaybackAutoStart({
+ autoPlay: true,
+ hasStarted: startedRef.current,
+ isCurrentlyPlaying,
+ });
+ if (decision === "already-playing") {
+ startedRef.current = true;
+ markMediaReady();
+ return;
+ }
+ if (decision === "skip") {
+ return;
+ }
+ void el.play?.().then(() => {
+ if (!el.paused) {
+ startedRef.current = true;
+ markMediaReady();
+ }
+ });
+ };
+
+ el.addEventListener("loadeddata", () => {
+ const video = getMoviVideoElement(el);
+ if (video && isMoviVideoPlaybackReady(video)) {
+ markMediaReady();
+ }
+ if (!resumedRef.current && startAt > 0) {
+ const duration = el.duration;
+ if (Number.isFinite(duration) && duration > 0) {
+ resumedRef.current = true;
+ el.currentTime = Math.min(startAt, duration);
+ }
+ }
+ attemptStart();
+ });
+
+ el.addEventListener("playing", () => {
+ lastProgressRef.current = Date.now();
+ startedRef.current = true;
+ markMediaReady();
+ });
+
+ el.addEventListener("timeupdate", () => {
+ lastProgressRef.current = Date.now();
+ if (Number.isFinite(el.currentTime) && el.currentTime > 0) {
+ markMediaReady();
+ persist(el.currentTime, el.duration);
+ }
+ });
+
+ el.addEventListener("statechange", (event: Event) => {
+ const state = (event as CustomEvent).detail;
+ if (state === "error") {
+ onError({ kind: "error" });
+ }
+ });
+
+ el.addEventListener("ended", () => {
+ persistImmediate(el.currentTime, el.duration);
+ void onEnded?.();
+ });
+
+ container.appendChild(el);
+ resetMoviProgressObservation(el);
+
+ stallTimer = window.setInterval(() => {
+ if (readyRef.current) {
+ return;
+ }
+ if (isMoviPlayerMakingProgress(el)) {
+ lastProgressRef.current = Date.now();
+ return;
+ }
+ if (Date.now() - lastProgressRef.current >= MOVI_STALL_TIMEOUT_MS) {
+ onError({ kind: "timeout" });
+ }
+ }, MOVI_STALL_POLL_MS);
+ })
+ .catch(() => {
+ onError({ kind: "error" });
+ });
+
+ return () => {
+ cancelled = true;
+ if (stallTimer !== undefined) {
+ window.clearInterval(stallTimer);
+ }
+ disposeMoviPlayer(playerRef.current);
+ if (playerRef.current) {
+ resetMoviProgressObservation(playerRef.current);
+ }
+ playerRef.current = null;
+ setPlayer(null);
+ };
+ }, [
+ markMediaReady,
+ onEnded,
+ onError,
+ persist,
+ persistImmediate,
+ poster,
+ sourceUrl,
+ startAt,
+ stream.name,
+ title,
+ ]);
+
+ useEffect(() => {
+ const flush = () => {
+ const el = playerRef.current;
+ if (!el) {
+ return;
+ }
+ persistImmediate(el.currentTime, el.duration);
+ };
+ registerPlaybackProgressFlush(flush);
+ return () => unregisterPlaybackProgressFlush(flush);
+ }, [persistImmediate, sourceUrl]);
+
+ return ;
+}
+
+function MoviDirectPlayerInstance({
+ stream,
+ candidates,
+ title,
+ poster,
+ className,
+ progressKey,
+ onStreamFailed,
+ onMediaReady,
+ onEnded,
+}: MoviDirectPlayerProps) {
+ const isAnimeEpisode = useEpisodeStore((state) => state.isAnimeEpisode);
+ const animePreference = useServerStore((state) => state.animePreference);
+ const preferredAudioLang = isAnimeEpisode
+ ? preferredAudioLangForTranslation(animePreference)
+ : undefined;
+ const playbackEnglishSubtitles = useAppSettingsStore(
+ (state) => state.playbackEnglishSubtitles,
+ );
+ const onStreamFailedRef = useRef(onStreamFailed);
+ onStreamFailedRef.current = onStreamFailed;
+
+ const [castFallbackUrl, setCastFallbackUrl] = useState(null);
+ const [showCastFallback, setShowCastFallback] = useState(false);
+
+ const {
+ activeStream,
+ engine,
+ failed,
+ buffering,
+ statusNote,
+ playbackAttempt,
+ handleEngineReady,
+ handleEngineError,
+ } = useDirectPlaybackOrchestrator({
+ stream,
+ candidates,
+ onReady: () => onMediaReady?.(),
+ onExhausted: () => onStreamFailedRef.current(),
+ });
+
+ const [moviBuffering, setMoviBuffering] = useState(true);
+
+ const sourceUrl = engine
+ ? engineSourceUrl(activeStream, engine)
+ : activeStream.url;
+ const effectiveSourceUrl = castFallbackUrl ?? sourceUrl;
+ const effectiveEngine: DirectPlaybackEngine | null = castFallbackUrl
+ ? "vidstack-hls"
+ : engine;
+
+ useEffect(() => {
+ setCastFallbackUrl(null);
+ setShowCastFallback(false);
+ }, [activeStream.hash, sourceUrl, playbackAttempt]);
+
+ const handleCastFallback = useCallback(() => {
+ if (!activeStream.fallbackUrl) {
+ return;
+ }
+ setCastFallbackUrl(activeStream.fallbackUrl);
+ setShowCastFallback(false);
+ }, [activeStream.fallbackUrl]);
+
+ const handleCanvasCastBlocked = useCallback(
+ (player: MoviPlayerElement) => {
+ const mode = readMoviPresentationMode(player);
+ const canCast = readMoviCanCast(player);
+ if (
+ mode === "canvas" &&
+ !canCast &&
+ activeStream.fallbackUrl &&
+ !castFallbackUrl
+ ) {
+ setShowCastFallback(true);
+ }
+ },
+ [activeStream.fallbackUrl, castFallbackUrl],
+ );
+
+ const showStartingOverlay =
+ effectiveEngine === "movi" ? moviBuffering : buffering;
+
+ if (failed || !effectiveEngine) {
+ return (
+
+ Trying next stream...
+
+ );
+ }
+
+ return (
+
+ {showStartingOverlay || statusNote ? (
+
+
+ {statusNote ? (
+
+ {statusNote}
+
+ ) : null}
+
+ ) : null}
+
+ {showCastFallback ? (
+
+
+
+ ) : null}
+
+ {effectiveEngine === "movi" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function MoviStreamPlayerWithCastPolicy({
+ fallbackUrl,
+ onCanvasCastBlocked,
+ ...props
+}: ComponentProps & {
+ fallbackUrl?: string;
+ onCanvasCastBlocked?: (player: MoviPlayerElement) => void;
+}) {
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ void loadMoviFull();
+ }, []);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) {
+ return undefined;
+ }
+
+ const observer = new MutationObserver(() => {
+ const player = container.querySelector("movi-player");
+ if (player instanceof HTMLElement) {
+ onCanvasCastBlocked?.(player as MoviPlayerElement);
+ }
+ });
+
+ observer.observe(container, { childList: true, subtree: true });
+ const player = container.querySelector("movi-player");
+ if (player instanceof HTMLElement) {
+ const onCanCastChange = () => {
+ onCanvasCastBlocked?.(player as MoviPlayerElement);
+ };
+ player.addEventListener("cancastchange", onCanCastChange);
+ onCanvasCastBlocked?.(player as MoviPlayerElement);
+ return () => {
+ observer.disconnect();
+ player.removeEventListener("cancastchange", onCanCastChange);
+ };
+ }
+
+ return () => observer.disconnect();
+ }, [fallbackUrl, onCanvasCastBlocked, props.src]);
+
+ return (
+
+
+
+ );
+}
+
+export function MoviDirectPlayer(props: MoviDirectPlayerProps) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/media/movi-scrape-player.tsx b/apps/web/components/media/movi-scrape-player.tsx
new file mode 100644
index 00000000..50cc0ae0
--- /dev/null
+++ b/apps/web/components/media/movi-scrape-player.tsx
@@ -0,0 +1,629 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { IntroDbSegmentControl } from "@/components/media/controls/introdb-segment-control";
+import { useIntroDbSegments } from "@/hooks/use-introdb-segments";
+import { useMoviPlaybackTrackPreferences } from "@/hooks/use-movi-playback-track-preferences";
+import { usePlaybackProgress } from "@/hooks/use-playback-progress";
+import { useAppSettingsStore } from "@/lib/stores/app-settings-store";
+import { resolveScrapeAudioVariantUrl } from "@/lib/scrape/audio-versions";
+import {
+ formatIntroDbSegmentTitle,
+ type IntroDbSegment,
+} from "@/lib/playback/introdb";
+import type { PlaybackProgressKey } from "@/lib/playback/progress-storage";
+import { pickScrapeQualityIndexForPreference } from "@/lib/playback/playback-preferences";
+import { createMediaReadyHandler } from "@/lib/playback/media-ready";
+import { resolvePreferredSubtitleLang } from "@/lib/playback/movi-subtitle-preference";
+import { trackPreferenceStorageKey } from "@/lib/playback/track-preferences-storage";
+import { trackMatchesLanguage } from "@/lib/playback/track-matching";
+import {
+ decidePlaybackAutoStart,
+ ensurePlaybackAtStart,
+ hlsStartPosition,
+ PLAYBACK_START_TIMEOUT_MS,
+} from "@/lib/playback/playbackStart";
+import { loadMoviCompat, resolveMoviMediaUrl } from "@/lib/player/load-player";
+import {
+ applyMoviChapterMarkers,
+ applyMoviExternalQualities,
+ applyMoviExternalSubtitles,
+ type ChapterMarker,
+ type ExternalQualityEntry,
+} from "@/lib/player/movi-host-api";
+import { buildMoviScrapePlaybackHeaders } from "@/lib/player/movi-scrape-headers";
+import {
+ clearMoviResumeKeys,
+ disposeMoviPlayer,
+ resolveMoviPosterUrl,
+ type MoviPlayerElement,
+} from "@/lib/player/player-element";
+import {
+ getMoviVideoElement,
+ isMoviPlayerMakingProgress,
+ isMoviVideoPlaybackReady,
+ resetMoviProgressObservation,
+} from "@/lib/player/player-playback-ready";
+import {
+ buildScrapeQualityPlayOptions,
+ buildScrapeSubtitleTracks,
+ isAbrOnlyQualityFailover,
+} from "@/lib/scrape/player-sources";
+import {
+ buildScrapePlayUrl,
+ extractScrapePlaybackRefreshFromPlayUrl,
+} from "@/lib/scrape/playback";
+import { resolveActiveSubtitles } from "@/lib/scrape/linked-config";
+import type { ScrapeStreamKind } from "@/lib/scrape/stream-kind";
+import { VIDKING_PROACTIVE_REFRESH_AFTER_MS } from "@/lib/scrape/vidking-constants";
+import type {
+ ScrapeAudioVersion,
+ ScrapeQuality,
+ ScrapeSubtitle,
+} from "@/lib/scrape/types";
+import { cn } from "@/lib/utils";
+
+const VIDKING_KEEPALIVE_INTERVAL_MS = VIDKING_PROACTIVE_REFRESH_AFTER_MS;
+const MOVI_STALL_TIMEOUT_MS = 180_000;
+const MOVI_STALL_POLL_MS = 5_000;
+
+const toIntroDbChapterMarkers = (segments: IntroDbSegment[]): ChapterMarker[] =>
+ segments.map((segment) => ({
+ title: formatIntroDbSegmentTitle(segment.type),
+ start: segment.startSeconds,
+ end: segment.endSeconds,
+ }));
+
+type MoviScrapePlayerProps = {
+ playUrl: string;
+ streamKind?: ScrapeStreamKind;
+ qualities?: ScrapeQuality[];
+ referer?: string;
+ subtitles?: ScrapeSubtitle[];
+ audioVersions?: ScrapeAudioVersion[];
+ defaultAudioLang?: string;
+ defaultHardSubLang?: string;
+ preferredAudioLang?: string;
+ title: string;
+ poster?: string | null;
+ progressKey: PlaybackProgressKey;
+ imdbId?: string | null;
+ className?: string;
+ autoPlay?: boolean;
+ onFatalError?: () => void;
+ onMediaReady?: () => void;
+ onEnded?: () => Promise;
+};
+
+const resolveAbsolutePlaybackUrl = (playUrl: string): string => {
+ if (playUrl.startsWith("http://") || playUrl.startsWith("https://")) {
+ return playUrl;
+ }
+ try {
+ return new URL(playUrl, window.location.href).toString();
+ } catch {
+ return playUrl;
+ }
+};
+
+export function MoviScrapePlayer({
+ playUrl,
+ streamKind = "hls",
+ qualities,
+ referer,
+ subtitles,
+ audioVersions,
+ defaultAudioLang,
+ defaultHardSubLang,
+ preferredAudioLang,
+ title,
+ poster,
+ progressKey,
+ imdbId = null,
+ className,
+ autoPlay = true,
+ onFatalError,
+ onMediaReady,
+ onEnded,
+}: MoviScrapePlayerProps) {
+ const containerRef = useRef(null);
+ const playerRef = useRef(null);
+ const [player, setPlayer] = useState(null);
+ const onFatalErrorRef = useRef(onFatalError);
+ const onMediaReadyRef = useRef(onMediaReady);
+ const onEndedRef = useRef(onEnded);
+ const fatalReportedRef = useRef(false);
+ const startedRef = useRef(false);
+ const readyRef = useRef(false);
+ const lastProgressRef = useRef(Date.now());
+ const [duration, setDuration] = useState(0);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [audioLang, setAudioLang] = useState(
+ defaultAudioLang ?? audioVersions?.[0]?.lang ?? "",
+ );
+ const [hardSubLang, setHardSubLang] = useState(defaultHardSubLang ?? "off");
+ const playbackQuality = useAppSettingsStore((state) => state.playbackQuality);
+ const playbackEnglishSubtitles = useAppSettingsStore(
+ (state) => state.playbackEnglishSubtitles,
+ );
+ const { resumeTime, persist, persistImmediate } =
+ usePlaybackProgress(progressKey);
+ const resumeTimeRef = useRef(resumeTime);
+ resumeTimeRef.current = resumeTime;
+
+ onFatalErrorRef.current = onFatalError;
+ onMediaReadyRef.current = onMediaReady;
+ onEndedRef.current = onEnded;
+
+ useEffect(() => {
+ setAudioLang(defaultAudioLang ?? audioVersions?.[0]?.lang ?? "");
+ setHardSubLang(defaultHardSubLang ?? "off");
+ }, [audioVersions, defaultAudioLang, defaultHardSubLang]);
+
+ const variantRawUrl = useMemo(() => {
+ if (!audioVersions?.length || !audioLang) {
+ return null;
+ }
+ return resolveScrapeAudioVariantUrl(audioVersions, audioLang, hardSubLang);
+ }, [audioLang, audioVersions, hardSubLang]);
+
+ const variantPlayUrl = useMemo(() => {
+ if (!variantRawUrl) {
+ return null;
+ }
+ const refresh = extractScrapePlaybackRefreshFromPlayUrl(playUrl);
+ return buildScrapePlayUrl({
+ url: variantRawUrl,
+ referer,
+ refresh,
+ });
+ }, [playUrl, referer, variantRawUrl]);
+
+ const qualityOptions = useMemo(
+ () =>
+ buildScrapeQualityPlayOptions(
+ variantPlayUrl ?? playUrl,
+ qualities,
+ referer,
+ subtitles,
+ ),
+ [playUrl, qualities, referer, subtitles, variantPlayUrl],
+ );
+
+ const [qualityIndex, setQualityIndex] = useState(() =>
+ pickScrapeQualityIndexForPreference(qualityOptions, playbackQuality),
+ );
+
+ useEffect(() => {
+ setQualityIndex(
+ pickScrapeQualityIndexForPreference(qualityOptions, playbackQuality),
+ );
+ }, [playbackQuality, qualityOptions]);
+
+ const activeOption = qualityOptions[qualityIndex] ?? qualityOptions[0];
+ const activePlayUrl = activeOption?.playUrl ?? playUrl;
+ const activePlaybackUrl = useMemo(
+ () => resolveAbsolutePlaybackUrl(activePlayUrl),
+ [activePlayUrl],
+ );
+
+ const activeSubtitles = useMemo(() => {
+ const fromAudio = resolveActiveSubtitles({
+ audioVersions,
+ audioLang,
+ });
+ if (fromAudio.length > 0) {
+ return fromAudio;
+ }
+ return activeOption?.subtitles ?? subtitles ?? [];
+ }, [activeOption?.subtitles, audioLang, audioVersions, subtitles]);
+
+ const textTracks = useMemo(() => {
+ const tracks = buildScrapeSubtitleTracks(activeSubtitles, referer);
+ const preferredSubtitleLang = resolvePreferredSubtitleLang(
+ trackPreferenceStorageKey(progressKey),
+ {
+ preferEnglishSubtitles: playbackEnglishSubtitles,
+ preferredAudioLang,
+ },
+ );
+
+ if (!preferredSubtitleLang || preferredSubtitleLang === "off") {
+ return tracks;
+ }
+
+ let matched = false;
+ return tracks.map((track) => {
+ const isDefault =
+ !matched &&
+ trackMatchesLanguage(
+ { lang: track.lang, label: track.label },
+ preferredSubtitleLang,
+ );
+
+ if (isDefault) {
+ matched = true;
+ }
+
+ return {
+ ...track,
+ default: isDefault,
+ };
+ });
+ }, [
+ activeSubtitles,
+ playbackEnglishSubtitles,
+ preferredAudioLang,
+ progressKey,
+ referer,
+ ]);
+
+ const playerMountKey = activePlaybackUrl;
+
+ useMoviPlaybackTrackPreferences(player, progressKey, playerMountKey, {
+ preferEnglishSubtitles: playbackEnglishSubtitles,
+ preferredAudioLang,
+ });
+
+ const externalQualities = useMemo(
+ () =>
+ qualityOptions.map((option) => ({
+ label: option.label,
+ url: resolveAbsolutePlaybackUrl(option.playUrl),
+ })),
+ [qualityOptions],
+ );
+
+ const { segments: introDbSegments } = useIntroDbSegments(
+ progressKey,
+ duration,
+ imdbId,
+ );
+
+ const chapterMarkers = useMemo(
+ () => toIntroDbChapterMarkers(introDbSegments),
+ [introDbSegments],
+ );
+
+ const markMediaReady = useCallback(() => {
+ createMediaReadyHandler(() => onMediaReadyRef.current?.(), readyRef)();
+ }, []);
+
+ const reportFatal = useCallback(() => {
+ if (fatalReportedRef.current) {
+ return;
+ }
+ fatalReportedRef.current = true;
+ onFatalErrorRef.current?.();
+ }, []);
+
+ const bumpQualityOrFail = useCallback(() => {
+ if (
+ qualityIndex < qualityOptions.length - 1 &&
+ !isAbrOnlyQualityFailover(qualityOptions)
+ ) {
+ setQualityIndex((index) => index + 1);
+ return;
+ }
+ reportFatal();
+ }, [qualityIndex, qualityOptions, reportFatal]);
+
+ const attemptPlaybackStart = useCallback(
+ (player: MoviPlayerElement) => {
+ if (autoPlay) {
+ return;
+ }
+
+ const video = getMoviVideoElement(player);
+ const isCurrentlyPlaying =
+ video instanceof HTMLVideoElement
+ ? video.paused === false
+ : player.playing === true;
+ const decision = decidePlaybackAutoStart({
+ autoPlay,
+ hasStarted: startedRef.current,
+ isCurrentlyPlaying,
+ });
+
+ if (decision === "already-playing") {
+ startedRef.current = true;
+ markMediaReady();
+ return;
+ }
+ if (decision === "skip") {
+ return;
+ }
+
+ player.muted = false;
+ player.volume = 1;
+ const play = player.play;
+ if (typeof play === "function") {
+ void play.call(player).then(() => {
+ if (!player.paused) {
+ startedRef.current = true;
+ markMediaReady();
+ }
+ });
+ }
+ },
+ [autoPlay, markMediaReady],
+ );
+
+ useEffect(() => {
+ startedRef.current = false;
+ readyRef.current = false;
+ fatalReportedRef.current = false;
+ setDuration(0);
+ setCurrentTime(0);
+ lastProgressRef.current = Date.now();
+ }, [activePlayUrl]);
+
+ useEffect(() => {
+ const interval = window.setInterval(() => {
+ if (readyRef.current) {
+ return;
+ }
+ const el = playerRef.current;
+ if (el && isMoviPlayerMakingProgress(el)) {
+ lastProgressRef.current = Date.now();
+ return;
+ }
+ if (Date.now() - lastProgressRef.current < MOVI_STALL_TIMEOUT_MS) {
+ return;
+ }
+ bumpQualityOrFail();
+ }, MOVI_STALL_POLL_MS);
+
+ return () => window.clearInterval(interval);
+ }, [activePlayUrl, bumpQualityOrFail]);
+
+ useEffect(() => {
+ if (!autoPlay || !activePlayUrl.includes("/transcode/")) {
+ return undefined;
+ }
+
+ const failTimeout = window.setTimeout(() => {
+ if (fatalReportedRef.current || duration > 0) {
+ return;
+ }
+ reportFatal();
+ }, PLAYBACK_START_TIMEOUT_MS);
+
+ return () => window.clearTimeout(failTimeout);
+ }, [activePlayUrl, autoPlay, duration]);
+
+ useEffect(() => {
+ const refresh = extractScrapePlaybackRefreshFromPlayUrl(activePlayUrl);
+ if (!refresh) {
+ return undefined;
+ }
+
+ const keepSessionWarm = () => {
+ void fetch(activePlayUrl, {
+ method: "GET",
+ cache: "no-store",
+ }).catch(() => undefined);
+ };
+
+ keepSessionWarm();
+ const interval = window.setInterval(
+ keepSessionWarm,
+ VIDKING_KEEPALIVE_INTERVAL_MS,
+ );
+ return () => window.clearInterval(interval);
+ }, [activePlayUrl]);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) {
+ return undefined;
+ }
+
+ let cancelled = false;
+ let startTimer: number | undefined;
+ const mountStartAt = hlsStartPosition(resumeTimeRef.current);
+
+ void loadMoviCompat()
+ .then(() => {
+ if (cancelled || playerRef.current) {
+ return;
+ }
+
+ const el = document.createElement("movi-player") as MoviPlayerElement;
+ playerRef.current = el;
+ setPlayer(el);
+ el.setAttribute("presentation", "native");
+ el.setAttribute("data-movi-harness", "scrape");
+ el.className = "nyumat-movi-scrape-player h-full w-full";
+
+ const headers = buildMoviScrapePlaybackHeaders(activePlaybackUrl);
+ if (Object.keys(headers).length > 0) {
+ el.headers = headers;
+ }
+
+ const subtitlePayload = textTracks.map((track) => ({
+ id: track.id,
+ src: track.src,
+ lang: track.lang,
+ label: track.label,
+ format: track.type,
+ default: track.default,
+ }));
+
+ clearMoviResumeKeys(title, undefined);
+ el.setAttribute("startat", String(mountStartAt));
+ el.setAttribute("noerrorscreen", "");
+ el.controls = true;
+ el.autoplay = true;
+ el.muted = false;
+ el.volume = 1;
+ el.playsinline = true;
+ el.theme = "dark";
+ el.setAttribute("fastseek", "");
+ el.setAttribute("buffersize", "256");
+ const posterUrl = resolveMoviPosterUrl(poster);
+ if (posterUrl) {
+ el.poster = posterUrl;
+ }
+ if (title) {
+ el.setAttribute("title", title);
+ }
+
+ if (subtitlePayload.length > 0 && typeof el.source === "function") {
+ el.source({
+ video: { src: activePlaybackUrl },
+ subtitles: subtitlePayload,
+ });
+ } else {
+ el.src = resolveMoviMediaUrl(activePlaybackUrl);
+ }
+
+ el.addEventListener("loadeddata", () => {
+ const video = getMoviVideoElement(el);
+ if (video && isMoviVideoPlaybackReady(video)) {
+ markMediaReady();
+ }
+ const nextDuration = el.duration;
+ if (Number.isFinite(nextDuration) && nextDuration > 0) {
+ setDuration(nextDuration);
+ }
+ if (
+ !autoPlay &&
+ mountStartAt === 0 &&
+ video instanceof HTMLVideoElement &&
+ video.paused
+ ) {
+ ensurePlaybackAtStart(video);
+ }
+ attemptPlaybackStart(el);
+ });
+
+ el.addEventListener("playing", () => {
+ lastProgressRef.current = Date.now();
+ startedRef.current = true;
+ if (startTimer !== undefined) {
+ window.clearInterval(startTimer);
+ startTimer = undefined;
+ }
+ markMediaReady();
+ });
+
+ el.addEventListener("timeupdate", () => {
+ lastProgressRef.current = Date.now();
+ const nextCurrentTime = el.currentTime;
+ if (Number.isFinite(nextCurrentTime) && nextCurrentTime >= 0) {
+ setCurrentTime(nextCurrentTime);
+ }
+ if (Number.isFinite(nextCurrentTime) && nextCurrentTime > 0) {
+ markMediaReady();
+ persist(nextCurrentTime, el.duration);
+ }
+ });
+
+ el.addEventListener("statechange", (event: Event) => {
+ const state = (event as CustomEvent).detail;
+ if (state === "error") {
+ bumpQualityOrFail();
+ }
+ });
+
+ el.addEventListener("playererror", (event: Event) => {
+ const detail = (event as CustomEvent<{ severity?: string }>).detail;
+ if (detail?.severity === "recoverable") {
+ return;
+ }
+ bumpQualityOrFail();
+ });
+
+ el.addEventListener("ended", () => {
+ persistImmediate(el.currentTime, el.duration);
+ void onEndedRef.current?.();
+ });
+
+ container.appendChild(el);
+ resetMoviProgressObservation(el);
+
+ applyMoviExternalQualities(el, externalQualities);
+ if (chapterMarkers.length > 0) {
+ applyMoviChapterMarkers(el, chapterMarkers);
+ }
+
+ if (!autoPlay) {
+ startTimer = window.setInterval(() => {
+ attemptPlaybackStart(el);
+ }, 1500);
+ }
+ })
+ .catch(() => {
+ reportFatal();
+ });
+
+ return () => {
+ cancelled = true;
+ if (startTimer !== undefined) {
+ window.clearInterval(startTimer);
+ }
+ disposeMoviPlayer(playerRef.current);
+ if (playerRef.current) {
+ resetMoviProgressObservation(playerRef.current);
+ }
+ playerRef.current = null;
+ setPlayer(null);
+ };
+ }, [playerMountKey]);
+
+ useEffect(() => {
+ const el = playerRef.current;
+ if (!el || textTracks.length === 0) {
+ return;
+ }
+
+ applyMoviExternalSubtitles(
+ el,
+ textTracks.map((track) => ({
+ id: track.id,
+ src: track.src,
+ lang: track.lang,
+ label: track.label,
+ format: track.type,
+ default: track.default,
+ })),
+ );
+ }, [textTracks]);
+
+ useEffect(() => {
+ const el = playerRef.current;
+ if (!el) {
+ return;
+ }
+ applyMoviExternalQualities(el, externalQualities);
+ if (chapterMarkers.length > 0) {
+ applyMoviChapterMarkers(el, chapterMarkers);
+ }
+ }, [chapterMarkers, externalQualities]);
+
+ return (
+
+ {
+ const player = playerRef.current;
+ if (!player) {
+ return;
+ }
+ player.currentTime = time;
+ setCurrentTime(time);
+ }}
+ onAdvanceToNextEpisode={onEnded}
+ />
+
+ );
+}
diff --git a/apps/web/components/media/movi-stream-player.tsx b/apps/web/components/media/movi-stream-player.tsx
index b8618370..c40ca4ae 100644
--- a/apps/web/components/media/movi-stream-player.tsx
+++ b/apps/web/components/media/movi-stream-player.tsx
@@ -1,8 +1,10 @@
"use client";
-import { useCallback, useEffect, useRef } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useMoviPlaybackTrackPreferences } from "@/hooks/use-movi-playback-track-preferences";
import { usePlaybackProgress } from "@/hooks/use-playback-progress";
+import { useAppSettingsStore } from "@/lib/stores/app-settings-store";
import { loadMoviPlayer } from "@/lib/player/load-player";
import {
applyMoviSource,
@@ -55,10 +57,6 @@ const applyPreferredMoviTracks = (
if (prefs.audioLang) {
applied = player.selectAudioLang?.(prefs.audioLang) ?? false;
}
- if (prefs.subtitleLang) {
- void player.selectSubtitleLang?.(prefs.subtitleLang);
- applied = true;
- }
return applied;
};
@@ -77,6 +75,7 @@ function MoviStreamPlayerInstance({
}: MoviStreamPlayerProps) {
const containerRef = useRef(null);
const playerRef = useRef(null);
+ const [player, setPlayer] = useState(null);
const onErrorRef = useRef(onError);
const onMediaReadyRef = useRef(onMediaReady);
const onBufferingChangeRef = useRef(onBufferingChange);
@@ -88,6 +87,9 @@ function MoviStreamPlayerInstance({
const lastProgressRef = useRef(Date.now());
const { resumeTime, persist, persistImmediate } =
usePlaybackProgress(progressKey);
+ const playbackEnglishSubtitles = useAppSettingsStore(
+ (state) => state.playbackEnglishSubtitles,
+ );
const resumeTimeRef = useRef(resumeTime);
const persistRef = useRef(persist);
const persistImmediateRef = useRef(persistImmediate);
@@ -120,6 +122,11 @@ function MoviStreamPlayerInstance({
[preferredAudioLang],
);
+ useMoviPlaybackTrackPreferences(player, progressKey, src, {
+ preferredAudioLang,
+ preferEnglishSubtitles: playbackEnglishSubtitles,
+ });
+
useEffect(() => {
errorReportedRef.current = false;
resumedRef.current = false;
@@ -212,6 +219,7 @@ function MoviStreamPlayerInstance({
const el = document.createElement("movi-player") as MoviPlayerElement;
playerRef.current = el;
+ setPlayer(el);
el.addEventListener("error", reportError);
el.addEventListener("playing", () => {
@@ -284,6 +292,7 @@ function MoviStreamPlayerInstance({
resetMoviProgressObservation(playerRef.current);
}
playerRef.current = null;
+ setPlayer(null);
};
}, [
src,
diff --git a/apps/web/components/media/scrape-hls-player.tsx b/apps/web/components/media/scrape-hls-player.tsx
index b567a2fe..d92fc890 100644
--- a/apps/web/components/media/scrape-hls-player.tsx
+++ b/apps/web/components/media/scrape-hls-player.tsx
@@ -14,7 +14,7 @@ import {
type MediaProviderAdapter,
type MediaPlayerInstance,
} from "@vidstack/react";
-import Hls from "hls.js";
+import Hls, { type ErrorData } from "hls.js";
import {
type CSSProperties,
useCallback,
@@ -53,7 +53,11 @@ import {
mergeTranscodeHlsAuthConfig,
} from "@/lib/direct/transcode-hls-auth";
import { mergeScrapeHlsClientAuthConfig } from "@/lib/api/scrape-hls-client-auth";
-import { configureScrapeHlsInstance } from "@/lib/scrape/hls-quality";
+import {
+ configureScrapeHlsInstance,
+ isScrapeHlsMidstream,
+ shouldFailoverScrapeHlsFatal,
+} from "@/lib/scrape/hls-quality";
import { SCRAPE_VOD_HLS_CONFIG } from "@/lib/scrape/hls-vod-config";
import { resolveActiveSubtitles } from "@/lib/scrape/linked-config";
import {
@@ -79,7 +83,7 @@ import { VIDKING_PROACTIVE_REFRESH_AFTER_MS } from "@/lib/scrape/vidking-constan
import { createMediaReadyHandler } from "@/lib/playback/media-ready";
import {
decidePlaybackAutoStart,
- PLAYBACK_START_TIMEOUT_MS,
+ vidstackScrapeStartTimeoutMs,
} from "@/lib/playback/playbackStart";
import { cn } from "@/lib/utils";
@@ -115,10 +119,25 @@ type ScrapePlayerStyle = CSSProperties & {
[name: `--${string}`]: string | number | null | undefined;
};
+const readProviderVideo = (
+ player: MediaPlayerInstance | null,
+): HTMLVideoElement | null => {
+ const provider = player?.provider;
+ if (
+ provider &&
+ "video" in provider &&
+ provider.video instanceof HTMLVideoElement
+ ) {
+ return provider.video;
+ }
+
+ return null;
+};
+
const readBufferedEnd = (player: MediaPlayerInstance): number => {
- const provider = player.provider;
- if (provider && "video" in provider && provider.video) {
- return getTimeRangesEnd(provider.video.buffered) ?? 0;
+ const video = readProviderVideo(player);
+ if (video) {
+ return getTimeRangesEnd(video.buffered) ?? 0;
}
return 0;
@@ -159,8 +178,12 @@ export function ScrapeHlsPlayer({
const readyRef = useRef(false);
const startedRef = useRef(false);
const fatalReportedRef = useRef(false);
+ const hlsHopLockRef = useRef(false);
+ const hopFromHlsFailureRef = useRef<() => void>(() => undefined);
const onMediaReadyRef = useRef(onMediaReady);
+ const onFatalErrorRef = useRef(onFatalError);
onMediaReadyRef.current = onMediaReady;
+ onFatalErrorRef.current = onFatalError;
const markMediaReady = useCallback(() => {
createMediaReadyHandler(() => onMediaReadyRef.current?.(), readyRef)();
@@ -357,6 +380,7 @@ export function ScrapeHlsPlayer({
progressKey,
activePlayUrl,
preferredAudioLang,
+ { preferEnglishSubtitles: playbackEnglishSubtitles },
);
const subtitleOffset = useVidstackSubtitleOffset(
@@ -371,6 +395,31 @@ export function ScrapeHlsPlayer({
visible: subtitleOffset.hasTracks,
});
+ const hopFromHlsFailure = useCallback(() => {
+ if (hlsHopLockRef.current) {
+ return;
+ }
+ hlsHopLockRef.current = true;
+ queueMicrotask(() => {
+ hlsHopLockRef.current = false;
+ });
+
+ if (
+ qualityIndex < qualityOptions.length - 1 &&
+ !isAbrOnlyQualityFailover(qualityOptions)
+ ) {
+ setQualityIndex((index) => index + 1);
+ return;
+ }
+
+ if (fatalReportedRef.current) {
+ return;
+ }
+ fatalReportedRef.current = true;
+ onFatalErrorRef.current?.();
+ }, [qualityIndex, qualityOptions]);
+ hopFromHlsFailureRef.current = hopFromHlsFailure;
+
const handleProviderChange = useCallback(
(provider: MediaProviderAdapter | null) => {
if (!provider) {
@@ -403,7 +452,11 @@ export function ScrapeHlsPlayer({
: SCRAPE_VOD_HLS_CONFIG,
);
provider.onInstance((hls) => {
- configureScrapeHlsInstance(hls);
+ configureScrapeHlsInstance(hls, {
+ onRecoveryExhausted: () => {
+ hopFromHlsFailureRef.current();
+ },
+ });
});
return;
}
@@ -601,33 +654,23 @@ export function ScrapeHlsPlayer({
return;
}
fatalReportedRef.current = true;
- onFatalError?.();
+ onFatalErrorRef.current?.();
},
- [onFatalError, qualityIndex, qualityOptions, streamKind],
+ [qualityIndex, qualityOptions, streamKind],
);
const handleHlsError = useCallback(
- (detail: { fatal?: boolean }) => {
- if (!detail.fatal) {
+ (detail: ErrorData) => {
+ const midstream =
+ startedRef.current ||
+ isScrapeHlsMidstream(readProviderVideo(playerRef.current));
+ if (!shouldFailoverScrapeHlsFatal(detail, midstream)) {
return;
}
- // ABR height ladders remount the same stream 3× then still fail — skip them.
- if (
- qualityIndex < qualityOptions.length - 1 &&
- !isAbrOnlyQualityFailover(qualityOptions)
- ) {
- setQualityIndex((index) => index + 1);
- return;
- }
-
- if (fatalReportedRef.current) {
- return;
- }
- fatalReportedRef.current = true;
- onFatalError?.();
+ hopFromHlsFailure();
},
- [onFatalError, qualityIndex, qualityOptions],
+ [hopFromHlsFailure],
);
useEffect(() => {
@@ -641,6 +684,7 @@ export function ScrapeHlsPlayer({
startedRef.current = false;
readyRef.current = false;
fatalReportedRef.current = false;
+ hlsHopLockRef.current = false;
setDuration(0);
}, [activePlayUrl]);
@@ -649,54 +693,40 @@ export function ScrapeHlsPlayer({
return undefined;
}
+ const pollMs = duration > 0 ? 2000 : 1500;
+ const pollForMs = duration > 0 ? 120_000 : 180_000;
+
const tick = () => {
attemptPlaybackStart();
};
tick();
- const interval = window.setInterval(tick, 1500);
+ const interval = window.setInterval(tick, pollMs);
const timeout = window.setTimeout(() => {
window.clearInterval(interval);
- }, 180_000);
+ }, pollForMs);
return () => {
window.clearInterval(interval);
window.clearTimeout(timeout);
};
- }, [activePlayUrl, autoPlay, attemptPlaybackStart]);
+ }, [activePlayUrl, attemptPlaybackStart, autoPlay, duration]);
useEffect(() => {
- if (!autoPlay || !activePlayUrl.includes("/transcode/")) {
+ if (!autoPlay || duration > 0) {
return undefined;
}
const failTimeout = window.setTimeout(() => {
- if (fatalReportedRef.current || duration > 0) {
+ if (fatalReportedRef.current || startedRef.current || readyRef.current) {
return;
}
fatalReportedRef.current = true;
- onFatalError?.();
- }, PLAYBACK_START_TIMEOUT_MS);
+ onFatalErrorRef.current?.();
+ }, vidstackScrapeStartTimeoutMs(activePlayUrl));
return () => window.clearTimeout(failTimeout);
- }, [activePlayUrl, autoPlay, duration, onFatalError]);
-
- useEffect(() => {
- if (!autoPlay || duration <= 0) {
- return undefined;
- }
-
- attemptPlaybackStart();
- const interval = window.setInterval(attemptPlaybackStart, 2000);
- const timeout = window.setTimeout(() => {
- window.clearInterval(interval);
- }, 120_000);
-
- return () => {
- window.clearInterval(interval);
- window.clearTimeout(timeout);
- };
- }, [attemptPlaybackStart, autoPlay, duration]);
+ }, [activePlayUrl, autoPlay, duration]);
useEffect(() => {
const refresh = extractScrapePlaybackRefreshFromPlayUrl(activePlayUrl);
diff --git a/apps/web/components/media/scrape-shaka-dash-player.tsx b/apps/web/components/media/scrape-shaka-dash-player.tsx
index c25513bc..c43a1445 100644
--- a/apps/web/components/media/scrape-shaka-dash-player.tsx
+++ b/apps/web/components/media/scrape-shaka-dash-player.tsx
@@ -67,6 +67,7 @@ function ScrapeShakaDashPlayerInstance({
}: ScrapeShakaDashPlayerProps) {
const videoRef = useRef(null);
const playerRef = useRef(null);
+ const loadGenerationRef = useRef(0);
const readyRef = useRef(false);
const startedRef = useRef(false);
const fatalReportedRef = useRef(false);
@@ -177,6 +178,7 @@ function ScrapeShakaDashPlayerInstance({
readyRef.current = false;
startedRef.current = false;
fatalReportedRef.current = false;
+ const loadGeneration = ++loadGenerationRef.current;
let cancelled = false;
const setup = async () => {
@@ -188,7 +190,7 @@ function ScrapeShakaDashPlayerInstance({
await destroyPlayer();
const shaka = (await import("shaka-player")).default;
- if (cancelled) {
+ if (cancelled || loadGeneration !== loadGenerationRef.current) {
return;
}
@@ -224,11 +226,28 @@ function ScrapeShakaDashPlayerInstance({
}
}
} catch {
+ try {
+ await player.unload();
+ } catch {
+ void 0;
+ }
+ if (
+ cancelled ||
+ loadGeneration !== loadGenerationRef.current ||
+ playerRef.current !== player
+ ) {
+ return;
+ }
reportFatal();
return;
}
- if (cancelled) {
+ if (cancelled || loadGeneration !== loadGenerationRef.current) {
+ try {
+ await player.unload();
+ } catch {
+ void 0;
+ }
return;
}
@@ -239,6 +258,7 @@ function ScrapeShakaDashPlayerInstance({
return () => {
cancelled = true;
+ loadGenerationRef.current += 1;
void destroyPlayer();
};
}, [attemptPlaybackStart, destroyPlayer, playbackUrl, reportFatal]);
diff --git a/apps/web/components/peek/media-peek-sheet.tsx b/apps/web/components/peek/media-peek-sheet.tsx
index 70f8622a..a13cf1a1 100644
--- a/apps/web/components/peek/media-peek-sheet.tsx
+++ b/apps/web/components/peek/media-peek-sheet.tsx
@@ -27,6 +27,7 @@ import { extractVideoRowsFromMediaVideos } from "@/lib/select-primary-trailer-vi
import { useEpisodeStore } from "@/lib/stores/episode-store";
import { useMediaPeekStore } from "@/lib/stores/media-peek-store";
import { useRootTrailerAudioStore } from "@/lib/stores/root-trailer-audio-store";
+import { useLocalTvWatchCoords } from "@/hooks/use-local-tv-watch-coords";
import {
formatTvWatchLabel,
isTvPlaybackResume,
@@ -34,7 +35,9 @@ import {
resolveTvWatchTarget,
} from "@/lib/tv-watch-target";
import { formatRuntime, formatYear } from "@/lib/cards/formatters";
+import { resolveCastPersonHref } from "@/lib/cast-person-href";
import type { MediaAboveFoldDetail } from "@/lib/media-above-fold";
+import { resolveTvCardHref } from "@/lib/tv-card-href";
import { cn } from "@/lib/utils";
import type { ListResponse } from "@/tmdb/api";
import type {
@@ -189,6 +192,7 @@ const MediaPeekBody = ({
"Play",
);
const [tvPlaybackResume, setTvPlaybackResume] = useState(false);
+ const localCoords = useLocalTvWatchCoords(target.contentId);
const [activeTrailerKey, setActiveTrailerKey] = useState(null);
const [stuck, setStuck] = useState(false);
const scrollRef = useRef(null);
@@ -247,7 +251,7 @@ const MediaPeekBody = ({
if (target.mediaType === "movie") {
return fetchMovieSimilarPageClient(target.id, "1");
}
- return fetchTvSimilarPageClient(target.id, "1");
+ return fetchTvSimilarPageClient(target.id, "1", target.catalog);
},
});
@@ -263,7 +267,7 @@ const MediaPeekBody = ({
if (target.mediaType === "movie") {
return fetchMovieVideosClient(target.id);
}
- return fetchTvVideosClient(target.id);
+ return fetchTvVideosClient(target.id, target.catalog);
},
});
@@ -354,11 +358,16 @@ const MediaPeekBody = ({
target.contentId,
{ selectedEpisode, tvShowId, seasonNumber },
watchlistItem,
+ null,
+ null,
+ localCoords,
)
: null;
const tvResume =
tvWatchTarget != null &&
- (isTvWatchlistResume(tvWatchTarget, watchlistItem) || tvPlaybackResume);
+ (isTvWatchlistResume(tvWatchTarget, watchlistItem) ||
+ tvPlaybackResume ||
+ tvWatchTarget.source === "progress");
const playLabel =
target.mediaType === "movie"
? movieButtonLabel
@@ -464,12 +473,14 @@ const MediaPeekBody = ({
setActiveTrailerKey(null);
};
- const resolveSimilarHref = (item: { id: number }) =>
+ const resolveSimilarHref = (item: {
+ id: number;
+ href?: string;
+ poster_path?: string | null;
+ }) =>
target.mediaType === "movie"
? `/movies/${item.id}`
- : target.catalog === "anime"
- ? `/anime/${item.id}`
- : `/tvshows/${item.id}`;
+ : resolveTvCardHref(item, target.catalog === "anime" ? "anime" : null);
return (
{cast.map((person) => (
@@ -833,28 +844,42 @@ const MediaPeekCastChip = ({
person,
onNavigate,
}: {
- person: Cast & { profile_path: string };
+ person: Cast & { profile_path: string; href?: string | null };
onNavigate: () => void;
-}) => (
-
-
-
-
-
- {person.name}
-
-
- {person.character}
-
-
-);
+}) => {
+ const personHref = resolveCastPersonHref(person);
+ const chipClassName = "group w-[5.75rem] shrink-0 snap-start sm:w-[6.5rem]";
+ const inner = (
+ <>
+
+
+
+
+ {person.name}
+
+
+ {person.character}
+
+ >
+ );
+
+ if (!personHref) {
+ return {inner}
;
+ }
+
+ return (
+
+ {inner}
+
+ );
+};
diff --git a/apps/web/components/person/person-client.tsx b/apps/web/components/person/person-client.tsx
index da76ddeb..e143d6d2 100644
--- a/apps/web/components/person/person-client.tsx
+++ b/apps/web/components/person/person-client.tsx
@@ -110,7 +110,7 @@ export const PersonCreditsTable: React.FC = ({
return value === "all" || credit.media_type === value;
};
- const sortedList = useMemo(() => credits.sort(sort), [credits]);
+ const sortedList = useMemo(() => [...credits].sort(sort), [credits]);
const getCount = (type: "movie" | "tv") =>
sortedList.filter((credit) => credit.media_type === type).length;
diff --git a/apps/web/components/providers/app-settings-sync.tsx b/apps/web/components/providers/app-settings-sync.tsx
index 2e5c350e..796d8826 100644
--- a/apps/web/components/providers/app-settings-sync.tsx
+++ b/apps/web/components/providers/app-settings-sync.tsx
@@ -10,33 +10,19 @@ import {
useServerStore,
} from "@/lib/stores/server-store";
import { useEmbedServerStore } from "@/lib/stores/embed-server-store";
-import { getPlaybackModePolicy } from "@/lib/flags/site-flags";
+import {
+ getPlaybackModePolicy,
+ shouldSeedDefaultProxyPlayback,
+} from "@/lib/flags/site-flags";
import { useEffect, useRef } from "react";
-function hasSavedPlaybackServer(): boolean {
- if (typeof window === "undefined") {
- return false;
- }
-
- try {
- return Boolean(
- localStorage.getItem("playback-mode-storage") ||
- localStorage.getItem("video-server-storage"),
- );
- } catch {
- return false;
- }
-}
-
export function AppSettingsSync() {
const flags = useFeatureFlags();
- const noAdsMode = useAppSettingsStore((state) => state.noAdsMode);
const playbackAudio = useAppSettingsStore((state) => state.playbackAudio);
const setNoAdsMode = useAppSettingsStore((state) => state.setNoAdsMode);
const setDisableHeroTrailers = useAppSettingsStore(
(state) => state.setDisableHeroTrailers,
);
- const selectedServer = useServerStore((state) => state.selectedServer);
const setSelectedServer = useServerStore((state) => state.setSelectedServer);
const seededNoAdsFromFlagRef = useRef(false);
const seededProxyFromFlagRef = useRef(false);
@@ -133,11 +119,18 @@ export function AppSettingsSync() {
}
const policy = getPlaybackModePolicy(flags);
+ const playbackState = usePlaybackModeStore.getState();
+ const persistApi = usePlaybackModeStore.persist;
if (
- policy === "choice" &&
- flags.defaultProxyPlayback &&
- !hasSavedPlaybackServer() &&
- !seededProxyFromFlagRef.current
+ persistApi.hasHydrated() &&
+ !seededProxyFromFlagRef.current &&
+ shouldSeedDefaultProxyPlayback({
+ policy,
+ defaultProxyPlayback: flags.defaultProxyPlayback,
+ hasUserSelectedPlaybackServer:
+ playbackState.hasUserSelectedPlaybackServer,
+ selectedServerIsScrape: isScrapeServer(playbackState.selectedServer),
+ })
) {
seededProxyFromFlagRef.current = true;
setSelectedServer(scrapeServer);
@@ -175,14 +168,7 @@ export function AppSettingsSync() {
unsubPlaybackHydration();
unsubSettingsHydration();
};
- }, [
- flags,
- noAdsMode,
- selectedServer.id,
- setDisableHeroTrailers,
- setNoAdsMode,
- setSelectedServer,
- ]);
+ }, [flags, setDisableHeroTrailers, setNoAdsMode, setSelectedServer]);
return null;
}
diff --git a/apps/web/components/providers/devtools-troll-provider.tsx b/apps/web/components/providers/devtools-troll-provider.tsx
index d6249de0..78b1c99b 100644
--- a/apps/web/components/providers/devtools-troll-provider.tsx
+++ b/apps/web/components/providers/devtools-troll-provider.tsx
@@ -10,6 +10,7 @@ import { useEffect, useState, type ReactNode } from "react";
const DETECT_DELAY_MS = 400;
const isDevtoolsTrapDisabled = (): boolean =>
+ process.env.NODE_ENV === "development" ||
process.env.NEXT_PUBLIC_DISABLE_DEVTOOLS_TRAP === "true" ||
process.env.NEXT_PUBLIC_DISABLE_DEVTOOLS_TRAP === "1";
diff --git a/apps/web/components/providers/feature-flags-provider.tsx b/apps/web/components/providers/feature-flags-provider.tsx
index c6cea3c0..8cd5ac54 100644
--- a/apps/web/components/providers/feature-flags-provider.tsx
+++ b/apps/web/components/providers/feature-flags-provider.tsx
@@ -14,7 +14,10 @@ import {
const FeatureFlagsContext = createContext(null);
async function fetchSiteFlags(signal?: AbortSignal): Promise {
- const response = await fetch("/api/site/flags", { signal });
+ const response = await fetch("/api/site/flags", {
+ signal,
+ cache: "no-store",
+ });
if (!response.ok) {
return null;
}
@@ -28,27 +31,22 @@ export function FeatureFlagsProvider({
flags?: SiteFlags;
children: React.ReactNode;
}) {
- const [flags, setFlags] = useState(
+ const [fetchedFlags, setFetchedFlags] = useState(
flagsOverride ?? getDefaultSiteFlags(),
);
+ const flags = flagsOverride ?? fetchedFlags;
const refreshFlags = useCallback(async () => {
try {
const data = await fetchSiteFlags();
if (data) {
- setFlags(data);
+ setFetchedFlags(data);
}
} catch {
void 0;
}
}, []);
- useEffect(() => {
- if (flagsOverride) {
- setFlags(flagsOverride);
- }
- }, [flagsOverride]);
-
useEffect(() => {
if (flagsOverride) {
return;
@@ -59,7 +57,7 @@ export function FeatureFlagsProvider({
void fetchSiteFlags(controller.signal)
.then((data) => {
if (data) {
- setFlags(data);
+ setFetchedFlags(data);
}
})
.catch(() => undefined);
diff --git a/apps/web/components/providers/mal-reauth-provider.tsx b/apps/web/components/providers/mal-reauth-provider.tsx
index ca718d90..ab35494b 100644
--- a/apps/web/components/providers/mal-reauth-provider.tsx
+++ b/apps/web/components/providers/mal-reauth-provider.tsx
@@ -18,17 +18,15 @@ export function MalReauthProvider() {
const statusQuery = useMalSyncStatus();
const reauthRequired = statusQuery.data?.reauthRequired === true;
- const [open, setOpen] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
- if (!isSignedIn || dismissed) {
- return;
- }
if (reauthRequired) {
- setOpen(true);
+ setDismissed(false);
}
- }, [dismissed, isSignedIn, reauthRequired]);
+ }, [reauthRequired]);
+
+ const open = isSignedIn && reauthRequired && !dismissed;
if (!isSignedIn) {
return null;
@@ -38,7 +36,6 @@ export function MalReauthProvider() {
{
- setOpen(next);
if (!next) {
setDismissed(true);
}
diff --git a/apps/web/components/settings/playback-preferences-panel.tsx b/apps/web/components/settings/playback-preferences-panel.tsx
index 0384acf7..adfd20bb 100644
--- a/apps/web/components/settings/playback-preferences-panel.tsx
+++ b/apps/web/components/settings/playback-preferences-panel.tsx
@@ -1,5 +1,6 @@
"use client";
+import { PlayerEngineSwitch } from "@/components/media/controls/player-engine-switch";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useAppSettingsStore } from "@/lib/stores/app-settings-store";
@@ -67,6 +68,8 @@ export function PlaybackPreferencesPanel() {
return (
+
+
label="Audio"
value={playbackAudio}
diff --git a/apps/web/components/tv/tv-card.tsx b/apps/web/components/tv/tv-card.tsx
index 56853f96..0fd35a1e 100644
--- a/apps/web/components/tv/tv-card.tsx
+++ b/apps/web/components/tv/tv-card.tsx
@@ -3,29 +3,39 @@
import { PosterCard } from "@/components/cards/poster-card";
import { formatYear } from "@/lib/cards/formatters";
import { hasPosterPath } from "@/lib/media-poster-path";
+import { resolveTvCardHref } from "@/lib/tv-card-href";
+import type { TvDetailCatalog } from "@/lib/tv-detail-catalog";
import { type TvShow } from "@/tmdb/models";
export type TvCardProps = TvShow & {
variant?: "default" | "linkOnly";
+ catalog?: TvDetailCatalog | null;
};
export const TvCard: React.FC = (props) => {
- const { variant: _variant, ...show } = props;
+ const { variant: _variant, catalog, ...show } = props;
if (!hasPosterPath(show)) {
return null;
}
+ const href =
+ "href" in show && typeof show.href === "string" ? show.href : undefined;
+
return (
(
+ null,
+);
+
+type TvDetailBootstrapProviderProps = TvDetailBootstrapValue & {
+ children: ReactNode;
+};
+
+export const TvDetailBootstrapProvider = ({
+ tvId,
+ catalog,
+ details,
+ children,
+}: TvDetailBootstrapProviderProps) => (
+
+ {children}
+
+);
+
+export const useTvDetailBootstrap = (): TvDetailBootstrapValue => {
+ const value = useContext(TvDetailBootstrapContext);
+ if (!value) {
+ throw new Error(
+ "useTvDetailBootstrap must be used within TvDetailBootstrapProvider",
+ );
+ }
+ return value;
+};
+
+export const TvEpisodesPanelSkeleton = () => (
+
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+
+
+);
diff --git a/apps/web/components/tvshow/tv-show-detail-tab-panels.tsx b/apps/web/components/tvshow/tv-show-detail-tab-panels.tsx
index 35f23724..694eb2f2 100644
--- a/apps/web/components/tvshow/tv-show-detail-tab-panels.tsx
+++ b/apps/web/components/tvshow/tv-show-detail-tab-panels.tsx
@@ -4,6 +4,7 @@ import { HeroTvEpisodePanel } from "@/components/hero/hero-tv-episode-panel";
import { ExpandableCastGrid } from "@/components/media/expandable-cast-grid";
import { TvCard } from "@/components/tv/tv-card";
import { TvShowSeasonsPage } from "@/components/tvshow/tvshow-seasons-page";
+import { useTvDetailBootstrap } from "@/components/tvshow/tv-detail-bootstrap-context";
import {
fetchTvAllSeasonsClient,
fetchTvCreditsClient,
@@ -12,12 +13,12 @@ import {
} from "@/lib/media-detail-tab-client";
import { queryStaleTime } from "@/lib/cache-policy";
import { isAnilistBackedTvRouteId } from "@/lib/tv-detail-catalog";
-import { useTvDetailCatalog } from "@/hooks/use-tv-detail-catalog";
+import { useIsHydrated } from "@/hooks/use-is-hydrated";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { TvShowDetails } from "@/lib/domain/typings";
import { useQuery } from "@tanstack/react-query";
-import { Suspense, type ReactNode } from "react";
+import { type ReactNode } from "react";
import { useInView } from "react-intersection-observer";
type TvShowDetailTabPanelsProps = {
@@ -51,24 +52,15 @@ const DetailSection = ({
);
-const EpisodesFallback = () => (
-
-
-
-
- {Array.from({ length: 4 }).map((_, index) => (
-
- ))}
-
-
-
+const GridSectionSkeleton = () => (
+
+ {Array.from({ length: 8 }).map((_, index) => (
+
+ ))}
+
);
const SeriesGraphFallback = () => (
@@ -77,68 +69,53 @@ const SeriesGraphFallback = () => (
);
-const GridSectionFallback = ({ title }: { title: string }) => (
-
-
- {Array.from({ length: 8 }).map((_, index) => (
-
- ))}
-
-
-);
-
-const EpisodesSection = ({ tvId }: TvShowDetailTabPanelsProps) => {
- const catalog = useTvDetailCatalog();
+const EpisodesSection = () => {
+ const { tvId, catalog, details: bootstrapDetails } = useTvDetailBootstrap();
const isAnilistRoute = isAnilistBackedTvRouteId(tvId, catalog);
- const { data: allSeasonDetails } = useQuery({
- queryKey: queryKeys.tvAllSeasons(tvId),
- queryFn: () => fetchTvAllSeasonsClient(tvId, catalog),
- staleTime: queryStaleTime(60 * 60 * 1000),
- });
-
- const { data: details, isPending } = useQuery({
+ const { data: details = bootstrapDetails } = useQuery({
queryKey: isAnilistRoute
? queryKeys.tvDetailsRoute(tvId)
: queryKeys.tvDetails(Number.parseInt(tvId, 10)),
queryFn: async () => {
- const d = await fetchTvDetailsClient(tvId, catalog);
- if (!d) throw new Error("TV show not found");
- return d;
+ const nextDetails = await fetchTvDetailsClient(tvId, catalog);
+ if (!nextDetails) throw new Error("TV show not found");
+ return nextDetails;
},
+ initialData: bootstrapDetails,
+ staleTime: queryStaleTime(60 * 60 * 1000),
});
- if (isPending && !details) return ;
-
return (
-
+
);
};
-const SeriesGraphSection = ({ tvId }: TvShowDetailTabPanelsProps) => {
- const catalog = useTvDetailCatalog();
- const { data: allSeasonDetails, isPending } = useQuery({
+const SeriesGraphSection = () => {
+ const { tvId, catalog } = useTvDetailBootstrap();
+ const isAnilistRoute = isAnilistBackedTvRouteId(tvId, catalog);
+ const { data: allSeasonDetails } = useQuery({
queryKey: queryKeys.tvAllSeasons(tvId),
queryFn: () => fetchTvAllSeasonsClient(tvId, catalog),
+ staleTime: queryStaleTime(60 * 60 * 1000),
+ enabled: !isAnilistRoute,
});
- if (isPending && !allSeasonDetails) return ;
+ if (isAnilistRoute) {
+ return null;
+ }
- if (!allSeasonDetails) return ;
+ if (!allSeasonDetails) {
+ return ;
+ }
return ;
};
-const LazySeriesGraphSection = ({ tvId }: TvShowDetailTabPanelsProps) => {
+const LazySeriesGraphSection = () => {
+ const isHydrated = useIsHydrated();
const { ref, inView } = useInView({
rootMargin: "600px 0px",
triggerOnce: true,
@@ -146,84 +123,77 @@ const LazySeriesGraphSection = ({ tvId }: TvShowDetailTabPanelsProps) => {
return (
- {inView ? (
- }>
-
-
- ) : (
+ {!isHydrated || !inView ? (
+ ) : (
+
)}
);
};
-const CastSection = ({ tvId }: TvShowDetailTabPanelsProps) => {
- const catalog = useTvDetailCatalog();
- const { data: credits, isPending } = useQuery({
+const CastSection = () => {
+ const { tvId, catalog, details: bootstrapDetails } = useTvDetailBootstrap();
+ const { data: credits } = useQuery({
queryKey: queryKeys.tvTabCredits(tvId),
queryFn: () => fetchTvCreditsClient(tvId, catalog),
+ initialData: bootstrapDetails.credits,
+ staleTime: queryStaleTime(60 * 60 * 1000),
});
- if (isPending && !credits) return ;
-
- if (!credits) return ;
-
return (
- {credits.cast?.length ? (
+ {credits?.cast?.length ? (
- ) : (
+ ) : credits ? (
No cast information available
+ ) : (
+
)}
);
};
-const RecommendationsSection = ({ tvId }: TvShowDetailTabPanelsProps) => {
- const { data: recommendationsData, isPending } = useQuery({
+const RecommendationsSection = () => {
+ const { tvId, catalog, details: bootstrapDetails } = useTvDetailBootstrap();
+ const { data: recommendationsData } = useQuery({
queryKey: queryKeys.tvTabRecommendations(tvId, "1"),
- queryFn: () => fetchTvRecommendationsPageClient(tvId, "1"),
+ queryFn: () => fetchTvRecommendationsPageClient(tvId, "1", catalog),
+ initialData: bootstrapDetails.recommendations,
+ staleTime: queryStaleTime(60 * 60 * 1000),
});
- if (isPending && !recommendationsData) {
- return ;
- }
-
- if (!recommendationsData) {
- return ;
- }
-
return (
- {recommendationsData.results?.length ? (
-
+ {recommendationsData?.results?.length ? (
+
{recommendationsData.results.map((show) => (
-
+
))}
-
- ) : (
+
+ ) : recommendationsData ? (
No recommendations available
+ ) : (
+
)}
);
};
-export const TvShowDetailTabPanels = ({ tvId }: TvShowDetailTabPanelsProps) => {
+export const TvShowDetailTabPanels = ({
+ tvId: _tvId,
+}: TvShowDetailTabPanelsProps) => {
return (
- }>
-
-
-
- }>
-
-
-
- }>
-
-
-
-
+
+
+
+
);
};
diff --git a/apps/web/components/tvshow/tvshow-api.ts b/apps/web/components/tvshow/tvshow-api.ts
index 7a5f549a..ab2b5de3 100644
--- a/apps/web/components/tvshow/tvshow-api.ts
+++ b/apps/web/components/tvshow/tvshow-api.ts
@@ -2,32 +2,56 @@ import type { SeasonDetails } from "@/lib/domain/typings";
import {
TV_DETAIL_CATALOG_ANIME,
TV_DETAIL_CATALOG_QUERY,
- withTvDetailCatalogQuery,
+ withPageTvApiPath,
+ withTvApiRouteId,
+ type TvDetailCatalog,
} from "@/lib/tv-detail-catalog";
+const readSeasonDetailsResponse = async (
+ path: string,
+): Promise => {
+ const response = await fetch(path, { priority: "low" });
+ if (!response.ok) {
+ if (response.status === 404) {
+ return null;
+ }
+ console.warn(
+ `Failed to fetch season details: ${response.status} (${path})`,
+ );
+ return null;
+ }
+ return (await response.json()) as SeasonDetails;
+};
+
+export async function fetchSeasonDetailsForCatalog(
+ tvId: string,
+ seasonNumber: number,
+ catalog: TvDetailCatalog | null,
+): Promise {
+ try {
+ const path = withTvApiRouteId(
+ `/api/tv/${tvId}/season/${seasonNumber}`,
+ catalog,
+ );
+ return await readSeasonDetailsResponse(path);
+ } catch (error) {
+ console.error(error);
+ return null;
+ }
+}
+
export async function fetchSeasonDetails(
tvId: string,
seasonNumber: number,
): Promise {
try {
- const path = withTvDetailCatalogQuery(
+ const pathname =
+ typeof window !== "undefined" ? (window.location?.pathname ?? "") : "";
+ const path = withPageTvApiPath(
`/api/tv/${tvId}/season/${seasonNumber}`,
- typeof window !== "undefined" &&
- window.location.pathname.startsWith("/anime/")
- ? "anime"
- : null,
+ pathname,
);
- const response = await fetch(path);
- if (!response.ok) {
- if (response.status === 404) {
- return null;
- }
- console.warn(
- `Failed to fetch season details: ${response.status} (${path})`,
- );
- return null;
- }
- return await response.json();
+ return await readSeasonDetailsResponse(path);
} catch (error) {
console.error(error);
return null;
diff --git a/apps/web/components/tvshow/tvshow-detail-shell.tsx b/apps/web/components/tvshow/tvshow-detail-shell.tsx
index 386caf3b..f89484e7 100644
--- a/apps/web/components/tvshow/tvshow-detail-shell.tsx
+++ b/apps/web/components/tvshow/tvshow-detail-shell.tsx
@@ -3,6 +3,7 @@
import { WatchlistItem } from "@/lib/domain/watchlist";
import { DETAIL_CONTENT_CONTAINER_CLASS } from "@/components/layout/page-loading/detail-page-loading";
import { MediaDetailLayout } from "@/components/media/media-server";
+import { TvDetailBootstrapProvider } from "@/components/tvshow/tv-detail-bootstrap-context";
import { useWatchlistItem } from "@/hooks/useWatchlistItem";
import { useTvDetailCatalog } from "@/hooks/use-tv-detail-catalog";
import type { MediaAboveFoldDetail } from "@/lib/media-above-fold";
@@ -74,9 +75,7 @@ export const TvShowDetailShell = ({
)?.season_number;
if (entrySeason) {
- useEpisodeStore
- .getState()
- .setSeasonNumber(String(franchiseRootId), entrySeason);
+ useEpisodeStore.getState().setSeasonNumber(tvId, entrySeason);
}
const canonicalHref = buildAnilistTvDetailHref(franchiseRootId);
@@ -103,7 +102,15 @@ export const TvShowDetailShell = ({
initialSeasonNumber={watchlistItem?.lastWatchedSeason || null}
contentContainerClassName={DETAIL_CONTENT_CONTAINER_CLASS}
>
- {children}
+
+
+ {children}
+
+
>
);
diff --git a/apps/web/components/ui/carousel.tsx b/apps/web/components/ui/carousel.tsx
index e2fc26bb..ce094547 100644
--- a/apps/web/components/ui/carousel.tsx
+++ b/apps/web/components/ui/carousel.tsx
@@ -225,6 +225,7 @@ const Carousel = React.forwardRef<
api.on("select", onSelect);
return () => {
+ api?.off("reInit", onSelect);
api?.off("select", onSelect);
};
}, [api, onSelect]);
diff --git a/apps/web/components/ui/scroll-to-top.tsx b/apps/web/components/ui/scroll-to-top.tsx
index 38b7f0a1..93c6903b 100644
--- a/apps/web/components/ui/scroll-to-top.tsx
+++ b/apps/web/components/ui/scroll-to-top.tsx
@@ -17,6 +17,7 @@ export function ScrollToTop() {
}
};
+ toggleVisibility();
window.addEventListener("scroll", toggleVisibility);
return () => window.removeEventListener("scroll", toggleVisibility);
diff --git a/apps/web/components/watchlist/watchlist.tsx b/apps/web/components/watchlist/watchlist.tsx
index 344dff7f..f9e59b0d 100644
--- a/apps/web/components/watchlist/watchlist.tsx
+++ b/apps/web/components/watchlist/watchlist.tsx
@@ -50,7 +50,13 @@ import { useSession } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
-import { type DragEvent, type ReactNode, useEffect, useState } from "react";
+import {
+ type DragEvent,
+ type ReactNode,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
import { toast } from "sonner";
interface EpisodeProgressIndicatorProps {
@@ -174,6 +180,7 @@ export function WatchlistButton({
const [isInWatchlist, setIsInWatchlist] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isToggling, setIsToggling] = useState(false);
+ const isTogglingRef = useRef(false);
const session = useSession();
const pathname = usePathname();
const isSignedIn = Boolean(session.data?.user?.id);
@@ -206,7 +213,7 @@ export function WatchlistButton({
}, [contentId, mediaType]);
const handleToggle = async () => {
- if (isLoading || isToggling) return;
+ if (isLoading || isToggling || isTogglingRef.current) return;
if (!isSignedIn) {
toast.error("To add items to your watchlist, you must be logged in.", {
action: {
@@ -219,6 +226,7 @@ export function WatchlistButton({
return;
}
+ isTogglingRef.current = true;
setIsToggling(true);
try {
if (isInWatchlist) {
@@ -275,6 +283,7 @@ export function WatchlistButton({
: "Failed to add to watchlist",
);
} finally {
+ isTogglingRef.current = false;
setIsToggling(false);
}
};
@@ -695,7 +704,7 @@ function WatchlistBannerCard({
? buildAnilistTvDetailHref(malEntry.anilistId, {
season: malEntry.season,
})
- : `/anime/${item.id}`;
+ : buildAnilistTvDetailHref(item.id);
const showMalControls =
malConnected && mediaType === "tv" && Boolean(malEntry) && !isUnavailable;
const [isManageOpen, setIsManageOpen] = useState(false);
diff --git a/apps/web/hooks/use-anime-episode-coords.ts b/apps/web/hooks/use-anime-episode-coords.ts
new file mode 100644
index 00000000..23dd45ac
--- /dev/null
+++ b/apps/web/hooks/use-anime-episode-coords.ts
@@ -0,0 +1,273 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { useEffect } from "react";
+
+import { applySegmentAnimeEpisodeMapping } from "@/lib/anime/apply-segment-episode-mapping";
+import { resolveEpisodeAnimeSelection } from "@/lib/anime/episode-playback-source";
+import {
+ resolveAnimeEpisodeMappingTmdbShowId,
+ shouldAwaitPlaybackTmdbTvIdForMapping,
+} from "@/lib/anime/resolve-coords-tmdb-show-id";
+import { resolveEpisodeAnimeMapping } from "@/lib/anime/resolve-episode-mapping";
+import type { MappingSegment } from "@/lib/anime/tmdb-anilist-map";
+import { queryStaleTime } from "@/lib/cache-policy";
+import type { Episode } from "@/lib/domain/typings";
+import { useIsHydrated } from "@/hooks/use-is-hydrated";
+import { queryKeys } from "@/lib/query-keys";
+import {
+ useEpisodeStore,
+ type AnimeEpisodeMappingContext,
+} from "@/lib/stores/episode-store";
+
+type AnimeSeasonMap = {
+ segments?: MappingSegment[];
+ isAdult?: boolean;
+};
+
+type UseAnimeEpisodeCoordsOptions = {
+ tvRouteId: string;
+ mappedTmdbTvId: number | null;
+ enabled?: boolean;
+};
+
+const applyEmbeddedEpisodeSelection = (
+ episode: Episode,
+ seasonNumber: number,
+ isAdult: boolean,
+ context: AnimeEpisodeMappingContext,
+): boolean => {
+ const embedded = resolveEpisodeAnimeSelection(episode, {
+ animeSeasonNumber: seasonNumber,
+ isAdult,
+ });
+
+ if (!embedded) {
+ return false;
+ }
+
+ useEpisodeStore.getState().applyAnimeEpisodeMapping(
+ {
+ animeInfo: embedded.animeInfo,
+ relativeEpisodeNumber: embedded.mapping.relativeEpisodeNumber,
+ confidence: embedded.mapping.confidence,
+ isAdult: embedded.mapping.isAdult,
+ genres: embedded.mapping.genres,
+ animeSeasonNumber: embedded.mapping.animeSeasonNumber,
+ },
+ context,
+ );
+
+ return true;
+};
+
+/**
+ * Single resolver for AniList episode coords: season segment map + coords API.
+ */
+export function useAnimeEpisodeCoords({
+ tvRouteId,
+ mappedTmdbTvId,
+ enabled = true,
+}: UseAnimeEpisodeCoordsOptions) {
+ const isHydrated = useIsHydrated();
+ const selectedEpisode = useEpisodeStore((state) => state.selectedEpisode);
+ const tvShowId = useEpisodeStore((state) => state.tvShowId);
+ const seasonNumber = useEpisodeStore((state) => state.seasonNumber);
+ const defaultAnilistId = useEpisodeStore((state) => state.defaultAnilistId);
+ const defaultIsAdultAnime = useEpisodeStore(
+ (state) => state.defaultIsAdultAnime,
+ );
+ const playbackTmdbTvId = useEpisodeStore((state) => state.playbackTmdbTvId);
+ const applyAnimeEpisodeMapping = useEpisodeStore(
+ (state) => state.applyAnimeEpisodeMapping,
+ );
+ const setAnimeCoordsStatus = useEpisodeStore(
+ (state) => state.setAnimeCoordsStatus,
+ );
+
+ const animeMapTmdbShowId =
+ typeof mappedTmdbTvId === "number" && mappedTmdbTvId > 0
+ ? mappedTmdbTvId
+ : null;
+ const canFetchSeasonMap =
+ enabled &&
+ Number.isInteger(defaultAnilistId) &&
+ typeof animeMapTmdbShowId === "number" &&
+ animeMapTmdbShowId > 0 &&
+ seasonNumber != null &&
+ seasonNumber > 0;
+
+ const animeSeasonMapQuery = useQuery({
+ queryKey: queryKeys.animeSeasonMap(
+ animeMapTmdbShowId ?? 0,
+ seasonNumber ?? 0,
+ defaultAnilistId ?? 0,
+ ),
+ queryFn: async (): Promise => {
+ const response = await fetch(
+ `/api/map?tmdbShowId=${encodeURIComponent(String(animeMapTmdbShowId))}&tmdbSeason=${seasonNumber}&sourceAnilistId=${defaultAnilistId}`,
+ );
+ if (!response.ok) {
+ throw new Error("anime season map failed");
+ }
+ return response.json() as Promise;
+ },
+ enabled: isHydrated && canFetchSeasonMap,
+ staleTime: queryStaleTime(60 * 60 * 1000),
+ });
+
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
+ if (!defaultAnilistId || !selectedEpisode || !tvShowId || !seasonNumber) {
+ return;
+ }
+
+ if (tvShowId !== tvRouteId) {
+ return;
+ }
+
+ const mappingContext: AnimeEpisodeMappingContext = {
+ tvShowId: tvRouteId,
+ seasonNumber,
+ episodeNumber: selectedEpisode.episode_number,
+ };
+
+ const segments = animeSeasonMapQuery.data?.segments ?? [];
+ if (segments.length > 0) {
+ const applied = applySegmentAnimeEpisodeMapping(
+ segments,
+ selectedEpisode.episode_number,
+ {
+ ...mappingContext,
+ isAdult:
+ animeSeasonMapQuery.data?.isAdult === true || defaultIsAdultAnime,
+ },
+ );
+ if (applied) {
+ return;
+ }
+ }
+
+ const current = useEpisodeStore.getState();
+ if (
+ current.anilistId &&
+ current.animeCoordsStatus === "resolved" &&
+ current.mappingConfidence === "high" &&
+ current.seasonNumber === seasonNumber &&
+ current.selectedEpisode?.episode_number === selectedEpisode.episode_number
+ ) {
+ return;
+ }
+
+ const tmdbShowId = resolveAnimeEpisodeMappingTmdbShowId(
+ tvRouteId,
+ defaultAnilistId,
+ playbackTmdbTvId,
+ );
+
+ if (tmdbShowId === null) {
+ if (
+ applyEmbeddedEpisodeSelection(
+ selectedEpisode,
+ seasonNumber,
+ defaultIsAdultAnime,
+ mappingContext,
+ )
+ ) {
+ return;
+ }
+
+ if (
+ shouldAwaitPlaybackTmdbTvIdForMapping(
+ tvRouteId,
+ defaultAnilistId,
+ playbackTmdbTvId,
+ )
+ ) {
+ if (current.animeCoordsStatus !== "pending") {
+ setAnimeCoordsStatus("pending");
+ }
+ return;
+ }
+
+ if (current.animeCoordsStatus !== "miss") {
+ setAnimeCoordsStatus("miss");
+ }
+ return;
+ }
+
+ let cancelled = false;
+
+ if (current.animeCoordsStatus !== "pending") {
+ setAnimeCoordsStatus("pending");
+ }
+
+ void resolveEpisodeAnimeMapping({
+ tmdbShowId,
+ seasonNumber,
+ episodeNumber: selectedEpisode.episode_number,
+ isAdult: defaultIsAdultAnime,
+ }).then((coords) => {
+ if (cancelled) {
+ return;
+ }
+
+ if (!coords) {
+ if (
+ applyEmbeddedEpisodeSelection(
+ selectedEpisode,
+ seasonNumber,
+ defaultIsAdultAnime,
+ mappingContext,
+ )
+ ) {
+ return;
+ }
+
+ const latest = useEpisodeStore.getState();
+ if (
+ latest.tvShowId === mappingContext.tvShowId &&
+ latest.seasonNumber === mappingContext.seasonNumber &&
+ latest.selectedEpisode?.episode_number ===
+ mappingContext.episodeNumber &&
+ latest.animeCoordsStatus !== "miss"
+ ) {
+ setAnimeCoordsStatus("miss");
+ }
+ return;
+ }
+
+ applyAnimeEpisodeMapping(
+ {
+ animeInfo: coords.animeInfo,
+ relativeEpisodeNumber: coords.relativeEpisodeNumber,
+ confidence: coords.confidence,
+ isAdult: coords.isAdult,
+ genres: coords.genres,
+ animeSeasonNumber: coords.animeSeasonNumber,
+ },
+ mappingContext,
+ );
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ animeSeasonMapQuery.data?.isAdult,
+ animeSeasonMapQuery.data?.segments,
+ applyAnimeEpisodeMapping,
+ defaultAnilistId,
+ defaultIsAdultAnime,
+ enabled,
+ playbackTmdbTvId,
+ seasonNumber,
+ selectedEpisode,
+ setAnimeCoordsStatus,
+ tvRouteId,
+ tvShowId,
+ ]);
+}
diff --git a/apps/web/hooks/use-anime-episode-mapping.ts b/apps/web/hooks/use-anime-episode-mapping.ts
deleted file mode 100644
index 7740ada4..00000000
--- a/apps/web/hooks/use-anime-episode-mapping.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-
-import { resolveEpisodeAnimeMapping } from "@/lib/anime/resolve-episode-mapping";
-import { useEpisodeStore } from "@/lib/stores/episode-store";
-
-export function useAnimeEpisodeMapping() {
- const selectedEpisode = useEpisodeStore((state) => state.selectedEpisode);
- const tvShowId = useEpisodeStore((state) => state.tvShowId);
- const seasonNumber = useEpisodeStore((state) => state.seasonNumber);
- const defaultAnilistId = useEpisodeStore((state) => state.defaultAnilistId);
- const defaultIsAdultAnime = useEpisodeStore(
- (state) => state.defaultIsAdultAnime,
- );
- const playbackTmdbTvId = useEpisodeStore((state) => state.playbackTmdbTvId);
- const applyAnimeEpisodeMapping = useEpisodeStore(
- (state) => state.applyAnimeEpisodeMapping,
- );
- const setAnimeCoordsStatus = useEpisodeStore(
- (state) => state.setAnimeCoordsStatus,
- );
-
- useEffect(() => {
- if (!defaultAnilistId || !selectedEpisode || !tvShowId || !seasonNumber) {
- return;
- }
-
- const routeId = String(tvShowId);
- if (!Number.isInteger(Number(routeId))) {
- return;
- }
-
- const current = useEpisodeStore.getState();
- if (
- current.anilistId &&
- current.animeCoordsStatus === "resolved" &&
- current.mappingConfidence === "high"
- ) {
- return;
- }
-
- let cancelled = false;
-
- const tmdbShowId =
- typeof playbackTmdbTvId === "number" && playbackTmdbTvId > 0
- ? playbackTmdbTvId
- : Number(routeId);
-
- if (!Number.isInteger(tmdbShowId) || tmdbShowId <= 0) {
- if (current.animeCoordsStatus !== "pending") {
- setAnimeCoordsStatus("pending");
- }
- return;
- }
-
- if (
- defaultAnilistId > 0 &&
- tmdbShowId === defaultAnilistId &&
- playbackTmdbTvId == null
- ) {
- if (current.animeCoordsStatus !== "pending") {
- setAnimeCoordsStatus("pending");
- }
- return;
- }
-
- if (current.animeCoordsStatus !== "pending") {
- setAnimeCoordsStatus("pending");
- }
-
- void resolveEpisodeAnimeMapping({
- tmdbShowId,
- seasonNumber,
- episodeNumber: selectedEpisode.episode_number,
- isAdult: defaultIsAdultAnime,
- }).then((coords) => {
- if (cancelled) {
- return;
- }
- if (!coords) {
- if (current.animeCoordsStatus !== "miss") {
- setAnimeCoordsStatus("miss");
- }
- return;
- }
-
- applyAnimeEpisodeMapping({
- animeInfo: coords.animeInfo,
- relativeEpisodeNumber: coords.relativeEpisodeNumber,
- confidence: coords.confidence,
- isAdult: coords.isAdult,
- genres: coords.genres,
- animeSeasonNumber: coords.animeSeasonNumber,
- });
- });
-
- return () => {
- cancelled = true;
- };
- }, [
- applyAnimeEpisodeMapping,
- defaultAnilistId,
- defaultIsAdultAnime,
- playbackTmdbTvId,
- seasonNumber,
- selectedEpisode,
- setAnimeCoordsStatus,
- tvShowId,
- ]);
-}
diff --git a/apps/web/hooks/use-anime-playback-scrape.ts b/apps/web/hooks/use-anime-playback-scrape.ts
index 335c575b..5434e2af 100644
--- a/apps/web/hooks/use-anime-playback-scrape.ts
+++ b/apps/web/hooks/use-anime-playback-scrape.ts
@@ -17,12 +17,6 @@ import type { AnimeScrapeInput } from "@/lib/scrape/anime/types";
import { animeScrapeMediaKeyFor } from "@/lib/scrape/anime/types";
import type { StreamKind } from "@/lib/scrape/stream-url-patterns";
import { preferredAudioLangForTranslation } from "@/lib/scrape/anime/audio-preference";
-import {
- scoreAnimeScrapePayload,
- payloadMatchesPlaybackPreferences,
- type PlaybackPreferences,
-} from "@/lib/playback/playback-preferences";
-import { useAppSettingsStore } from "@/lib/stores/app-settings-store";
import type {
ScrapeAudioVersion,
ScrapeMediaInput,
@@ -129,19 +123,8 @@ export function useAnimePlaybackScrape(options?: {
}) {
const flags = useFeatureFlags();
const onAllProvidersFailed = options?.onAllProvidersFailed;
- const playbackAudio = useAppSettingsStore((state) => state.playbackAudio);
- const playbackQuality = useAppSettingsStore((state) => state.playbackQuality);
- const playbackEnglishSubtitles = useAppSettingsStore(
- (state) => state.playbackEnglishSubtitles,
- );
- const config = useMemo(() => {
- const playbackPreferences: PlaybackPreferences = {
- playbackAudio,
- playbackQuality,
- playbackEnglishSubtitles,
- };
-
- return {
+ const config = useMemo(
+ () => ({
...animePlaybackScrapeLoopConfig,
resolveProviderOrder: (input: AnimePlaybackScrapeInput) => {
const orders = getAnimePlaybackProviderOrders(flags);
@@ -154,12 +137,7 @@ export function useAnimePlaybackScrape(options?: {
},
onAllProvidersFailed,
soloFirstProviders: ANIME_PLAYBACK_SOLO_FIRST_PROVIDERS,
- raceFirstWin: false,
- scoreRacePayload: (payload: AnimePlaybackScrapeSuccessPayload) =>
- scoreAnimeScrapePayload(payload, playbackPreferences),
- matchesPlaybackPreferences: (
- payload: AnimePlaybackScrapeSuccessPayload,
- ) => payloadMatchesPlaybackPreferences(payload, playbackPreferences),
+ raceFirstWin: true,
harvestSubtitleProviders: (
winnerId: AnimePlaybackScrapeProviderId,
order: readonly AnimePlaybackScrapeProviderId[],
@@ -176,14 +154,9 @@ export function useAnimePlaybackScrape(options?: {
!failed.has(providerId),
);
},
- };
- }, [
- flags,
- onAllProvidersFailed,
- playbackAudio,
- playbackEnglishSubtitles,
- playbackQuality,
- ]);
+ }),
+ [flags, onAllProvidersFailed],
+ );
return useProviderScrapeLoop<
AnimePlaybackScrapeProviderId,
diff --git a/apps/web/hooks/use-anime-scrape.ts b/apps/web/hooks/use-anime-scrape.ts
index e9199264..43438624 100644
--- a/apps/web/hooks/use-anime-scrape.ts
+++ b/apps/web/hooks/use-anime-scrape.ts
@@ -8,11 +8,6 @@ import {
getAnimeScrapeProviderMenuOrder,
} from "@/lib/flags/site-flags";
import { useProviderScrapeLoop } from "@/hooks/use-provider-scrape-loop";
-import {
- scoreAnimeScrapePayload,
- type PlaybackPreferences,
-} from "@/lib/playback/playback-preferences";
-import { useAppSettingsStore } from "@/lib/stores/app-settings-store";
import {
ANIME_SCRAPE_PROVIDER_LABELS,
ANIME_SCRAPE_PROVIDER_ORDER,
@@ -72,29 +67,17 @@ const animeScrapeLoopConfig = {
export function useAnimeScrape() {
const flags = useFeatureFlags();
- const playbackAudio = useAppSettingsStore((state) => state.playbackAudio);
- const playbackQuality = useAppSettingsStore((state) => state.playbackQuality);
- const playbackEnglishSubtitles = useAppSettingsStore(
- (state) => state.playbackEnglishSubtitles,
- );
- const config = useMemo(() => {
- const playbackPreferences: PlaybackPreferences = {
- playbackAudio,
- playbackQuality,
- playbackEnglishSubtitles,
- };
-
- return {
+ const config = useMemo(
+ () => ({
...animeScrapeLoopConfig,
providerOrder: filterAnimeScrapeProviderIds(
flags,
getAnimeScrapeProviderMenuOrder(flags),
) as typeof ANIME_SCRAPE_PROVIDER_ORDER,
- raceFirstWin: false,
- scoreRacePayload: (payload: AnimeScrapeSuccessPayload) =>
- scoreAnimeScrapePayload(payload, playbackPreferences),
- };
- }, [flags, playbackAudio, playbackEnglishSubtitles, playbackQuality]);
+ raceFirstWin: true,
+ }),
+ [flags],
+ );
return useProviderScrapeLoop<
AnimeScrapeProviderId,
diff --git a/apps/web/hooks/use-because-you-watched.ts b/apps/web/hooks/use-because-you-watched.ts
index d49f80c5..93f70272 100644
--- a/apps/web/hooks/use-because-you-watched.ts
+++ b/apps/web/hooks/use-because-you-watched.ts
@@ -8,7 +8,8 @@ import {
} from "@/lib/playback/recently-watched";
import type { WatchlistItem } from "@/lib/domain/watchlist";
import { useQuery } from "@tanstack/react-query";
-import { useEffect, useMemo, useState } from "react";
+import { useIsHydrated } from "@/hooks/use-is-hydrated";
+import { useMemo } from "react";
async function fetchWatchlistItems(): Promise {
const response = await fetch("/api/watchlist");
@@ -23,11 +24,7 @@ async function fetchWatchlistItems(): Promise {
}
export function useBecauseYouWatched() {
- const [hydrated, setHydrated] = useState(false);
-
- useEffect(() => {
- setHydrated(true);
- }, []);
+ const hydrated = useIsHydrated();
const watchlistQuery = useQuery({
queryKey: queryKeys.watchlist(),
diff --git a/apps/web/hooks/use-click-outside.ts b/apps/web/hooks/use-click-outside.ts
index 779263e2..a6bf0290 100644
--- a/apps/web/hooks/use-click-outside.ts
+++ b/apps/web/hooks/use-click-outside.ts
@@ -1,12 +1,15 @@
"use client";
-import { useEffect, type RefObject } from "react";
+import { useEffect, useRef, type RefObject } from "react";
export function useClickOutside(
containerRef: RefObject,
enabled: boolean,
onClickOutside: () => void,
) {
+ const onClickOutsideRef = useRef(onClickOutside);
+ onClickOutsideRef.current = onClickOutside;
+
useEffect(() => {
if (!enabled) {
return;
@@ -15,11 +18,11 @@ export function useClickOutside(
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (containerRef.current && !containerRef.current.contains(target)) {
- onClickOutside();
+ onClickOutsideRef.current();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
- }, [containerRef, enabled, onClickOutside]);
+ }, [containerRef, enabled]);
}
diff --git a/apps/web/hooks/use-client-mounted.ts b/apps/web/hooks/use-client-mounted.ts
index c2fbca32..a0758aa0 100644
--- a/apps/web/hooks/use-client-mounted.ts
+++ b/apps/web/hooks/use-client-mounted.ts
@@ -1,13 +1,5 @@
"use client";
-import { useEffect, useState } from "react";
+import { useIsHydrated } from "@/hooks/use-is-hydrated";
-export const useClientMounted = () => {
- const [mounted, setMounted] = useState(false);
-
- useEffect(() => {
- setMounted(true);
- }, []);
-
- return mounted;
-};
+export const useClientMounted = () => useIsHydrated();
diff --git a/apps/web/hooks/use-hero-scrape-playback.ts b/apps/web/hooks/use-hero-scrape-playback.ts
index 5db66278..6ea47eef 100644
--- a/apps/web/hooks/use-hero-scrape-playback.ts
+++ b/apps/web/hooks/use-hero-scrape-playback.ts
@@ -13,6 +13,13 @@ import {
} from "@/lib/flags/site-flags";
import { prefetchDirectPlaybackPlayer } from "@/lib/direct/prefetch-playback-player";
import { prefetchDirectStreamDiscovery } from "@/lib/direct/stream-discovery-cache";
+import {
+ isAnimeFirstPlayback,
+ shouldAllowTmdbOnlyScrape,
+ shouldHoldAnimePlaybackStart,
+} from "@/lib/anime/anime-playback-policy";
+import { scrapeSessionKeyFor } from "@/lib/anime/scrape-start-gate";
+import { prefetchMoviScrapePlayer } from "@/lib/scrape/prefetch-scrape-player";
import type { HeroScrapeChrome } from "@/components/hero/hero-scrape-types";
import { flushPlaybackProgress } from "@/lib/playback/progress-flush";
import { useAnimePlaybackScrape } from "@/hooks/use-anime-playback-scrape";
@@ -48,6 +55,7 @@ import { useAppSettingsStore } from "@/lib/stores/app-settings-store";
import { useEpisodeStore } from "@/lib/stores/episode-store";
import {
isScrapeServer,
+ scrapeServer,
useServerStore,
videoServers,
} from "@/lib/stores/server-store";
@@ -225,8 +233,6 @@ export function useHeroScrapePlayback({
const [scrapeProviderPreference, setScrapeProviderPreference] = useState<
string | null
>(null);
- const isPlayingVideoRef = useRef(isPlayingVideo);
- isPlayingVideoRef.current = isPlayingVideo;
const isScrapeMode = isScrapeServer(selectedServer);
const isAnimeScrapeMode =
@@ -236,6 +242,25 @@ export function useHeroScrapePlayback({
typeof relativeEpisodeNumber === "number" &&
relativeEpisodeNumber > 0;
+ const animeFirstPlayback = useMemo(
+ () => isAnimeFirstPlayback(tvDetailCatalog, defaultAnilistId),
+ [defaultAnilistId, tvDetailCatalog],
+ );
+
+ const animePlaybackHoldInput = useMemo(
+ () => ({
+ animeFirst: animeFirstPlayback,
+ hasSelectedEpisode: Boolean(selectedEpisode),
+ animeCoordsReady: isAnimeScrapeMode,
+ animeCoordsStatus,
+ }),
+ [animeCoordsStatus, animeFirstPlayback, isAnimeScrapeMode, selectedEpisode],
+ );
+
+ const awaitingAnimeCoords = shouldHoldAnimePlaybackStart(
+ animePlaybackHoldInput,
+ );
+
const resolvedMediaType = useMemo(
() =>
detectMediaType({
@@ -378,6 +403,9 @@ export function useHeroScrapePlayback({
]);
const usesDirectPlayback = useMemo(() => {
+ if (awaitingAnimeCoords) {
+ return false;
+ }
if (!isScrapeMode || !flags.directScrapeProviderAvailable) {
return false;
}
@@ -398,6 +426,7 @@ export function useHeroScrapePlayback({
}
return flags.proxyModeOnly || directIsOnlyScrapeProvider;
}, [
+ awaitingAnimeCoords,
directIsOnlyScrapeProvider,
directPlaybackEligible,
flags.directScrapeProviderAvailable,
@@ -446,15 +475,6 @@ export function useHeroScrapePlayback({
mappingConfidence,
};
- // Known anime show, but TMDB→AniList episode coords still loading — don't
- // flash the TMDB provider list in the scrape overlay.
- const awaitingAnimeCoords =
- Boolean(defaultAnilistId) &&
- Boolean(selectedEpisode) &&
- mappingConfidence !== "high" &&
- !isDirectMode &&
- !isAnimeScrapeActive;
-
const activeScrape = isAnimeScrapeActive ? animePlaybackScrape : mediaScrape;
const stopScraping = useCallback(() => {
@@ -553,14 +573,11 @@ export function useHeroScrapePlayback({
anilistId: episodeAnilistId!,
episodeNumber: relativeEpisodeNumber!,
translationType: playbackAudio,
- query: String(media.title || media.name || "").trim() || undefined,
};
}, [
playbackAudio,
episodeAnilistId,
isAnimeScrapeActive,
- media.name,
- media.title,
relativeEpisodeNumber,
]);
@@ -620,6 +637,7 @@ export function useHeroScrapePlayback({
}
prefetchDirectPlaybackPlayer();
+ prefetchMoviScrapePlayer();
if (awaitingAnimeCoords) {
return;
@@ -628,62 +646,83 @@ export function useHeroScrapePlayback({
if (isDirectMode) {
const input = buildScrapeInput();
const mediaKey = input ? scrapeMediaKeyFor(input) : String(media.id);
- if (lastScrapeMediaKeyRef.current === mediaKey) {
+ const sessionKey = scrapeSessionKeyFor("direct", mediaKey);
+ if (
+ lastScrapeMediaKeyRef.current === sessionKey &&
+ directPlaybackRef.current.status !== "idle"
+ ) {
return;
}
animePlaybackScrapeRef.current.stopScraping();
mediaScrapeRef.current.stopScraping();
- lastScrapeMediaKeyRef.current = mediaKey;
+ lastScrapeMediaKeyRef.current = sessionKey;
void directPlaybackRef.current.loadStreams();
return;
}
if (isAnimeScrapeActive) {
- if (mappingConfidence !== "high") {
- return;
- }
-
const playbackInput = buildAnimePlaybackInput();
if (!playbackInput) {
return;
}
const mediaKey = animeScrapeMediaKeyFor(playbackInput.anime);
- if (lastScrapeMediaKeyRef.current === mediaKey) {
+ const sessionKey = scrapeSessionKeyFor("anime", mediaKey);
+ if (
+ lastScrapeMediaKeyRef.current === sessionKey &&
+ animePlaybackScrapeRef.current.status !== "idle"
+ ) {
return;
}
mediaScrapeRef.current.stopScraping();
+ directPlaybackRef.current.reset();
- lastScrapeMediaKeyRef.current = mediaKey;
+ lastScrapeMediaKeyRef.current = sessionKey;
animePlaybackScrapeRef.current.startScraping(playbackInput);
return;
}
+ if (!shouldAllowTmdbOnlyScrape(animePlaybackHoldInput)) {
+ return;
+ }
+
const input = buildScrapeInput();
if (!input) {
return;
}
const mediaKey = scrapeMediaKeyFor(input);
- if (lastScrapeMediaKeyRef.current === mediaKey) {
+ const sessionKey = scrapeSessionKeyFor("tmdb", mediaKey);
+ if (
+ lastScrapeMediaKeyRef.current === sessionKey &&
+ mediaScrapeRef.current.status !== "idle"
+ ) {
return;
}
- lastScrapeMediaKeyRef.current = mediaKey;
+ lastScrapeMediaKeyRef.current = sessionKey;
+ animePlaybackScrapeRef.current.stopScraping();
+ directPlaybackRef.current.reset();
mediaScrapeRef.current.startScraping(input);
}, [
+ animePlaybackHoldInput,
awaitingAnimeCoords,
buildAnimePlaybackInput,
buildScrapeInput,
isAnimeScrapeActive,
isDirectMode,
- mappingConfidence,
media.id,
selectedServer,
]);
+ useEffect(() => {
+ if (!isAnimeScrapeActive) {
+ animePlaybackScrapeRef.current.stopScraping();
+ }
+ }, [isAnimeScrapeActive]);
+
const stopScrapingPlayback = useCallback(() => {
lastScrapeMediaKeyRef.current = null;
if (isDirectMode && directPlayback.status !== "idle") {
@@ -728,7 +767,33 @@ export function useHeroScrapePlayback({
}, [activeScrape.result?.providerId, activeScrape.status, isScrapeMode]);
useEffect(() => {
- if (!isPlayingVideoRef.current) {
+ if (!isPlayingVideo || !animeFirstPlayback || !selectedEpisode) {
+ return;
+ }
+ if (isScrapeServer(selectedServer)) {
+ return;
+ }
+ if (
+ !awaitingAnimeCoords &&
+ (!isAnimeScrapeMode || !hasEnabledAnimeScrapeProviders)
+ ) {
+ return;
+ }
+
+ setSelectedServer(scrapeServer);
+ }, [
+ animeFirstPlayback,
+ awaitingAnimeCoords,
+ hasEnabledAnimeScrapeProviders,
+ isAnimeScrapeMode,
+ isPlayingVideo,
+ selectedEpisode,
+ selectedServer.id,
+ setSelectedServer,
+ ]);
+
+ useEffect(() => {
+ if (!isPlayingVideo) {
return;
}
@@ -740,6 +805,7 @@ export function useHeroScrapePlayback({
stopScrapingPlayback();
}, [
animeCoordsStatus,
+ isPlayingVideo,
isScrapeMode,
movieScrapeOverride,
scrapeContextKey,
@@ -756,6 +822,7 @@ export function useHeroScrapePlayback({
}
prefetchDirectPlaybackPlayer();
+ prefetchMoviScrapePlayer();
}, [isScrapeMode, usesDirectPlayback]);
// Warm stream discovery as soon as the hero mounts in direct mode so the
@@ -924,7 +991,7 @@ export function useHeroScrapePlayback({
scrapeFallbackHandledRef.current = null;
stopScraping();
- setSelectedServer(server);
+ setSelectedServer(server, { userInitiated: true });
},
[setSelectedServer, stopScraping],
);
@@ -949,7 +1016,11 @@ export function useHeroScrapePlayback({
preferredScrapeProviderIdRef.current = "direct";
animePlaybackScrapeRef.current.stopScraping();
mediaScrapeRef.current.stopScraping();
- lastScrapeMediaKeyRef.current = null;
+ const input = buildScrapeInput();
+ lastScrapeMediaKeyRef.current = scrapeSessionKeyFor(
+ "direct",
+ input ? scrapeMediaKeyFor(input) : String(directTmdbId),
+ );
void directPlaybackRef.current.loadStreams();
return;
}
@@ -965,7 +1036,10 @@ export function useHeroScrapePlayback({
return;
}
- lastScrapeMediaKeyRef.current = scrapeMediaKeyFor(input);
+ lastScrapeMediaKeyRef.current = scrapeSessionKeyFor(
+ "tmdb",
+ scrapeMediaKeyFor(input),
+ );
mediaScrapeRef.current.switchToProvider(
input,
providerId as ScrapeProviderId,
@@ -990,8 +1064,9 @@ export function useHeroScrapePlayback({
return;
}
- lastScrapeMediaKeyRef.current = animeScrapeMediaKeyFor(
- playbackInput.anime,
+ lastScrapeMediaKeyRef.current = scrapeSessionKeyFor(
+ "anime",
+ animeScrapeMediaKeyFor(playbackInput.anime),
);
animePlaybackScrapeRef.current.switchToProvider(
playbackInput,
@@ -1005,7 +1080,10 @@ export function useHeroScrapePlayback({
return;
}
- lastScrapeMediaKeyRef.current = scrapeMediaKeyFor(input);
+ lastScrapeMediaKeyRef.current = scrapeSessionKeyFor(
+ "tmdb",
+ scrapeMediaKeyFor(input),
+ );
mediaScrapeRef.current.switchToProvider(
input,
providerId as ScrapeProviderId,
@@ -1264,6 +1342,7 @@ export function useHeroScrapePlayback({
buildPlaybackProgressKey,
isAnimeScrapeActive,
isDirectMode,
+ awaitingAnimeCoords,
activeScrape: effectiveActiveScrape,
directPlayback,
sourceOverlayItems,
diff --git a/apps/web/hooks/use-is-hydrated.ts b/apps/web/hooks/use-is-hydrated.ts
index 44f4e3e5..89201ac8 100644
--- a/apps/web/hooks/use-is-hydrated.ts
+++ b/apps/web/hooks/use-is-hydrated.ts
@@ -1,11 +1,10 @@
-import { useEffect, useState } from "react";
+import { useSyncExternalStore } from "react";
-export const useIsHydrated = (): boolean => {
- const [isHydrated, setIsHydrated] = useState(false);
+const subscribe = () => () => undefined;
- useEffect(() => {
- setIsHydrated(true);
- }, []);
-
- return isHydrated;
-};
+export const useIsHydrated = (): boolean =>
+ useSyncExternalStore(
+ subscribe,
+ () => true,
+ () => false,
+ );
diff --git a/apps/web/hooks/use-local-tv-watch-coords.ts b/apps/web/hooks/use-local-tv-watch-coords.ts
new file mode 100644
index 00000000..6b39d400
--- /dev/null
+++ b/apps/web/hooks/use-local-tv-watch-coords.ts
@@ -0,0 +1,24 @@
+"use client";
+
+import { useIsHydrated } from "@/hooks/use-is-hydrated";
+import { usePlaybackProgressRevision } from "@/hooks/use-playback-progress-revision";
+import {
+ resolveLocalTvWatchCoords,
+ type LocalTvWatchCoords,
+} from "@/lib/tv-watch-target";
+import { useMemo } from "react";
+
+export function useLocalTvWatchCoords(
+ contentId: number,
+): LocalTvWatchCoords | null {
+ const isHydrated = useIsHydrated();
+ const progressRevision = usePlaybackProgressRevision();
+
+ return useMemo(() => {
+ if (!isHydrated) {
+ return null;
+ }
+
+ return resolveLocalTvWatchCoords(contentId);
+ }, [contentId, isHydrated, progressRevision]);
+}
diff --git a/apps/web/hooks/use-media-card-prefetch.ts b/apps/web/hooks/use-media-card-prefetch.ts
index 3e0eb3ac..10210043 100644
--- a/apps/web/hooks/use-media-card-prefetch.ts
+++ b/apps/web/hooks/use-media-card-prefetch.ts
@@ -1,31 +1,50 @@
"use client";
+import { fetchSeasonDetailsForCatalog } from "@/components/tvshow/tvshow-api";
import {
getMediaAboveFoldApiHref,
getMediaAboveFoldImageUrls,
type MediaAboveFoldDetail,
type MediaAboveFoldType,
} from "@/lib/media-above-fold";
+import { queryStaleTime } from "@/lib/cache-policy";
import { getHref } from "@/lib/cards/selectors";
import { queryKeys } from "@/lib/query-keys";
+import {
+ resolveAnimePrefetchRouteId,
+ resolveTvDetailCatalogFromHref,
+} from "@/lib/tv-detail-catalog";
import type { CanonicalMediaCard, MediaItem } from "@/lib/domain/typings";
+import {
+ normalizeTvContentKey,
+ resolveLocalTvWatchCoords,
+} from "@/lib/tv-watch-target";
import { QueryClientContext } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useCallback, useContext, useEffect, useRef } from "react";
const warmed = new Set();
+const warmedSeasons = new Set();
const pending = new Map>();
const MAX_WARMED_ITEMS = 250;
-function rememberWarmed(cacheKey: string) {
- if (warmed.has(cacheKey)) return;
- warmed.add(cacheKey);
+function rememberWarmed(cacheKey: string, bucket: Set) {
+ if (bucket.has(cacheKey)) return;
+ bucket.add(cacheKey);
- if (warmed.size <= MAX_WARMED_ITEMS) return;
- const oldest = warmed.values().next().value;
- if (oldest) warmed.delete(oldest);
+ if (bucket.size <= MAX_WARMED_ITEMS) return;
+ const oldest = bucket.values().next().value;
+ if (oldest) bucket.delete(oldest);
}
+const resolveDefaultPrefetchSeason = (contentId: number | null): number => {
+ if (contentId === null) {
+ return 1;
+ }
+
+ return resolveLocalTvWatchCoords(contentId)?.seasonNumber ?? 1;
+};
+
function getMediaType(
item: CanonicalMediaCard | MediaItem,
): MediaAboveFoldType | null {
@@ -79,6 +98,11 @@ function getPrefetchId(
item: CanonicalMediaCard | MediaItem,
link: string,
): number | string {
+ const animeRouteId = resolveAnimePrefetchRouteId(link);
+ if (animeRouteId) {
+ return animeRouteId;
+ }
+
const animeItem = item as MediaItem & {
isAniListFallback?: boolean;
sourceAnilistId?: number;
@@ -118,19 +142,44 @@ export function useMediaCardPrefetch(
if (!queryClient) return;
- if (warmed.has(cacheKey)) return;
- rememberWarmed(cacheKey);
-
- void fetchAboveFold(mediaType, prefetchId).then((detail) => {
- if (!detail) return;
- queryClient.setQueryData(
- queryKeys.mediaAboveFold(mediaType, String(prefetchId)),
- detail,
- );
- for (const url of getMediaAboveFoldImageUrls(detail)) {
- preloadImage(url);
- }
- });
+ if (!warmed.has(cacheKey)) {
+ void fetchAboveFold(mediaType, prefetchId).then((detail) => {
+ if (!detail) return;
+ rememberWarmed(cacheKey, warmed);
+ queryClient.setQueryData(
+ queryKeys.mediaAboveFold(mediaType, String(prefetchId)),
+ detail,
+ );
+ for (const url of getMediaAboveFoldImageUrls(detail)) {
+ preloadImage(url);
+ }
+ });
+ }
+
+ if (mediaType !== "tv") {
+ return;
+ }
+
+ const tvRouteId = String(prefetchId);
+ const contentId = normalizeTvContentKey(prefetchId);
+ const seasonNumber = resolveDefaultPrefetchSeason(contentId);
+ const seasonWarmKey = `${tvRouteId}:season:${seasonNumber}`;
+ if (warmedSeasons.has(seasonWarmKey)) {
+ return;
+ }
+
+ const catalog = resolveTvDetailCatalogFromHref(link);
+ void queryClient
+ .prefetchQuery({
+ queryKey: queryKeys.tvSeasonRoute(tvRouteId, seasonNumber),
+ queryFn: () =>
+ fetchSeasonDetailsForCatalog(tvRouteId, seasonNumber, catalog),
+ staleTime: queryStaleTime(60 * 60 * 1000),
+ })
+ .then(() => {
+ rememberWarmed(seasonWarmKey, warmedSeasons);
+ })
+ .catch(() => undefined);
}, [href, item, queryClient, router]);
const schedulePrefetch = useCallback(() => {
diff --git a/apps/web/hooks/use-movi-playback-track-preferences.ts b/apps/web/hooks/use-movi-playback-track-preferences.ts
new file mode 100644
index 00000000..67d05def
--- /dev/null
+++ b/apps/web/hooks/use-movi-playback-track-preferences.ts
@@ -0,0 +1,132 @@
+"use client";
+
+import { useEffect, useMemo, useRef } from "react";
+
+import {
+ applyMoviSubtitlePreference,
+ readActiveMoviSubtitlePreference,
+ resolvePreferredSubtitleLang,
+} from "@/lib/playback/movi-subtitle-preference";
+import type { PlaybackProgressKey } from "@/lib/playback/progress-storage";
+import {
+ trackPreferenceStorageKey,
+ updateTrackPreferences,
+} from "@/lib/playback/track-preferences-storage";
+import type { MoviPlayerElement } from "@/lib/player/player-element";
+
+const MOVI_SUBTITLE_APPLY_DELAYS_MS = [0, 250, 1000, 2500] as const;
+
+export function useMoviPlaybackTrackPreferences(
+ player: MoviPlayerElement | null,
+ progressKey: PlaybackProgressKey,
+ sourceKey: string,
+ options?: {
+ preferEnglishSubtitles?: boolean;
+ preferredAudioLang?: string | null;
+ },
+): void {
+ const scopeKey = useMemo(
+ () => trackPreferenceStorageKey(progressKey),
+ [progressKey.contentId, progressKey.mediaType],
+ );
+ const applyingRef = useRef(false);
+ const appliedForSourceRef = useRef(null);
+
+ useEffect(() => {
+ appliedForSourceRef.current = null;
+ }, [sourceKey]);
+
+ useEffect(() => {
+ if (!player) {
+ return;
+ }
+
+ let disposed = false;
+ const timers: number[] = [];
+
+ const clearTimers = () => {
+ for (const timer of timers) {
+ window.clearTimeout(timer);
+ }
+ timers.length = 0;
+ };
+
+ const scheduleApply = () => {
+ clearTimers();
+ for (const delay of MOVI_SUBTITLE_APPLY_DELAYS_MS) {
+ const timer = window.setTimeout(() => {
+ void tryApply();
+ }, delay);
+ timers.push(timer);
+ }
+ };
+
+ const tryApply = async () => {
+ if (disposed || applyingRef.current) {
+ return;
+ }
+
+ const preferred = resolvePreferredSubtitleLang(scopeKey, options);
+ if (!preferred || preferred === "off") {
+ return;
+ }
+
+ const hasTracks =
+ (player.getSubtitleLangs?.().length ?? 0) > 0 ||
+ (player.getSubtitleTracks?.().length ?? 0) > 0;
+ if (!hasTracks) {
+ return;
+ }
+
+ applyingRef.current = true;
+ try {
+ const applied = await applyMoviSubtitlePreference(player, preferred);
+ if (applied) {
+ appliedForSourceRef.current = sourceKey;
+ }
+ } finally {
+ applyingRef.current = false;
+ }
+ };
+
+ const persistSubtitlePreference = () => {
+ if (disposed || applyingRef.current) {
+ return;
+ }
+
+ updateTrackPreferences(scopeKey, {
+ subtitleLang: readActiveMoviSubtitlePreference(player),
+ });
+ };
+
+ const handleTracksChange = () => {
+ if (appliedForSourceRef.current === sourceKey) {
+ return;
+ }
+ scheduleApply();
+ };
+
+ player.addEventListener("trackschange", handleTracksChange);
+ player.addEventListener("loadeddata", handleTracksChange);
+ player.addEventListener("subtitletrackchange", persistSubtitlePreference);
+
+ scheduleApply();
+
+ return () => {
+ disposed = true;
+ clearTimers();
+ player.removeEventListener("trackschange", handleTracksChange);
+ player.removeEventListener("loadeddata", handleTracksChange);
+ player.removeEventListener(
+ "subtitletrackchange",
+ persistSubtitlePreference,
+ );
+ };
+ }, [
+ options?.preferEnglishSubtitles,
+ options?.preferredAudioLang,
+ player,
+ scopeKey,
+ sourceKey,
+ ]);
+}
diff --git a/apps/web/hooks/use-movi-preview.ts b/apps/web/hooks/use-movi-preview.ts
new file mode 100644
index 00000000..b56f642e
--- /dev/null
+++ b/apps/web/hooks/use-movi-preview.ts
@@ -0,0 +1,85 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+
+export type PlayerEngine = "vidstack" | "movi";
+
+export const PLAYER_ENGINE_STORAGE_KEY = "nyumat:playerEngine";
+export const PLAYER_ENGINE_CHANGE_EVENT = "nyumat:player-engine-change";
+export const DEFAULT_PLAYER_ENGINE: PlayerEngine = "vidstack";
+
+export const readStoredPlayerEngine = (): PlayerEngine | null => {
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ try {
+ const stored = localStorage.getItem(PLAYER_ENGINE_STORAGE_KEY);
+ if (stored === "movi" || stored === "vidstack") {
+ return stored;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+};
+
+export const resolvePlayerEngine = (): PlayerEngine => {
+ return readStoredPlayerEngine() ?? DEFAULT_PLAYER_ENGINE;
+};
+
+export const writePlayerEnginePreference = (engine: PlayerEngine): void => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ localStorage.setItem(PLAYER_ENGINE_STORAGE_KEY, engine);
+ window.dispatchEvent(new CustomEvent(PLAYER_ENGINE_CHANGE_EVENT));
+ } catch {
+ // storage may be unavailable in private browsing
+ }
+};
+
+export const usePlayerEngine = () => {
+ const [engine, setEngine] = useState(() =>
+ typeof window !== "undefined"
+ ? resolvePlayerEngine()
+ : DEFAULT_PLAYER_ENGINE,
+ );
+
+ useEffect(() => {
+ const sync = () => {
+ setEngine(resolvePlayerEngine());
+ };
+
+ window.addEventListener(PLAYER_ENGINE_CHANGE_EVENT, sync);
+ const onStorage = (event: StorageEvent) => {
+ if (event.key === PLAYER_ENGINE_STORAGE_KEY) {
+ sync();
+ }
+ };
+ window.addEventListener("storage", onStorage);
+
+ return () => {
+ window.removeEventListener(PLAYER_ENGINE_CHANGE_EVENT, sync);
+ window.removeEventListener("storage", onStorage);
+ };
+ }, []);
+
+ const setPlayerEngine = useCallback((next: PlayerEngine) => {
+ writePlayerEnginePreference(next);
+ setEngine(next);
+ }, []);
+
+ return {
+ engine,
+ isMovi: engine === "movi",
+ setPlayerEngine,
+ };
+};
+
+export const useMoviPreview = (): boolean => {
+ const { isMovi } = usePlayerEngine();
+ return isMovi;
+};
diff --git a/apps/web/hooks/use-movie-watch-button-label.ts b/apps/web/hooks/use-movie-watch-button-label.ts
new file mode 100644
index 00000000..7cc6420d
--- /dev/null
+++ b/apps/web/hooks/use-movie-watch-button-label.ts
@@ -0,0 +1,22 @@
+"use client";
+
+import { useIsHydrated } from "@/hooks/use-is-hydrated";
+import { usePlaybackProgressRevision } from "@/hooks/use-playback-progress-revision";
+import { movieWatchButtonLabel } from "@/lib/playback/movie-watch-label";
+import { useMemo } from "react";
+
+export function useMovieWatchButtonLabel(
+ contentId: number,
+ mediaType?: "tv" | "movie",
+): "Play" | "Resume" {
+ const isHydrated = useIsHydrated();
+ const progressRevision = usePlaybackProgressRevision();
+
+ return useMemo(() => {
+ if (!isHydrated || mediaType !== "movie") {
+ return "Play";
+ }
+
+ return movieWatchButtonLabel(contentId);
+ }, [contentId, isHydrated, mediaType, progressRevision]);
+}
diff --git a/apps/web/hooks/use-playback-progress-revision.ts b/apps/web/hooks/use-playback-progress-revision.ts
new file mode 100644
index 00000000..93590262
--- /dev/null
+++ b/apps/web/hooks/use-playback-progress-revision.ts
@@ -0,0 +1,14 @@
+"use client";
+
+import {
+ getPlaybackProgressRevision,
+ subscribePlaybackProgressChanged,
+} from "@/lib/playback/progress-change-events";
+import { useSyncExternalStore } from "react";
+
+export const usePlaybackProgressRevision = (): number =>
+ useSyncExternalStore(
+ subscribePlaybackProgressChanged,
+ getPlaybackProgressRevision,
+ () => 0,
+ );
diff --git a/apps/web/hooks/use-playback-progress.ts b/apps/web/hooks/use-playback-progress.ts
index 0fd037d2..ee330cfe 100644
--- a/apps/web/hooks/use-playback-progress.ts
+++ b/apps/web/hooks/use-playback-progress.ts
@@ -1,10 +1,11 @@
"use client";
-import { useCallback, useRef } from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
import {
clampPlaybackProgress,
getPlaybackProgress,
+ progressStorageKey,
resolveResumeTime,
setPlaybackProgress,
shouldPersistPlaybackProgress,
@@ -17,12 +18,21 @@ const SAVE_INTERVAL_MS = 5_000;
const WATCHLIST_SYNC_INTERVAL_MS = 30_000;
export function usePlaybackProgress(key: PlaybackProgressKey) {
- const savedEntryRef = useRef(getPlaybackProgress(key));
+ const storageKey = progressStorageKey(key);
const lastSavedAtRef = useRef(0);
const lastWatchlistSyncRef = useRef(0);
const watchlistSyncedRef = useRef(false);
- const resumeTime = resolveResumeTime(savedEntryRef.current);
+ useEffect(() => {
+ lastSavedAtRef.current = 0;
+ lastWatchlistSyncRef.current = 0;
+ watchlistSyncedRef.current = false;
+ }, [storageKey]);
+
+ const resumeTime = useMemo(
+ () => resolveResumeTime(getPlaybackProgress(key)),
+ [key, storageKey],
+ );
const syncWatchlist = useCallback(async () => {
await postWatchProgressIfSignedIn({
@@ -94,9 +104,12 @@ export function usePlaybackProgress(key: PlaybackProgressKey) {
[key.mediaType, persist, syncWatchlist],
);
- return {
- resumeTime,
- persist,
- persistImmediate,
- };
+ return useMemo(
+ () => ({
+ resumeTime,
+ persist,
+ persistImmediate,
+ }),
+ [resumeTime, persist, persistImmediate],
+ );
}
diff --git a/apps/web/hooks/use-playback-track-preferences.ts b/apps/web/hooks/use-playback-track-preferences.ts
index 99a41fdd..1966eb32 100644
--- a/apps/web/hooks/use-playback-track-preferences.ts
+++ b/apps/web/hooks/use-playback-track-preferences.ts
@@ -191,6 +191,7 @@ export function usePlaybackTrackPreferences(
progressKey: PlaybackProgressKey,
sourceKey: string,
preferredAudioLang?: string | null,
+ options?: { preferEnglishSubtitles?: boolean },
) {
const scopeKey = useMemo(
() => trackPreferenceStorageKey(progressKey),
@@ -219,7 +220,10 @@ export function usePlaybackTrackPreferences(
try {
if (preferences) {
applySubtitlePreference(player, preferences.subtitleLang);
- } else if (isJapaneseAudioPreference(preferredAudioLang)) {
+ } else if (
+ options?.preferEnglishSubtitles ||
+ isJapaneseAudioPreference(preferredAudioLang)
+ ) {
applySubtitlePreference(player, "english");
}
@@ -303,12 +307,14 @@ export function usePlaybackTrackPreferences(
}
};
- textTracks.addEventListener("add", applySaved);
- textTracks.addEventListener("mode-change", persistSubtitlePreference);
- audioTracks?.addEventListener("add", () => {
+ const handleAudioTracksAdd = () => {
normalizeAudioTracks();
applySaved();
- });
+ };
+
+ textTracks.addEventListener("add", applySaved);
+ textTracks.addEventListener("mode-change", persistSubtitlePreference);
+ audioTracks?.addEventListener("add", handleAudioTracksAdd);
audioTracks?.addEventListener("change", persistAudioPreference);
normalizeAudioTracks();
@@ -320,7 +326,7 @@ export function usePlaybackTrackPreferences(
"mode-change",
persistSubtitlePreference,
);
- audioTracks?.removeEventListener("add", applySaved);
+ audioTracks?.removeEventListener("add", handleAudioTracksAdd);
audioTracks?.removeEventListener("change", persistAudioPreference);
};
};
@@ -340,5 +346,11 @@ export function usePlaybackTrackPreferences(
disposed = true;
cleanup?.();
};
- }, [playerRef, preferredAudioLang, scopeKey, sourceKey]);
+ }, [
+ options?.preferEnglishSubtitles,
+ playerRef,
+ preferredAudioLang,
+ scopeKey,
+ sourceKey,
+ ]);
}
diff --git a/apps/web/hooks/use-provider-scrape-loop.ts b/apps/web/hooks/use-provider-scrape-loop.ts
index 9bdaea72..40c5339a 100644
--- a/apps/web/hooks/use-provider-scrape-loop.ts
+++ b/apps/web/hooks/use-provider-scrape-loop.ts
@@ -23,11 +23,21 @@ import {
} from "@/lib/scrape/provider-race";
import {
classifySiblingCancel,
+ hasPendingStartupProbes,
shouldFinalizeBatchWinner,
shouldFinalizeFirstPreferenceMatch,
+ shouldFinalizeFirstPreferenceMatchWithStartup,
shouldFinalizeRaceWinner,
type ScrapeCancelReason,
} from "@/lib/scrape/scrape-race-batch";
+import {
+ probeClientPlaybackStartup,
+ prefetchScrapePlayback,
+} from "@/lib/scrape/client-playback-startup-probe";
+import {
+ scorePayloadWithStartupProbe,
+ pickBestCachedFallback,
+} from "@/lib/scrape/playback-startup-score";
import type {
ScrapeItem,
ScrapeItemStatus,
@@ -99,6 +109,8 @@ export type ProviderScrapeLoopConfig<
scoreRacePayload?: (payload: _TPayload) => number;
/** Start playback on the first success that satisfies these hard prefs. */
matchesPlaybackPreferences?: (payload: _TPayload) => boolean;
+ /** Probe master → first segment in-browser; prefer fastest buffer path. */
+ raceStartupProbe?: boolean;
harvestSubtitleProviders?: (
winnerId: TProviderId,
order: readonly TProviderId[],
@@ -136,6 +148,7 @@ export function useProviderScrapeLoop<
retryDelayMs = DEFAULT_SCRAPE_RETRY_DELAY_MS,
scoreRacePayload,
matchesPlaybackPreferences,
+ raceStartupProbe = false,
harvestSubtitleProviders,
} = config;
@@ -144,6 +157,20 @@ export function useProviderScrapeLoop<
const useScoredRaceWinner =
Boolean(scoreRacePayload) && !raceFirstWin && !usePreferenceMatchFirstWin;
const shouldRaceFirstWin = raceFirstWin;
+ const useStartupAwareRace = raceStartupProbe && Boolean(scoreRacePayload);
+
+ const scoreRacePayloadWithStartup = useCallback(
+ (payload: TPayload): number => {
+ if (!scoreRacePayload) {
+ return 0;
+ }
+ const base = scoreRacePayload(payload);
+ return useStartupAwareRace
+ ? scorePayloadWithStartupProbe(base, payload)
+ : base;
+ },
+ [scoreRacePayload, useStartupAwareRace],
+ );
const activeProviderOrderRef = useRef(providerOrder);
@@ -251,6 +278,16 @@ export function useProviderScrapeLoop<
}
if (failed.has(providerId)) {
+ const previousStatus = statusByProvider.get(providerId);
+ if (previousStatus === "skipped") {
+ return {
+ providerId,
+ name: providerLabels[providerId],
+ status: "skipped" as ScrapeItemStatus,
+ error: undefined,
+ };
+ }
+
return {
providerId,
name: providerLabels[providerId],
@@ -335,7 +372,10 @@ export function useProviderScrapeLoop<
input: TInput,
runId: number,
signal: AbortSignal,
- options: { quiet?: boolean; maxRetries?: number } = {},
+ options: {
+ quiet?: boolean;
+ maxRetries?: number;
+ } = {},
): Promise> => {
const quiet = options.quiet === true;
const retries = options.maxRetries ?? retryAttempts;
@@ -367,7 +407,9 @@ export function useProviderScrapeLoop<
try {
response = await fetchWithCapSession(apiPath, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ },
body: JSON.stringify(buildRequestBody(providerId, input)),
signal,
});
@@ -412,7 +454,6 @@ export function useProviderScrapeLoop<
: "Unknown error";
isUnavailable = rawPayload.unavailable === true;
- // Non-retryable permanent failures
if (
isUnavailable ||
response.status === 403 ||
@@ -611,20 +652,20 @@ export function useProviderScrapeLoop<
return null;
}
- for (const providerId of order) {
- if (failed.has(providerId) || excludeIds.has(providerId)) {
- continue;
- }
-
- const payload = cache.get(providerId);
- if (payload) {
- return { providerId, payload };
- }
- }
+ const scoreFn = useStartupAwareRace
+ ? scoreRacePayloadWithStartup
+ : scoreRacePayload;
- return null;
+ return pickBestCachedFallback({
+ order,
+ cache,
+ failed,
+ excludeIds,
+ scorePayload: scoreFn,
+ requireStartupProbePass: useStartupAwareRace,
+ });
},
- [],
+ [scoreRacePayload, scoreRacePayloadWithStartup, useStartupAwareRace],
);
const startBackgroundHarvest = useCallback(
@@ -900,6 +941,49 @@ export function useProviderScrapeLoop<
resolveBatchEarly = resolve;
});
+ const scheduleStartupProbe = (
+ providerId: TProviderId,
+ payload: TPayload,
+ signal?: AbortSignal,
+ ) => {
+ if (!useStartupAwareRace) {
+ return;
+ }
+
+ const playUrl =
+ typeof payload.playUrl === "string" ? payload.playUrl : "";
+ if (!playUrl) {
+ return;
+ }
+
+ prefetchScrapePlayback(playUrl);
+
+ void probeClientPlaybackStartup(playUrl, { signal }).then((probe) => {
+ if (runId !== runIdRef.current || signal?.aborted) {
+ return;
+ }
+
+ const enriched = {
+ ...payload,
+ startupProbeMs: probe.ms,
+ startupProbeOk: probe.ok,
+ };
+ storePayloadCache(mediaKey, providerId, enriched);
+
+ const successEntry = successes.find(
+ (entry) => entry.providerId === providerId,
+ );
+ if (successEntry?.attempt.outcome === "success") {
+ successEntry.attempt.payload = enriched;
+ }
+
+ const winner = tryFinalizeWinner();
+ if (winner?.outcome === "success") {
+ resolveBatchEarly?.();
+ }
+ });
+ };
+
const tryFinalizeWinner = (): ScrapeAttemptResult | null => {
if (playbackLockedRef.current) {
return null;
@@ -910,20 +994,26 @@ export function useProviderScrapeLoop<
if (shouldRaceFirstWin) {
winnerEntry = shouldFinalizeRaceWinner(successes);
} else if (usePreferenceMatchFirstWin && matchesPlaybackPreferences) {
- winnerEntry = shouldFinalizeFirstPreferenceMatch(
- successes,
- matchesPlaybackPreferences,
- );
+ winnerEntry = useStartupAwareRace
+ ? shouldFinalizeFirstPreferenceMatchWithStartup(
+ successes,
+ matchesPlaybackPreferences,
+ )
+ : shouldFinalizeFirstPreferenceMatch(
+ successes,
+ matchesPlaybackPreferences,
+ );
if (
!winnerEntry &&
pending.size === 0 &&
successes.length > 0 &&
- scoreRacePayload
+ scoreRacePayload &&
+ (!useStartupAwareRace || !hasPendingStartupProbes(successes))
) {
winnerEntry = pickScoredRaceWinner(
order,
successes,
- scoreRacePayload,
+ scoreRacePayloadWithStartup,
);
}
} else {
@@ -931,7 +1021,10 @@ export function useProviderScrapeLoop<
order,
successes,
pending,
- useScoredRaceWinner ? scoreRacePayload : undefined,
+ useScoredRaceWinner || useStartupAwareRace
+ ? scoreRacePayloadWithStartup
+ : undefined,
+ useStartupAwareRace,
);
}
@@ -996,6 +1089,11 @@ export function useProviderScrapeLoop<
providerId,
attempt: { outcome: "success", payload: attempt.payload },
});
+ scheduleStartupProbe(
+ providerId,
+ attempt.payload,
+ controller.signal,
+ );
return { providerId, attempt, winner: tryFinalizeWinner() };
}
@@ -1061,8 +1159,14 @@ export function useProviderScrapeLoop<
if (!finalized) {
const winnerEntry =
scoreRacePayload &&
- (usePreferenceMatchFirstWin || useScoredRaceWinner)
- ? pickScoredRaceWinner(order, successes, scoreRacePayload)
+ (usePreferenceMatchFirstWin ||
+ useScoredRaceWinner ||
+ useStartupAwareRace)
+ ? pickScoredRaceWinner(
+ order,
+ successes,
+ scoreRacePayloadWithStartup,
+ )
: pickRaceWinner(order, successes);
if (winnerEntry?.attempt.payload) {
finalized = {
@@ -1233,6 +1337,7 @@ export function useProviderScrapeLoop<
scrapeProvider,
soloFirstProviders,
scoreRacePayload,
+ scoreRacePayloadWithStartup,
shouldRaceFirstWin,
startBackgroundHarvest,
storePayloadCache,
@@ -1240,6 +1345,7 @@ export function useProviderScrapeLoop<
updateItem,
usePreferenceMatchFirstWin,
useScoredRaceWinner,
+ useStartupAwareRace,
],
);
@@ -1345,7 +1451,7 @@ export function useProviderScrapeLoop<
(
input: TInput,
fromProviderId: TProviderId,
- failureReason = "Playback failed",
+ _failureReason = "Playback failed",
) => {
const mediaKey = mediaKeyFor(input);
const failed =
@@ -1354,8 +1460,8 @@ export function useProviderScrapeLoop<
failedProvidersRef.current.set(mediaKey, failed);
clearPreferredScrapeProvider(mediaKey);
updateItem(fromProviderId, {
- status: "failure",
- error: failureReason,
+ status: "skipped" as ScrapeItemStatus,
+ error: undefined,
});
const order = deprioritizeProviders(getOrderForInput(input), failed);
diff --git a/apps/web/hooks/use-recently-watched.ts b/apps/web/hooks/use-recently-watched.ts
index a6d46f03..adffc32d 100644
--- a/apps/web/hooks/use-recently-watched.ts
+++ b/apps/web/hooks/use-recently-watched.ts
@@ -23,11 +23,14 @@ import {
type RecentlyWatchedScope,
type RecentlyWatchedStub,
} from "@/lib/playback/recently-watched";
+import { usePlaybackProgressRevision } from "@/hooks/use-playback-progress-revision";
+import { useIsHydrated } from "@/hooks/use-is-hydrated";
import { applyContinueWatchingComplete } from "@/lib/watchlist/apply-continue-watching-complete";
+import { fetchTvShowDetailClient } from "@/lib/tv-show-detail-client";
import { isAnime } from "@/utils/anilist-helpers";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
type MediaDetailResponse = {
@@ -64,13 +67,42 @@ function detectAnime(detail: MediaDetailResponse): boolean {
async function fetchMediaDetail(
stub: RecentlyWatchedStub,
): Promise {
- const path =
- stub.mediaType === "movie"
- ? `/api/movies/${stub.contentId}`
- : `/api/tv/${stub.contentId}`;
+ if (stub.mediaType === "movie") {
+ const response = await fetch(`/api/movies/${stub.contentId}`);
+ if (!response.ok) {
+ if (stub.title) {
+ return toRecentlyWatchedItem(stub, {
+ title: stub.title,
+ backdropPath: stub.backdropPath,
+ posterPath: stub.posterPath,
+ isAnime: false,
+ });
+ }
+ return null;
+ }
- const response = await fetch(path);
- if (!response.ok) {
+ const detail = (await response.json()) as MediaDetailResponse;
+ const title = detail.title ?? stub.title;
+ if (!title) {
+ return null;
+ }
+
+ return toRecentlyWatchedItem(stub, {
+ title,
+ backdropPath: detail.backdrop_path ?? stub.backdropPath,
+ posterPath: detail.poster_path ?? stub.posterPath,
+ voteAverage: detail.vote_average ?? stub.voteAverage,
+ year: detail.release_date
+ ? String(new Date(detail.release_date).getFullYear())
+ : stub.year,
+ isAnime: false,
+ });
+ }
+
+ const fetched = await fetchTvShowDetailClient(stub.contentId, {
+ expectedTitle: stub.title,
+ });
+ if (!fetched) {
if (stub.title) {
return toRecentlyWatchedItem(stub, {
title: stub.title,
@@ -82,29 +114,21 @@ async function fetchMediaDetail(
return null;
}
- const detail = (await response.json()) as MediaDetailResponse;
- const title =
- stub.mediaType === "movie"
- ? (detail.title ?? stub.title)
- : (detail.name ?? stub.title);
-
+ const detail = fetched.detail;
+ const title = detail.name ?? stub.title;
if (!title) {
return null;
}
- const date =
- stub.mediaType === "movie" ? detail.release_date : detail.first_air_date;
-
- const tvFields =
- stub.mediaType === "tv" ? tvCompleteFieldsFromDetail(detail) : {};
+ const tvFields = tvCompleteFieldsFromDetail(detail);
return toRecentlyWatchedItem(stub, {
title,
backdropPath: detail.backdrop_path ?? stub.backdropPath,
posterPath: detail.poster_path ?? stub.posterPath,
voteAverage: detail.vote_average,
- year: date?.substring(0, 4),
- isAnime: detectAnime(detail),
+ year: detail.first_air_date?.substring(0, 4) ?? stub.year,
+ isAnime: fetched.catalog === "anime" || detectAnime(detail),
...tvFields,
});
}
@@ -122,7 +146,8 @@ export function useRecentlyWatched(scope: RecentlyWatchedScope = "all") {
const queryClient = useQueryClient();
const { data: session, status: sessionStatus } = useSession();
const isSignedIn = Boolean(session?.user?.id);
- const [hydrated, setHydrated] = useState(false);
+ const hydrated = useIsHydrated();
+ const progressRevision = usePlaybackProgressRevision();
const [dismissTick, setDismissTick] = useState(0);
const [pendingCompleteKey, setPendingCompleteKey] = useState(
null,
@@ -133,10 +158,6 @@ export function useRecentlyWatched(scope: RecentlyWatchedScope = "all") {
? RECENTLY_WATCHED_LIMIT * 2
: RECENTLY_WATCHED_LIMIT;
- useEffect(() => {
- setHydrated(true);
- }, []);
-
const watchlistQuery = useQuery({
queryKey: queryKeys.watchlist(),
queryFn: fetchWatchlistItems,
@@ -153,7 +174,14 @@ export function useRecentlyWatched(scope: RecentlyWatchedScope = "all") {
mediaTypes,
dismissals: readContinueWatchingDismissals(),
});
- }, [hydrated, watchlistQuery.data, collectLimit, mediaTypes, dismissTick]);
+ }, [
+ hydrated,
+ watchlistQuery.data,
+ collectLimit,
+ mediaTypes,
+ dismissTick,
+ progressRevision,
+ ]);
const stubsKey = useMemo(
() =>
diff --git a/apps/web/hooks/use-scroll-fetch-lock.ts b/apps/web/hooks/use-scroll-fetch-lock.ts
new file mode 100644
index 00000000..d0638f91
--- /dev/null
+++ b/apps/web/hooks/use-scroll-fetch-lock.ts
@@ -0,0 +1,47 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+
+type ScrollFetchLock = {
+ beginFetch: () => number | null;
+ endFetch: (generation: number) => void;
+ isCurrent: (generation: number) => boolean;
+};
+
+export const useScrollFetchLock = (): ScrollFetchLock => {
+ const isFetchingRef = useRef(false);
+ const generationRef = useRef(0);
+
+ const beginFetch = useCallback((): number | null => {
+ if (isFetchingRef.current) {
+ return null;
+ }
+
+ isFetchingRef.current = true;
+ generationRef.current += 1;
+ return generationRef.current;
+ }, []);
+
+ const endFetch = useCallback((generation: number) => {
+ if (generation !== generationRef.current) {
+ return;
+ }
+
+ isFetchingRef.current = false;
+ }, []);
+
+ const isCurrent = useCallback(
+ (generation: number) => generation === generationRef.current,
+ [],
+ );
+
+ useEffect(
+ () => () => {
+ generationRef.current += 1;
+ isFetchingRef.current = false;
+ },
+ [],
+ );
+
+ return { beginFetch, endFetch, isCurrent };
+};
diff --git a/apps/web/hooks/use-search-autocomplete.ts b/apps/web/hooks/use-search-autocomplete.ts
index 83eca57e..7f7928f1 100644
--- a/apps/web/hooks/use-search-autocomplete.ts
+++ b/apps/web/hooks/use-search-autocomplete.ts
@@ -44,6 +44,16 @@ export function useSearchAutocomplete({
const listboxId = useId();
const [selectedIndex, setSelectedIndex] = useState(-1);
const [focusTarget, setFocusTarget] = useState<"input" | "list">("input");
+ const resultsKey = useMemo(
+ () => results.map((result) => result.id).join("|"),
+ [results],
+ );
+
+ useEffect(() => {
+ setSelectedIndex(-1);
+ setFocusTarget("input");
+ }, [query, resultsKey, footer]);
+
const includeFooter = footer !== "none";
const itemCount = getSearchAutocompleteItemCount(results, {
@@ -85,11 +95,6 @@ export function useSearchAutocomplete({
inputRef?.current?.focus();
}, [inputRef]);
- useEffect(() => {
- setSelectedIndex(-1);
- setFocusTarget("input");
- }, [query, results, footer]);
-
const handleInputFocus = useCallback(() => {
setFocusTarget("input");
}, []);
diff --git a/apps/web/hooks/use-search-keyboard-shortcuts.ts b/apps/web/hooks/use-search-keyboard-shortcuts.ts
index dfcb76e4..a08c9018 100644
--- a/apps/web/hooks/use-search-keyboard-shortcuts.ts
+++ b/apps/web/hooks/use-search-keyboard-shortcuts.ts
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, type RefObject } from "react";
+import { useEffect, useRef, type RefObject } from "react";
type UseSearchKeyboardShortcutsOptions = {
inputRef: RefObject;
@@ -19,6 +19,9 @@ export function useSearchKeyboardShortcuts({
focusOnModK = true,
onEscape,
}: UseSearchKeyboardShortcutsOptions) {
+ const onEscapeRef = useRef(onEscape);
+ onEscapeRef.current = onEscape;
+
useEffect(() => {
if (!enabled) {
return;
@@ -39,7 +42,7 @@ export function useSearchKeyboardShortcuts({
}
if (event.key === "Escape") {
- onEscape?.();
+ onEscapeRef.current?.();
inputRef.current?.blur();
}
@@ -68,12 +71,5 @@ export function useSearchKeyboardShortcuts({
const target = focusOnModK ? document : window;
target.addEventListener("keydown", handleKeyDown);
return () => target.removeEventListener("keydown", handleKeyDown);
- }, [
- enabled,
- focusOnModK,
- focusOnPrintable,
- focusOnSlash,
- inputRef,
- onEscape,
- ]);
+ }, [enabled, focusOnModK, focusOnPrintable, focusOnSlash, inputRef]);
}
diff --git a/apps/web/hooks/use-subtitle-appearance.ts b/apps/web/hooks/use-subtitle-appearance.ts
index 8bcefd79..cd41e635 100644
--- a/apps/web/hooks/use-subtitle-appearance.ts
+++ b/apps/web/hooks/use-subtitle-appearance.ts
@@ -1,6 +1,11 @@
"use client";
-import { useCallback, useEffect, useState, type RefObject } from "react";
+import {
+ useCallback,
+ useEffect,
+ useSyncExternalStore,
+ type RefObject,
+} from "react";
import type { MediaPlayerInstance } from "@vidstack/react";
import {
@@ -14,7 +19,7 @@ import {
getSubtitleAppearance,
resetSubtitleAppearance,
setSubtitleAppearance,
- SUBTITLE_APPEARANCE_CHANGE_EVENT,
+ subscribeSubtitleAppearance,
} from "@/lib/playback/subtitle-appearance-storage";
export function useSubtitleAppearance(
@@ -24,32 +29,12 @@ export function useSubtitleAppearance(
setAppearance: (patch: Partial) => void;
resetAppearance: () => void;
} {
- const [appearance, setAppearanceState] = useState(
- DEFAULT_SUBTITLE_APPEARANCE,
+ const appearance = useSyncExternalStore(
+ subscribeSubtitleAppearance,
+ getSubtitleAppearance,
+ () => DEFAULT_SUBTITLE_APPEARANCE,
);
- useEffect(() => {
- setAppearanceState(getSubtitleAppearance());
-
- const handleExternalChange = () => {
- setAppearanceState(getSubtitleAppearance());
- };
-
- window.addEventListener(
- SUBTITLE_APPEARANCE_CHANGE_EVENT,
- handleExternalChange,
- );
- window.addEventListener("storage", handleExternalChange);
-
- return () => {
- window.removeEventListener(
- SUBTITLE_APPEARANCE_CHANGE_EVENT,
- handleExternalChange,
- );
- window.removeEventListener("storage", handleExternalChange);
- };
- }, []);
-
const applyToPlayer = useCallback(
(next: SubtitleAppearance) => {
const player = playerRef?.current;
@@ -64,35 +49,14 @@ export function useSubtitleAppearance(
useEffect(() => {
if (playerRef) {
- applyToPlayer(appearance);
+ const frame = requestAnimationFrame(() => applyToPlayer(appearance));
+ return () => cancelAnimationFrame(frame);
}
}, [appearance, applyToPlayer, playerRef]);
- useEffect(() => {
- if (!playerRef) {
- return;
- }
-
- const player = playerRef.current;
- if (!player) {
- return;
- }
-
- const frame = requestAnimationFrame(() => {
- applyToPlayer(appearance);
- });
-
- return () => {
- cancelAnimationFrame(frame);
- };
- }, [appearance, applyToPlayer, playerRef]);
-
const setAppearance = useCallback((patch: Partial) => {
- setAppearanceState((current) => {
- const next = mergeSubtitleAppearance(current, patch);
- setSubtitleAppearance(next);
- return next;
- });
+ const current = getSubtitleAppearance();
+ setSubtitleAppearance(mergeSubtitleAppearance(current, patch));
}, []);
const resetAppearance = useCallback(() => {
@@ -102,7 +66,6 @@ export function useSubtitleAppearance(
clearSubtitleAppearanceFromElement(player.el);
applySubtitleAppearanceToElement(player.el, next);
}
- setAppearanceState(next);
}, [playerRef]);
return {
diff --git a/apps/web/hooks/use-tv-episode-hydrate.ts b/apps/web/hooks/use-tv-episode-hydrate.ts
index 2036ee61..f32b811c 100644
--- a/apps/web/hooks/use-tv-episode-hydrate.ts
+++ b/apps/web/hooks/use-tv-episode-hydrate.ts
@@ -1,7 +1,7 @@
"use client";
import { useQuery } from "@tanstack/react-query";
-import { useEffect, useMemo } from "react";
+import { useEffect, useMemo, useRef } from "react";
import { fetchSeasonDetails } from "@/components/tvshow/tvshow-api";
import type { WatchlistItem } from "@/lib/domain/watchlist";
@@ -9,10 +9,13 @@ import type { Episode } from "@/lib/domain/typings";
import { queryStaleTime } from "@/lib/cache-policy";
import { queryKeys } from "@/lib/query-keys";
import { useEpisodeStore } from "@/lib/stores/episode-store";
+import { useLocalTvWatchCoords } from "@/hooks/use-local-tv-watch-coords";
import {
isSameTvWatchTarget,
+ isTvCoordsWatchTarget,
resolveTvWatchTarget,
} from "@/lib/tv-watch-target";
+import { resolveEpisodeAnimeSelection } from "@/lib/anime/episode-playback-source";
type UseTvEpisodeHydrateOptions = {
mediaId: number;
@@ -31,6 +34,8 @@ export function useTvEpisodeHydrate({
}: UseTvEpisodeHydrateOptions) {
const { selectedEpisode, tvShowId, seasonNumber, setSelectedEpisode } =
useEpisodeStore();
+ const localCoords = useLocalTvWatchCoords(mediaId);
+ const hydrateGenerationRef = useRef(0);
const target = useMemo(() => {
if (mediaType !== "tv") {
@@ -43,10 +48,12 @@ export function useTvEpisodeHydrate({
watchlistItem,
initialEpisode,
initialSeasonNumber,
+ localCoords,
);
}, [
initialEpisode,
initialSeasonNumber,
+ localCoords,
mediaId,
mediaType,
seasonNumber,
@@ -58,38 +65,50 @@ export function useTvEpisodeHydrate({
const contentIdStr = mediaId.toString();
const shouldSkipHydrate =
target != null &&
- target.source !== "watchlist" &&
+ !isTvCoordsWatchTarget(target) &&
isSameTvWatchTarget(
target,
{ selectedEpisode, tvShowId, seasonNumber },
mediaId,
);
+ const coordsTarget = target && isTvCoordsWatchTarget(target) ? target : null;
const seasonQuery = useQuery({
- queryKey: queryKeys.tvSeason(
- mediaId,
- target?.source === "watchlist" ? target.seasonNumber : 0,
+ queryKey: queryKeys.tvSeasonRoute(
+ contentIdStr,
+ coordsTarget?.seasonNumber ?? 0,
),
queryFn: () =>
- fetchSeasonDetails(
- contentIdStr,
- (target as { seasonNumber: number }).seasonNumber,
- ),
- enabled:
- mediaType === "tv" &&
- target?.source === "watchlist" &&
- !shouldSkipHydrate,
+ fetchSeasonDetails(contentIdStr, coordsTarget?.seasonNumber ?? 0),
+ enabled: mediaType === "tv" && coordsTarget != null && !shouldSkipHydrate,
staleTime: queryStaleTime(10 * 60 * 1000),
});
useEffect(() => {
+ const generation = ++hydrateGenerationRef.current;
+
if (mediaType !== "tv" || !target || shouldSkipHydrate) {
return;
}
- if (target.source === "watchlist") {
+ const isStale = () => generation !== hydrateGenerationRef.current;
+
+ const matchesTarget = () => {
+ const state = useEpisodeStore.getState();
+ return isSameTvWatchTarget(
+ target,
+ {
+ selectedEpisode: state.selectedEpisode,
+ tvShowId: state.tvShowId,
+ seasonNumber: state.seasonNumber,
+ },
+ mediaId,
+ );
+ };
+
+ if (isTvCoordsWatchTarget(target)) {
const seasonData = seasonQuery.data;
- if (!seasonData) {
+ if (!seasonData || isStale()) {
return;
}
@@ -98,55 +117,42 @@ export function useTvEpisodeHydrate({
(item) => item.episode_number === target.episodeNumber,
) ?? null;
- if (!episode) {
+ if (!episode || isStale() || matchesTarget()) {
return;
}
- if (
- isSameTvWatchTarget(
- target,
- {
- selectedEpisode: useEpisodeStore.getState().selectedEpisode,
- tvShowId: useEpisodeStore.getState().tvShowId,
- seasonNumber: useEpisodeStore.getState().seasonNumber,
- },
- mediaId,
- )
- ) {
- return;
- }
+ const embeddedSelection = resolveEpisodeAnimeSelection(episode, {
+ animeSeasonNumber: target.seasonNumber,
+ });
setSelectedEpisode(
episode,
contentIdStr,
target.seasonNumber,
- undefined,
+ embeddedSelection?.animeInfo,
true,
seasonData.episodes,
+ embeddedSelection?.mapping,
);
return;
}
- if (
- isSameTvWatchTarget(
- target,
- {
- selectedEpisode: useEpisodeStore.getState().selectedEpisode,
- tvShowId: useEpisodeStore.getState().tvShowId,
- seasonNumber: useEpisodeStore.getState().seasonNumber,
- },
- mediaId,
- )
- ) {
+ if (isStale() || matchesTarget()) {
return;
}
+ const embeddedSelection = resolveEpisodeAnimeSelection(target.episode, {
+ animeSeasonNumber: target.seasonNumber,
+ });
+
setSelectedEpisode(
target.episode,
contentIdStr,
target.seasonNumber,
- undefined,
+ embeddedSelection?.animeInfo,
true,
+ undefined,
+ embeddedSelection?.mapping,
);
}, [
contentIdStr,
diff --git a/apps/web/hooks/use-up-next-inbox.ts b/apps/web/hooks/use-up-next-inbox.ts
index 032a04b2..1d299763 100644
--- a/apps/web/hooks/use-up-next-inbox.ts
+++ b/apps/web/hooks/use-up-next-inbox.ts
@@ -3,6 +3,7 @@
import type { WatchlistItem } from "@/lib/domain/watchlist";
import type { EpisodeInfo } from "@/lib/domain/episodes";
import {
+ buildAnimeUpNextHref,
buildUpNextHref,
collectUpNextCandidates,
parseEpisodeDataResponse,
@@ -13,6 +14,7 @@ import {
toRecentlyWatchedItem,
type RecentlyWatchedItem,
} from "@/lib/playback/recently-watched";
+import { fetchTvShowDetailClient } from "@/lib/tv-show-detail-client";
import { isAnime } from "@/utils/anilist-helpers";
import { useQuery } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
@@ -23,17 +25,6 @@ export type UpNextInboxItem = RecentlyWatchedItem & {
upNextHref: string;
};
-type MediaDetailResponse = {
- id?: number;
- name?: string;
- backdrop_path?: string | null;
- poster_path?: string | null;
- vote_average?: number;
- first_air_date?: string;
- genre_ids?: number[];
- genres?: Array<{ id: number; name?: string }>;
-};
-
async function fetchWatchlistItems(): Promise {
const response = await fetch("/api/watchlist");
if (response.status === 401) {
@@ -65,21 +56,19 @@ async function fetchUpNextEpisodeData(): Promise> {
async function enrichUpNextCandidate(
candidate: UpNextCandidate,
): Promise {
- const response = await fetch(`/api/tv/${candidate.contentId}`);
- if (!response.ok) {
- return null;
- }
-
- const detail = (await response.json()) as MediaDetailResponse;
- if (detail.id !== candidate.contentId) {
+ const fetched = await fetchTvShowDetailClient(candidate.contentId);
+ if (!fetched) {
return null;
}
+ const detail = fetched.detail;
const title = detail.name;
if (!title) {
return null;
}
+ const isAnimeItem = fetched.catalog === "anime" || isAnime(detail);
+
const stub = {
mediaType: "tv" as const,
contentId: candidate.contentId,
@@ -103,15 +92,22 @@ async function enrichUpNextCandidate(
posterPath: detail.poster_path,
voteAverage: detail.vote_average,
year: detail.first_air_date?.substring(0, 4),
- isAnime: isAnime(detail),
+ isAnime: isAnimeItem,
});
- const upNextHref = buildUpNextHref(
- candidate.contentId,
- candidate.episodeInfo.nextUnwatchedEpisode,
- candidate.watchlistItem.lastWatchedSeason,
- candidate.watchlistItem.lastWatchedEpisode,
- );
+ const upNextHref = isAnimeItem
+ ? buildAnimeUpNextHref(
+ candidate.contentId,
+ candidate.episodeInfo.nextUnwatchedEpisode,
+ candidate.watchlistItem.lastWatchedSeason,
+ candidate.watchlistItem.lastWatchedEpisode,
+ )
+ : buildUpNextHref(
+ candidate.contentId,
+ candidate.episodeInfo.nextUnwatchedEpisode,
+ candidate.watchlistItem.lastWatchedSeason,
+ candidate.watchlistItem.lastWatchedEpisode,
+ );
return {
...item,
diff --git a/apps/web/hooks/use-validated-hero-backdrop.ts b/apps/web/hooks/use-validated-hero-backdrop.ts
index b17f5ae9..80e279b8 100644
--- a/apps/web/hooks/use-validated-hero-backdrop.ts
+++ b/apps/web/hooks/use-validated-hero-backdrop.ts
@@ -42,8 +42,6 @@ export const useValidatedHeroBackdrop = ({
useEffect(() => {
if (!preferPosterWhenNoBackdrop) {
- setBackdropAccepted(true);
- lastAcceptedRef.current = true;
return;
}
diff --git a/apps/web/hooks/use-vidsrc-progress.ts b/apps/web/hooks/use-vidsrc-progress.ts
index 3a78f71c..fb3f8d38 100644
--- a/apps/web/hooks/use-vidsrc-progress.ts
+++ b/apps/web/hooks/use-vidsrc-progress.ts
@@ -1,33 +1,18 @@
"use client";
-import { VIDSRC_PROGRESS_STORAGE_KEY } from "@/lib/playback/recently-watched";
+import {
+ isSingleVidsrcProgressEntry,
+ persistVidsrcProgressPayload,
+ type VidsrcProgressEntry,
+ type VidsrcProgressMap,
+ VIDSRC_PROGRESS_STORAGE_KEY,
+} from "@/lib/playback/vidsrc-progress-storage";
import { logger } from "@/lib/utils";
import { postWatchProgressIfSignedIn } from "@/lib/watchlist/post-watch-progress";
import { useEffect, useRef } from "react";
export { VIDSRC_PROGRESS_STORAGE_KEY };
-interface VidsrcProgress {
- watched: number;
- duration: number;
-}
-
-interface VidsrcProgressEntry {
- id: string;
- type: "movie" | "tv";
- title?: string;
- poster_path?: string;
- backdrop_path?: string;
- progress?: VidsrcProgress;
- last_updated?: number;
- number_of_episodes?: number;
- number_of_seasons?: number;
- last_season_watched?: string;
- last_episode_watched?: string;
-}
-
-type VidsrcProgressMap = Record;
-
interface VidsrcMediaMessage {
type: "MEDIA_DATA";
data: VidsrcProgressEntry | VidsrcProgressMap;
@@ -51,13 +36,6 @@ function isMediaMessage(data: unknown): data is VidsrcMediaMessage {
);
}
-function isSingleEntry(
- data: VidsrcProgressEntry | VidsrcProgressMap,
-): data is VidsrcProgressEntry {
- const candidate = data as VidsrcProgressEntry;
- return typeof candidate.id === "string" && typeof candidate.type === "string";
-}
-
/**
* Picks the most recently updated entry so we can sync a single item to the
* app's watchlist progress endpoint even when the player emits its full map.
@@ -65,7 +43,7 @@ function isSingleEntry(
function pickLatestEntry(
data: VidsrcProgressEntry | VidsrcProgressMap,
): VidsrcProgressEntry | null {
- if (isSingleEntry(data)) {
+ if (isSingleVidsrcProgressEntry(data)) {
return data;
}
@@ -139,10 +117,7 @@ export function useVidsrcProgress(): void {
const mediaData = event.data.data;
try {
- window.localStorage.setItem(
- VIDSRC_PROGRESS_STORAGE_KEY,
- JSON.stringify(mediaData),
- );
+ persistVidsrcProgressPayload(mediaData);
} catch (error) {
logger.error("Failed to persist VidSrc progress", error);
}
diff --git a/apps/web/hooks/use-watch-history.ts b/apps/web/hooks/use-watch-history.ts
index f733fa8d..c787d11e 100644
--- a/apps/web/hooks/use-watch-history.ts
+++ b/apps/web/hooks/use-watch-history.ts
@@ -12,9 +12,12 @@ import {
type RecentlyWatchedScope,
type RecentlyWatchedStub,
} from "@/lib/playback/recently-watched";
+import { usePlaybackProgressRevision } from "@/hooks/use-playback-progress-revision";
+import { useIsHydrated } from "@/hooks/use-is-hydrated";
+import { fetchTvShowDetailClient } from "@/lib/tv-show-detail-client";
import { isAnime } from "@/utils/anilist-helpers";
import { useQuery } from "@tanstack/react-query";
-import { useEffect, useMemo, useState } from "react";
+import { useMemo } from "react";
type MediaDetailResponse = {
title?: string;
@@ -43,13 +46,40 @@ async function fetchWatchlistItems(): Promise {
async function fetchMediaDetail(
stub: RecentlyWatchedStub,
): Promise {
- const path =
- stub.mediaType === "movie"
- ? `/api/movies/${stub.contentId}`
- : `/api/tv/${stub.contentId}`;
+ if (stub.mediaType === "movie") {
+ const response = await fetch(`/api/movies/${stub.contentId}`);
+ if (!response.ok) {
+ if (stub.title) {
+ return toRecentlyWatchedItem(stub, {
+ title: stub.title,
+ backdropPath: stub.backdropPath,
+ posterPath: stub.posterPath,
+ isAnime: false,
+ });
+ }
+ return null;
+ }
- const response = await fetch(path);
- if (!response.ok) {
+ const detail = (await response.json()) as MediaDetailResponse;
+ const title = detail.title ?? stub.title;
+ if (!title) {
+ return null;
+ }
+
+ return toRecentlyWatchedItem(stub, {
+ title,
+ backdropPath: detail.backdrop_path ?? stub.backdropPath,
+ posterPath: detail.poster_path ?? stub.posterPath,
+ voteAverage: detail.vote_average,
+ year: detail.release_date?.substring(0, 4),
+ isAnime: false,
+ });
+ }
+
+ const fetched = await fetchTvShowDetailClient(stub.contentId, {
+ expectedTitle: stub.title,
+ });
+ if (!fetched) {
if (stub.title) {
return toRecentlyWatchedItem(stub, {
title: stub.title,
@@ -61,26 +91,19 @@ async function fetchMediaDetail(
return null;
}
- const detail = (await response.json()) as MediaDetailResponse;
- const title =
- stub.mediaType === "movie"
- ? (detail.title ?? stub.title)
- : (detail.name ?? stub.title);
-
+ const detail = fetched.detail;
+ const title = detail.name ?? stub.title;
if (!title) {
return null;
}
- const date =
- stub.mediaType === "movie" ? detail.release_date : detail.first_air_date;
-
return toRecentlyWatchedItem(stub, {
title,
backdropPath: detail.backdrop_path ?? stub.backdropPath,
posterPath: detail.poster_path ?? stub.posterPath,
voteAverage: detail.vote_average,
- year: date?.substring(0, 4),
- isAnime: isAnime(detail),
+ year: detail.first_air_date?.substring(0, 4),
+ isAnime: fetched.catalog === "anime" || isAnime(detail),
});
}
@@ -107,17 +130,14 @@ async function enrichStubs(
}
export function useWatchHistory(scope: RecentlyWatchedScope = "all") {
- const [hydrated, setHydrated] = useState(false);
+ const hydrated = useIsHydrated();
+ const progressRevision = usePlaybackProgressRevision();
const mediaTypes = mediaTypesForScope(scope);
const collectLimit =
scope === "tv" || scope === "anime"
? WATCH_HISTORY_LIMIT * 2
: WATCH_HISTORY_LIMIT;
- useEffect(() => {
- setHydrated(true);
- }, []);
-
const watchlistQuery = useQuery({
queryKey: queryKeys.watchlist(),
queryFn: fetchWatchlistItems,
@@ -133,7 +153,13 @@ export function useWatchHistory(scope: RecentlyWatchedScope = "all") {
limit: collectLimit,
mediaTypes,
});
- }, [hydrated, watchlistQuery.data, collectLimit, mediaTypes]);
+ }, [
+ hydrated,
+ watchlistQuery.data,
+ collectLimit,
+ mediaTypes,
+ progressRevision,
+ ]);
const stubsKey = useMemo(
() =>
diff --git a/apps/web/hooks/useMedia.tsx b/apps/web/hooks/useMedia.tsx
index 1c01db77..a73dfd46 100644
--- a/apps/web/hooks/useMedia.tsx
+++ b/apps/web/hooks/useMedia.tsx
@@ -1,39 +1,35 @@
-import { useEffect, useState } from "react";
-import { isBrowser } from "@/lib/constants";
-
-const getInitialState = (query: string, defaultState?: boolean) => {
- if (defaultState !== undefined) return defaultState;
- if (isBrowser) return window.matchMedia(query).matches;
- if (process.env.NODE_ENV !== "production")
- console.warn(
- "`useMedia` When server side rendering, defaultState should be defined to prevent hydration mismatches.",
- );
- return false;
-};
+import { useCallback, useSyncExternalStore } from "react";
const useMedia = (query: string, defaultState?: boolean) => {
- const [state, setState] = useState(getInitialState(query, defaultState));
+ const subscribe = useCallback(
+ (onStoreChange: () => void) => {
+ const mediaQueryList = window.matchMedia(query);
+ mediaQueryList.addEventListener("change", onStoreChange);
+ return () => mediaQueryList.removeEventListener("change", onStoreChange);
+ },
+ [query],
+ );
+
+ const getSnapshot = useCallback(
+ () => window.matchMedia(query).matches,
+ [query],
+ );
- useEffect(() => {
- let mounted = true;
- const mql = window.matchMedia(query);
- const onChange = () => {
- if (!mounted) {
- return;
- }
- setState(!!mql.matches);
- };
+ const getServerSnapshot = useCallback(() => {
+ if (defaultState !== undefined) {
+ return defaultState;
+ }
- mql.addEventListener("change", onChange);
- setState(mql.matches);
+ if (process.env.NODE_ENV !== "production") {
+ console.warn(
+ "`useMedia` When server side rendering, defaultState should be defined to prevent hydration mismatches.",
+ );
+ }
- return () => {
- mounted = false;
- mql.removeEventListener("change", onChange);
- };
- }, [query]);
+ return false;
+ }, [defaultState]);
- return state;
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
};
export default useMedia;
diff --git a/apps/web/hooks/useMediaHero.ts b/apps/web/hooks/useMediaHero.ts
index 32521c69..7fd9888e 100644
--- a/apps/web/hooks/useMediaHero.ts
+++ b/apps/web/hooks/useMediaHero.ts
@@ -15,6 +15,7 @@ import type { MediaItem } from "@/lib/domain/typings";
import { getFirstRegularSeason, isTVShow } from "@/lib/domain/typings";
import { LegacyAnimationControls, useAnimation } from "framer-motion";
import { stabilizeScrollTop } from "@/components/layout/route-scroll-reset";
+import { withPageTvApiPath } from "@/lib/tv-detail-catalog";
import { usePathname, useSearchParams } from "next/navigation";
import {
useCallback,
@@ -99,7 +100,8 @@ export const useMediaHero = ({
const [isPlayingVideo, setIsPlayingVideo] = useState(false);
const [isPlayingTrailer, setIsPlayingTrailer] = useState(false);
const [youtubePlayer, setYoutubePlayer] = useState(null);
- const [historyLength, setHistoryLength] = useState(2);
+ const historyLength =
+ typeof window !== "undefined" ? window.history.length : 2;
const controls = useAnimation();
const gateAction = useAdblockGateAction();
@@ -210,10 +212,16 @@ export const useMediaHero = ({
if (firstSeason) {
seasonAbort = new AbortController();
try {
- const res = await fetch(
+ const seasonPath = withPageTvApiPath(
`/api/tv/${currentItem.id}/season/${firstSeason.season_number}`,
- { signal: seasonAbort.signal },
+ pathname,
);
+ const res = await fetch(seasonPath, {
+ signal: seasonAbort.signal,
+ });
+ if (!res.ok) {
+ return;
+ }
const seasonData = await res.json();
if (cancelled) {
return;
@@ -297,12 +305,6 @@ export const useMediaHero = ({
setIsPlayingVideo(false);
}, [onPlaybackStop]);
- useEffect(() => {
- if (typeof window !== "undefined") {
- setHistoryLength(window.history.length);
- }
- }, []);
-
const canPlayTrailer = useMemo(() => {
const rows = extractVideoRowsFromMediaVideos(currentItem?.videos);
if (selectPrimaryTrailerKey(rows)) {
diff --git a/apps/web/hooks/useSearchResults.ts b/apps/web/hooks/useSearchResults.ts
index fd24efe2..95968cc5 100644
--- a/apps/web/hooks/useSearchResults.ts
+++ b/apps/web/hooks/useSearchResults.ts
@@ -6,7 +6,7 @@ import { fetchCombinedGenres, fetchSearchResults } from "@/lib/api";
import { queryKeys } from "@/lib/query-keys";
import type { CanonicalMediaCard } from "@/lib/domain/typings";
import { useQuery } from "@tanstack/react-query";
-import { useCallback, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
export interface UseSearchResultsState {
items: CanonicalMediaCard[];
@@ -86,11 +86,9 @@ export const useSearchResults = (query: string): UseSearchResultsReturn => {
searchQuery.refetch();
}, [searchQuery]);
- const [lastQuery, setLastQuery] = useState(debouncedQuery);
- if (debouncedQuery !== lastQuery) {
- setLastQuery(debouncedQuery);
+ useEffect(() => {
setCurrentPage(1);
- }
+ }, [debouncedQuery]);
return {
items,
diff --git a/apps/web/hooks/useWatchlistItem.ts b/apps/web/hooks/useWatchlistItem.ts
index 38db2017..440473e6 100644
--- a/apps/web/hooks/useWatchlistItem.ts
+++ b/apps/web/hooks/useWatchlistItem.ts
@@ -49,6 +49,8 @@ export function useWatchlistItem(
[contentId, mediaType],
);
+ const refetch = useCallback(() => fetchWatchlistItem(), [fetchWatchlistItem]);
+
useEffect(() => {
const controller = new AbortController();
void fetchWatchlistItem(controller.signal);
@@ -59,6 +61,6 @@ export function useWatchlistItem(
watchlistItem,
isLoading,
error,
- refetch: () => fetchWatchlistItem(),
+ refetch,
};
}
diff --git a/apps/web/lib/anilist-franchise-display.ts b/apps/web/lib/anilist-franchise-display.ts
index 63a2e144..f31699ab 100644
--- a/apps/web/lib/anilist-franchise-display.ts
+++ b/apps/web/lib/anilist-franchise-display.ts
@@ -2,7 +2,16 @@ import type { AniListFranchiseSeason } from "@/lib/anilist-franchise";
import type { FribbAnimeRow, FribbTmdbEntry } from "@/lib/fribb-mapping";
import { normalizeFribbTmdbShowId } from "@/lib/fribb-mapping";
import type { Episode, Season } from "@/lib/domain/typings";
-import type { AniListTvMedia } from "@/lib/anilist-tv-detail";
+
+export type SeasonEpisodeSource = {
+ id: number;
+ episodes?: number | null;
+ format?: string | null;
+ title?: {
+ english?: string | null;
+ romaji?: string | null;
+ };
+};
export type TmdbGroupedFranchiseSeason = {
seasonNumber: number;
@@ -25,10 +34,45 @@ const fribbTmdbSeasonNumberForRow = (row: FribbAnimeRow): number => {
return season;
};
+export const collectFribbAnilistIdsForTmdbSeason = (
+ fribbRows: readonly FribbAnimeRow[],
+ tmdbShowId: number,
+ seasonNumber: number,
+): number[] =>
+ fribbRows
+ .filter(
+ (row) =>
+ normalizeFribbTmdbShowId(row.themoviedb_id) === tmdbShowId &&
+ fribbTmdbSeasonNumberForRow(row) === seasonNumber,
+ )
+ .sort((left, right) => {
+ const offsetDiff =
+ (left.episode_offset?.tmdb ?? 0) - (right.episode_offset?.tmdb ?? 0);
+ if (offsetDiff !== 0) return offsetDiff;
+ return left.anilist_id - right.anilist_id;
+ })
+ .map((row) => row.anilist_id);
+
+export const mergeAnilistIdsByFribbOffset = (
+ franchiseIds: readonly number[],
+ fribbIds: readonly number[],
+ fribbRows: readonly FribbAnimeRow[],
+): number[] => {
+ const merged = [...new Set([...franchiseIds, ...fribbIds])];
+ const offsetFor = (anilistId: number): number =>
+ fribbRows.find((row) => row.anilist_id === anilistId)?.episode_offset
+ ?.tmdb ?? 0;
+
+ return merged.sort(
+ (left, right) => offsetFor(left) - offsetFor(right) || left - right,
+ );
+};
+
export const groupFranchiseSeasonsByTmdb = (
franchiseSeasons: readonly AniListFranchiseSeason[],
fribbByAnilistId: Record,
tmdbShowId: number,
+ fribbRows?: readonly FribbAnimeRow[],
): TmdbGroupedFranchiseSeason[] | null => {
const groups = new Map();
let mappedCount = 0;
@@ -48,18 +92,31 @@ export const groupFranchiseSeasonsByTmdb = (
return null;
}
- return [...groups.entries()]
+ const grouped = [...groups.entries()]
.sort(([left], [right]) => left - right)
.map(([seasonNumber, anilistIds]) => ({ seasonNumber, anilistIds }));
+
+ if (!fribbRows?.length) {
+ return grouped;
+ }
+
+ return grouped.map(({ seasonNumber, anilistIds }) => ({
+ seasonNumber,
+ anilistIds: mergeAnilistIdsByFribbOffset(
+ anilistIds,
+ collectFribbAnilistIdsForTmdbSeason(fribbRows, tmdbShowId, seasonNumber),
+ fribbRows,
+ ),
+ }));
};
export const buildMergedEpisodesForTmdbSeason = (input: {
tmdbShowId: number;
seasonNumber: number;
anilistIds: readonly number[];
- seasonsByAnilistId: ReadonlyMap;
+ seasonsByAnilistId: ReadonlyMap;
fribbRows: readonly FribbAnimeRow[];
- buildEpisodes: (media: AniListTvMedia) => Episode[];
+ buildEpisodes: (media: SeasonEpisodeSource) => Episode[];
}): Episode[] => {
const seasonRows = input.fribbRows
.filter(
@@ -75,7 +132,13 @@ export const buildMergedEpisodesForTmdbSeason = (input: {
const segments =
seasonRows.length > 0
- ? seasonRows.map((row) => row.anilist_id)
+ ? [
+ ...seasonRows.map((row) => row.anilist_id),
+ ...input.anilistIds.filter(
+ (anilistId) =>
+ !seasonRows.some((row) => row.anilist_id === anilistId),
+ ),
+ ]
: [...input.anilistIds];
const episodes: Episode[] = [];
@@ -88,13 +151,20 @@ export const buildMergedEpisodesForTmdbSeason = (input: {
(candidate) => candidate.anilist_id === anilistId,
);
const offset = row?.episode_offset?.tmdb ?? 0;
- const segmentStart = offset > 0 ? offset + 1 : episodes.length + 1;
+ const segmentStart =
+ offset > 0
+ ? offset + 1
+ : episodes.length > 0
+ ? (episodes[episodes.length - 1]?.episode_number ?? 0) + 1
+ : 1;
for (const episode of input.buildEpisodes(media)) {
episodes.push({
...episode,
episode_number: segmentStart + episode.episode_number - 1,
id: media.id * 10_000 + segmentStart + episode.episode_number - 1,
+ sourceAnilistId: media.id,
+ sourceEpisodeNumber: episode.episode_number,
});
}
}
@@ -105,9 +175,10 @@ export const buildMergedEpisodesForTmdbSeason = (input: {
export const collapseSeasonSummariesForTmdb = (input: {
franchiseSeasons: readonly AniListFranchiseSeason[];
groupedSeasons: readonly TmdbGroupedFranchiseSeason[];
- seasonsByAnilistId: ReadonlyMap;
- buildSeason: (media: AniListTvMedia, seasonNumber: number) => Season;
+ seasonsByAnilistId: ReadonlyMap;
+ buildSeason: (media: SeasonEpisodeSource, seasonNumber: number) => Season;
tmdbSeasons?: readonly Season[];
+ mergedEpisodeCountBySeason?: ReadonlyMap;
}): Season[] =>
input.groupedSeasons.map(({ seasonNumber, anilistIds }) => {
const primaryId = anilistIds[0];
@@ -133,10 +204,17 @@ export const collapseSeasonSummariesForTmdb = (input: {
);
const base = input.buildSeason(media, seasonNumber);
+ const mergedEpisodeCount =
+ input.mergedEpisodeCountBySeason?.get(seasonNumber) ?? 0;
+
return {
...base,
name: tmdbSeason?.name ?? base.name,
- episode_count: tmdbSeason?.episode_count ?? base.episode_count,
+ episode_count: Math.max(
+ mergedEpisodeCount,
+ tmdbSeason?.episode_count ?? 0,
+ base.episode_count,
+ ),
air_date: tmdbSeason?.air_date ?? base.air_date,
overview: tmdbSeason?.overview ?? base.overview,
poster_path: tmdbSeason?.poster_path ?? base.poster_path,
diff --git a/apps/web/lib/anilist-page-hrefs.ts b/apps/web/lib/anilist-page-hrefs.ts
index 40a0c23b..d52f3d98 100644
--- a/apps/web/lib/anilist-page-hrefs.ts
+++ b/apps/web/lib/anilist-page-hrefs.ts
@@ -1,4 +1,7 @@
-import { buildAnilistTvDetailHref } from "@/lib/anilist-route-id";
+import {
+ buildAnilistTvDetailHref,
+ normalizeAnilistAnimeDetailHref,
+} from "@/lib/anilist-route-id";
import { buildTmdbAnimeDetailHref } from "@/lib/tmdb-anime-route-id";
import type { MediaItem } from "@/lib/domain/typings";
@@ -75,7 +78,10 @@ export const withAnimePageHref = (item: MediaItem): MediaItem => {
const existingHref =
typeof animeItem.href === "string" ? animeItem.href : undefined;
if (existingHref && !isExternalHref(existingHref)) {
- return { ...item, href: existingHref } as MediaItem;
+ return {
+ ...item,
+ href: normalizeAnilistAnimeDetailHref(existingHref),
+ } as MediaItem;
}
const id = Math.abs(item.id);
diff --git a/apps/web/lib/anilist-route-id.ts b/apps/web/lib/anilist-route-id.ts
index 27143107..4dc00a6b 100644
--- a/apps/web/lib/anilist-route-id.ts
+++ b/apps/web/lib/anilist-route-id.ts
@@ -1,4 +1,4 @@
-/** AniList-backed anime catalog routes use bare numeric ids under `/anime/`. */
+/** AniList-backed anime catalog routes use `anilist-{id}` slugs under `/anime/`. */
import { isMalAnimeRouteId } from "@/lib/mal/route-id";
import { isTmdbAnimeRouteId } from "@/lib/tmdb-anime-route-id";
@@ -13,7 +13,7 @@ export const toAnilistTvRouteSlug = (anilistId: number): string =>
export const isBareAnilistRouteId = (routeId: string): boolean =>
BARE_ANILIST_ROUTE_ID.test(routeId) && Number.parseInt(routeId, 10) > 0;
-/** Route ids for `/anime/[id]` detail pages (bare numeric, legacy slug, or negative). */
+/** Route ids for `/anime/[id]` detail pages (prefixed slug, legacy bare numeric, or negative). */
export const isAnimeAnilistRouteId = (routeId: string): boolean => {
if (isMalAnimeRouteId(routeId) || isTmdbAnimeRouteId(routeId)) {
return false;
@@ -64,6 +64,10 @@ export const normalizeAnilistTvRouteSlug = (routeId: string): string => {
return routeId;
}
+ if (isBareAnilistRouteId(routeId)) {
+ return toAnilistTvRouteSlug(Number.parseInt(routeId, 10));
+ }
+
if (routeId.startsWith("-")) {
return toAnilistTvRouteSlug(fromAnilistTvRouteId(routeId));
}
@@ -80,10 +84,23 @@ export const buildAnilistTvDetailHref = (
params.set("season", String(options.season));
}
const query = params.toString();
- const path = `/anime/${Math.abs(anilistId)}`;
+ const path = `/anime/${toAnilistTvRouteSlug(anilistId)}`;
return query ? `${path}?${query}` : path;
};
+/** Upgrade legacy `/anime/{id}` links to prefixed AniList slugs. */
+export const normalizeAnilistAnimeDetailHref = (href: string): string => {
+ const trimmed = href.trim();
+ const match = trimmed.match(/^\/anime\/(\d+)(.*)$/);
+ if (!match?.[1] || !isBareAnilistRouteId(match[1])) {
+ return trimmed;
+ }
+
+ const anilistId = Number.parseInt(match[1], 10);
+ const suffix = match[2] ?? "";
+ return `${buildAnilistTvDetailHref(anilistId)}${suffix}`;
+};
+
/** Route slug for detail APIs/navigation: AniList slugs from the path, else TMDB id. */
export const resolveTvDetailRouteId = (
pathname: string,
@@ -99,7 +116,7 @@ export const resolveTvDetailRouteId = (
}
if (routeSegment && pathname.startsWith("/anime/")) {
if (isAnimeAnilistRouteId(routeSegment)) {
- return routeSegment;
+ return normalizeAnilistTvRouteSlug(routeSegment);
}
} else if (routeSegment && isAnilistTvRouteId(routeSegment)) {
return routeSegment;
diff --git a/apps/web/lib/anilist-tv-detail.ts b/apps/web/lib/anilist-tv-detail.ts
index fa9f4651..492a435c 100644
--- a/apps/web/lib/anilist-tv-detail.ts
+++ b/apps/web/lib/anilist-tv-detail.ts
@@ -33,6 +33,7 @@ import {
buildMergedEpisodesForTmdbSeason,
collapseSeasonSummariesForTmdb,
groupFranchiseSeasonsByTmdb,
+ type SeasonEpisodeSource,
type TmdbGroupedFranchiseSeason,
} from "@/lib/anilist-franchise-display";
import {
@@ -40,7 +41,13 @@ import {
enrichAnilistTvDetailsWithTmdb,
resolveAnilistTmdbTvIdForEnrichment,
} from "@/lib/anilist-tv-tmdb-enrich";
-import { getFribbAnimeList, getFribbMapping } from "@/lib/fribb-mapping";
+import {
+ getFribbAnimeList,
+ getFribbMapping,
+ type FribbAnimeRow,
+} from "@/lib/fribb-mapping";
+import { hasFribbSplitCourForTmdbSeason } from "@/lib/anime/split-cour-appendix";
+import { fetchSeasonDetailsServer } from "@/lib/server/tvshow-api";
import { cache } from "react";
const ANILIST_TV_DETAIL_REVALIDATE_SECONDS = 3600;
@@ -367,7 +374,10 @@ const buildEpisodes = (media: AniListTvMedia): Episode[] => {
return episodeNumbers.map((episodeNumber) => ({
id: media.id * 10_000 + episodeNumber,
- name: `Episode ${episodeNumber}`,
+ name:
+ episodeNumbers.length === 1 && media.format === "SPECIAL"
+ ? getAniListTitle(asAniListMedia(media))
+ : `Episode ${episodeNumber}`,
overview: "",
episode_number: episodeNumber,
air_date: airingDates.get(episodeNumber) ?? defaultAirDate,
@@ -375,9 +385,14 @@ const buildEpisodes = (media: AniListTvMedia): Episode[] => {
runtime,
vote_average: 0,
vote_count: 0,
+ sourceAnilistId: media.id,
+ sourceEpisodeNumber: episodeNumber,
}));
};
+const buildEpisodesForSeasonSource = (media: SeasonEpisodeSource): Episode[] =>
+ buildEpisodes(media as AniListTvMedia);
+
const buildSeason = (media: AniListTvMedia, seasonNumber: number): Season => {
const episodeCount = collectEpisodeNumbers(media).length;
const poster = getAniListPoster(asAniListMedia(media)) ?? null;
@@ -410,6 +425,7 @@ const mapCharactersToCast = (media: AniListTvMedia): Actor[] => {
character: edge.role ?? "Main",
profile_path: node.image?.large ?? null,
popularity: 0,
+ href: null,
});
}
@@ -576,6 +592,137 @@ const fetchAniListTvMediaUncached = async (
}
};
+const resolveSpecialSequelAppendixIds = async (
+ tailMedia: AniListTvMedia,
+): Promise => {
+ const appendix: number[] = [];
+ let current: AniListTvMedia | null = tailMedia;
+
+ for (let depth = 0; depth < 3 && current; depth += 1) {
+ const sequelNode = current.relations?.edges?.find(
+ (edge) =>
+ edge.relationType === "SEQUEL" &&
+ edge.node?.type === "ANIME" &&
+ edge.node.format === "SPECIAL" &&
+ typeof edge.node.id === "number" &&
+ edge.node.id > 0,
+ )?.node;
+ if (!sequelNode?.id) break;
+
+ appendix.push(sequelNode.id);
+ current = await getCachedAnilistTvMedia(sequelNode.id);
+ }
+
+ return appendix;
+};
+
+const appendSpecialSequelIdsToGroupedSeasons = async (
+ groupedSeasons: readonly TmdbGroupedFranchiseSeason[],
+ seasonsByAnilistId: Map,
+): Promise => {
+ const expanded: TmdbGroupedFranchiseSeason[] = [];
+
+ for (const groupedSeason of groupedSeasons) {
+ const anilistIds = [...groupedSeason.anilistIds];
+ const tailId = anilistIds[anilistIds.length - 1];
+ const tailMedia = tailId ? seasonsByAnilistId.get(tailId) : undefined;
+ if (tailMedia) {
+ const sequelIds = await resolveSpecialSequelAppendixIds(tailMedia);
+ for (const sequelId of sequelIds) {
+ if (!anilistIds.includes(sequelId)) {
+ anilistIds.push(sequelId);
+ }
+ }
+ }
+ expanded.push({ ...groupedSeason, anilistIds });
+ }
+
+ return expanded;
+};
+
+const hydrateSplitCourAppendixMedia = async (
+ groupedSeasons: readonly TmdbGroupedFranchiseSeason[],
+ seasonsByAnilistId: Map,
+): Promise => {
+ const missingIds = [
+ ...new Set(
+ groupedSeasons.flatMap((season) =>
+ season.anilistIds.filter(
+ (anilistId) => !seasonsByAnilistId.has(anilistId),
+ ),
+ ),
+ ),
+ ];
+
+ if (missingIds.length === 0) return;
+
+ const loaded = await Promise.all(
+ missingIds.map(async (anilistId) => {
+ const media = await getCachedAnilistTvMedia(anilistId);
+ return [anilistId, media] as const;
+ }),
+ );
+
+ for (const [anilistId, media] of loaded) {
+ if (media) {
+ seasonsByAnilistId.set(anilistId, media);
+ }
+ }
+};
+
+const shouldMergeEpisodesForGroupedSeason = (
+ groupedSeason: TmdbGroupedFranchiseSeason,
+ tmdbShowId: number | null,
+ fribbRows: readonly FribbAnimeRow[],
+): boolean => {
+ if (!tmdbShowId || groupedSeason.anilistIds.length === 0) {
+ return false;
+ }
+
+ return (
+ groupedSeason.anilistIds.length > 1 ||
+ hasFribbSplitCourForTmdbSeason(
+ fribbRows,
+ tmdbShowId,
+ groupedSeason.seasonNumber,
+ )
+ );
+};
+
+const buildSeasonForSeasonSource = (
+ media: SeasonEpisodeSource,
+ seasonNumber: number,
+): Season => buildSeason(media as AniListTvMedia, seasonNumber);
+
+const buildMergedEpisodeCountBySeason = (
+ groupedSeasons: readonly TmdbGroupedFranchiseSeason[],
+ resolved: ResolvedAniListTvShow,
+ tmdbShowId: number | null,
+ fribbRows: readonly FribbAnimeRow[],
+): Map => {
+ const counts = new Map();
+
+ for (const groupedSeason of groupedSeasons) {
+ if (
+ !shouldMergeEpisodesForGroupedSeason(groupedSeason, tmdbShowId, fribbRows)
+ ) {
+ continue;
+ }
+
+ const episodes = buildMergedEpisodesForTmdbSeason({
+ tmdbShowId: tmdbShowId!,
+ seasonNumber: groupedSeason.seasonNumber,
+ anilistIds: groupedSeason.anilistIds,
+ seasonsByAnilistId: resolved.seasonsByAnilistId,
+ fribbRows,
+ buildEpisodes: buildEpisodesForSeasonSource,
+ });
+ counts.set(groupedSeason.seasonNumber, episodes.length);
+ }
+
+ return counts;
+};
+
const resolveAniListTvShowUncached = async (
entryAnilistId: number,
): Promise => {
@@ -598,6 +745,29 @@ const resolveAniListTvShowUncached = async (
const root = seasonsByAnilistId.get(franchise.rootAnilistId);
if (!entry || !root) return null;
+ const fribbMapping = await getFribbMapping();
+ const fribbRows = await getFribbAnimeList();
+ const entryFribb = fribbMapping[entryAnilistId];
+ const tmdbShowId = entryFribb?.tv ?? null;
+ if (tmdbShowId) {
+ const grouped =
+ groupFranchiseSeasonsByTmdb(
+ franchise.seasons,
+ fribbMapping,
+ tmdbShowId,
+ fribbRows,
+ ) ??
+ franchise.seasons.map((season) => ({
+ seasonNumber: season.seasonNumber,
+ anilistIds: [season.anilistId],
+ }));
+ const expandedGroups = await appendSpecialSequelIdsToGroupedSeasons(
+ grouped,
+ seasonsByAnilistId,
+ );
+ await hydrateSplitCourAppendixMedia(expandedGroups, seasonsByAnilistId);
+ }
+
return {
routeSlug: toAnilistTvRouteSlug(franchise.rootAnilistId),
franchise,
@@ -652,13 +822,19 @@ const resolveDisplaySeasonGroups = async (
if (!tmdbShowId) return fallback;
const fribbMapping = await getFribbMapping();
+ const fribbRows = await getFribbAnimeList();
const grouped = groupFranchiseSeasonsByTmdb(
resolved.franchise.seasons,
fribbMapping,
tmdbShowId,
+ fribbRows,
);
- return grouped ?? fallback;
+ const baseGroups = grouped ?? fallback;
+ return appendSpecialSequelIdsToGroupedSeasons(
+ baseGroups,
+ resolved.seasonsByAnilistId,
+ );
};
const collapseSplitCourAnilistSeasons = async (
@@ -673,12 +849,21 @@ const collapseSplitCourAnilistSeasons = async (
return details;
}
+ const fribbRows = await getFribbAnimeList();
+ const mergedEpisodeCountBySeason = buildMergedEpisodeCountBySeason(
+ grouped,
+ resolved,
+ tmdbShowId,
+ fribbRows,
+ );
+
const seasons = collapseSeasonSummariesForTmdb({
franchiseSeasons: resolved.franchise.seasons,
groupedSeasons: grouped,
seasonsByAnilistId: resolved.seasonsByAnilistId,
- buildSeason,
+ buildSeason: buildSeasonForSeasonSource,
tmdbSeasons: details.seasons,
+ mergedEpisodeCountBySeason,
});
return {
@@ -718,6 +903,14 @@ export const getCachedAnilistTvShowDetail = async (
return resolved ? await buildEnrichedAnilistTvShowDetails(resolved) : null;
};
+const fetchMappedTmdbSeasonDetails = async (
+ tmdbShowId: number,
+ seasonNumber: number,
+): Promise =>
+ fetchSeasonDetailsServer(String(tmdbShowId), seasonNumber, {
+ source: "tmdb",
+ });
+
export const getCachedAnilistTvSeasonDetails = async (
routeId: string,
seasonNumber: number,
@@ -733,7 +926,10 @@ export const getCachedAnilistTvSeasonDetails = async (
const groupedSeason = groupedSeasons.find(
(season) => season.seasonNumber === seasonNumber,
);
- if (!groupedSeason) return null;
+ if (!groupedSeason) {
+ if (!tmdbShowId) return null;
+ return fetchMappedTmdbSeasonDetails(tmdbShowId, seasonNumber);
+ }
const primaryAnilistId = groupedSeason.anilistIds[0];
const media =
@@ -741,21 +937,21 @@ export const getCachedAnilistTvSeasonDetails = async (
? resolved.seasonsByAnilistId.get(primaryAnilistId)
: undefined) ?? resolved.entry;
const season = buildSeason(media, seasonNumber);
- const fribbRows =
- groupedSeason.anilistIds.length > 1 && tmdbShowId
- ? await getFribbAnimeList()
- : [];
- const episodes =
- groupedSeason.anilistIds.length > 1 && tmdbShowId
- ? buildMergedEpisodesForTmdbSeason({
- tmdbShowId,
- seasonNumber,
- anilistIds: groupedSeason.anilistIds,
- seasonsByAnilistId: resolved.seasonsByAnilistId,
- fribbRows,
- buildEpisodes,
- })
- : buildEpisodes(media);
+ const fribbRows = tmdbShowId ? await getFribbAnimeList() : [];
+ const episodes = shouldMergeEpisodesForGroupedSeason(
+ groupedSeason,
+ tmdbShowId,
+ fribbRows,
+ )
+ ? buildMergedEpisodesForTmdbSeason({
+ tmdbShowId: tmdbShowId!,
+ seasonNumber,
+ anilistIds: groupedSeason.anilistIds,
+ seasonsByAnilistId: resolved.seasonsByAnilistId,
+ fribbRows,
+ buildEpisodes: buildEpisodesForSeasonSource,
+ })
+ : buildEpisodes(media);
const enrichedSeason = await enrichAnilistSeasonDetailsWithTmdb(
routeId,
@@ -767,60 +963,83 @@ export const getCachedAnilistTvSeasonDetails = async (
episodes,
},
resolved,
+ {
+ preserveSplitCourAppendix: shouldMergeEpisodesForGroupedSeason(
+ groupedSeason,
+ tmdbShowId,
+ fribbRows,
+ ),
+ },
);
return enrichedSeason;
};
-export const getCachedAnilistTvAllSeasons = async (
- routeId: string,
- options?: AnilistRouteResolveOptions,
-): Promise> => {
- const resolved = await requireResolvedAniListTvShow(routeId, options);
- if (!resolved) return {};
-
- const tmdbShowId = await resolveAnilistTmdbTvIdForEnrichment(
- resolved.entry.id,
- );
- const groupedSeasons = await resolveDisplaySeasonGroups(resolved, tmdbShowId);
- const fribbRows =
- groupedSeasons.some((season) => season.anilistIds.length > 1) && tmdbShowId
- ? await getFribbAnimeList()
- : [];
-
- const allSeasons: Record = {};
-
- for (const groupedSeason of groupedSeasons) {
- const primaryAnilistId = groupedSeason.anilistIds[0];
- const media =
- (primaryAnilistId
- ? resolved.seasonsByAnilistId.get(primaryAnilistId)
- : undefined) ?? resolved.entry;
- const season = buildSeason(media, groupedSeason.seasonNumber);
- const episodes =
- groupedSeason.anilistIds.length > 1 && tmdbShowId
+export const getCachedAnilistTvAllSeasons = cache(
+ async (
+ routeId: string,
+ options?: AnilistRouteResolveOptions,
+ ): Promise> => {
+ const resolved = await requireResolvedAniListTvShow(routeId, options);
+ if (!resolved) return {};
+
+ const tmdbShowId = await resolveAnilistTmdbTvIdForEnrichment(
+ resolved.entry.id,
+ );
+ const groupedSeasons = await resolveDisplaySeasonGroups(
+ resolved,
+ tmdbShowId,
+ );
+ const fribbRows = tmdbShowId ? await getFribbAnimeList() : [];
+
+ const allSeasons: Record = {};
+
+ for (const groupedSeason of groupedSeasons) {
+ const primaryAnilistId = groupedSeason.anilistIds[0];
+ const media =
+ (primaryAnilistId
+ ? resolved.seasonsByAnilistId.get(primaryAnilistId)
+ : undefined) ?? resolved.entry;
+ const season = buildSeason(media, groupedSeason.seasonNumber);
+ const episodes = shouldMergeEpisodesForGroupedSeason(
+ groupedSeason,
+ tmdbShowId,
+ fribbRows,
+ )
? buildMergedEpisodesForTmdbSeason({
- tmdbShowId,
+ tmdbShowId: tmdbShowId!,
seasonNumber: groupedSeason.seasonNumber,
anilistIds: groupedSeason.anilistIds,
seasonsByAnilistId: resolved.seasonsByAnilistId,
fribbRows,
- buildEpisodes,
+ buildEpisodes: buildEpisodesForSeasonSource,
})
: buildEpisodes(media);
- const baseSeason: SeasonDetails = {
- id: season.id,
- name: season.name,
- overview: season.overview,
- season_number: season.season_number,
- episodes,
- };
- allSeasons[groupedSeason.seasonNumber] =
- await enrichAnilistSeasonDetailsWithTmdb(routeId, baseSeason, resolved);
- }
+ const baseSeason: SeasonDetails = {
+ id: season.id,
+ name: season.name,
+ overview: season.overview,
+ season_number: season.season_number,
+ episodes,
+ };
+ allSeasons[groupedSeason.seasonNumber] =
+ await enrichAnilistSeasonDetailsWithTmdb(
+ routeId,
+ baseSeason,
+ resolved,
+ {
+ preserveSplitCourAppendix: shouldMergeEpisodesForGroupedSeason(
+ groupedSeason,
+ tmdbShowId,
+ fribbRows,
+ ),
+ },
+ );
+ }
- return allSeasons;
-};
+ return allSeasons;
+ },
+);
export const getCachedAnilistTvCredits = async (
routeId: string,
diff --git a/apps/web/lib/anilist-tv-tmdb-enrich.ts b/apps/web/lib/anilist-tv-tmdb-enrich.ts
index ffc92f8b..18f94974 100644
--- a/apps/web/lib/anilist-tv-tmdb-enrich.ts
+++ b/apps/web/lib/anilist-tv-tmdb-enrich.ts
@@ -17,6 +17,7 @@ import {
} from "@/lib/anilist-tmdb-match";
import {
fetchAllSeasonDetails,
+ fetchSeasonDetailsServer,
fetchTVShowDetails,
} from "@/lib/server/tvshow-api";
import type {
@@ -27,6 +28,7 @@ import type {
import { tmdb } from "@/tmdb/api";
import type { TvShowWithMediaType } from "@/tmdb/models";
import { unstable_cache } from "next/cache";
+import { cache } from "react";
const ENRICH_REVALIDATE_SECONDS = 60 * 60 * 24;
@@ -133,6 +135,7 @@ const mergeEpisode = (
export const mergeTmdbEpisodesIntoSeason = (
season: SeasonDetails,
tmdbEpisodes: Episode[] | undefined,
+ options?: { preserveSplitCourAppendix?: boolean },
): SeasonDetails => {
if (!tmdbEpisodes?.length) return season;
@@ -149,13 +152,32 @@ export const mergeTmdbEpisodesIntoSeason = (
: tmdbEpisode;
});
+ if (!options?.preserveSplitCourAppendix) {
+ return {
+ ...season,
+ overview: hasUsableText(season.overview)
+ ? season.overview
+ : (tmdbEpisodes.find((episode) => hasUsableText(episode.overview))
+ ?.overview ?? season.overview),
+ episodes,
+ };
+ }
+
+ const maxTmdbEpisodeNumber = episodes.reduce(
+ (max, episode) => Math.max(max, episode.episode_number),
+ 0,
+ );
+ const trailingAnilistEpisodes = season.episodes
+ .filter((episode) => episode.episode_number > maxTmdbEpisodeNumber)
+ .sort((left, right) => left.episode_number - right.episode_number);
+
return {
...season,
overview: hasUsableText(season.overview)
? season.overview
: (tmdbEpisodes.find((episode) => hasUsableText(episode.overview))
?.overview ?? season.overview),
- episodes,
+ episodes: [...episodes, ...trailingAnilistEpisodes],
};
};
@@ -280,25 +302,72 @@ export const enrichAnilistTvDetailsWithTmdb = async (
return merged;
};
-export const enrichAnilistSeasonDetailsWithTmdb = async (
- _routeId: string,
- season: SeasonDetails,
+export type TmdbSeasonEnrichmentContext = {
+ tmdbId: number;
+ tmdbSeasons: Record;
+};
+
+const resolveTmdbSeasonEnrichmentContext = async (
resolved: ResolvedAniListTvShow,
-): Promise => {
- if (!shouldMergeTmdbSeasonData(resolved)) return season;
+): Promise => {
+ if (!shouldMergeTmdbSeasonData(resolved)) {
+ return null;
+ }
const tmdbId = await resolveAnilistTmdbTvIdForEnrichment(resolved.entry.id);
- if (!tmdbId) return season;
+ if (!tmdbId) {
+ return null;
+ }
try {
const tmdbDetails = await fetchTVShowDetails(String(tmdbId));
const tmdbSeasons = await fetchAllSeasonDetails(
String(tmdbId),
tmdbDetails.seasons,
+ { source: "tmdb" },
);
- const tmdbSeason = tmdbSeasons[season.season_number];
- return mergeTmdbEpisodesIntoSeason(season, tmdbSeason?.episodes);
+ return { tmdbId, tmdbSeasons };
} catch {
- return season;
+ return null;
+ }
+};
+
+export const getTmdbSeasonEnrichmentContext = cache(
+ resolveTmdbSeasonEnrichmentContext,
+);
+
+export const enrichAnilistSeasonDetailsWithTmdb = async (
+ _routeId: string,
+ season: SeasonDetails,
+ resolved: ResolvedAniListTvShow,
+ options?: {
+ preserveSplitCourAppendix?: boolean;
+ tmdbContext?: TmdbSeasonEnrichmentContext | null;
+ },
+): Promise => {
+ if (!shouldMergeTmdbSeasonData(resolved)) return season;
+
+ let tmdbEpisodes: Episode[] | undefined;
+ if (options?.tmdbContext) {
+ tmdbEpisodes =
+ options.tmdbContext.tmdbSeasons[season.season_number]?.episodes;
+ } else {
+ const tmdbId = await resolveAnilistTmdbTvIdForEnrichment(resolved.entry.id);
+ if (!tmdbId) return season;
+
+ try {
+ const tmdbSeason = await fetchSeasonDetailsServer(
+ String(tmdbId),
+ season.season_number,
+ { source: "tmdb" },
+ );
+ tmdbEpisodes = tmdbSeason?.episodes;
+ } catch {
+ return season;
+ }
}
+
+ return mergeTmdbEpisodesIntoSeason(season, tmdbEpisodes, {
+ preserveSplitCourAppendix: options?.preserveSplitCourAppendix,
+ });
};
diff --git a/apps/web/lib/anime/anime-playback-policy.ts b/apps/web/lib/anime/anime-playback-policy.ts
new file mode 100644
index 00000000..00bbcc77
--- /dev/null
+++ b/apps/web/lib/anime/anime-playback-policy.ts
@@ -0,0 +1,46 @@
+import type { TvDetailCatalog } from "@/lib/tv-detail-catalog";
+
+export type AnimeCoordsStatus = "idle" | "pending" | "resolved" | "miss";
+
+export type AnimePlaybackHoldInput = {
+ animeFirst: boolean;
+ hasSelectedEpisode: boolean;
+ animeCoordsReady: boolean;
+ animeCoordsStatus: AnimeCoordsStatus;
+};
+
+/** `/anime/*` and other AniList-backed detail pages use anime scrapers first. */
+export const isAnimeFirstPlayback = (
+ catalog: TvDetailCatalog | null,
+ defaultAnilistId: number | null,
+): boolean => catalog === "anime" || defaultAnilistId != null;
+
+/**
+ * Hold scrape until we know the AniList episode. TMDB proxies only after a miss.
+ */
+export const shouldHoldAnimePlaybackStart = (
+ input: AnimePlaybackHoldInput,
+): boolean => {
+ if (!input.animeFirst || !input.hasSelectedEpisode) {
+ return false;
+ }
+
+ if (input.animeCoordsReady) {
+ return false;
+ }
+
+ return input.animeCoordsStatus !== "miss";
+};
+
+/** Plain TV/TMDB scrape is only allowed when coords could not be resolved. */
+export const shouldAllowTmdbOnlyScrape = (
+ input: AnimePlaybackHoldInput,
+): boolean => {
+ if (!input.animeFirst || !input.hasSelectedEpisode) {
+ return true;
+ }
+
+ return input.animeCoordsStatus === "miss" && !input.animeCoordsReady;
+};
+
+export const ANIME_PLAYBACK_RESOLVING_MESSAGE = "Resolving episode…";
diff --git a/apps/web/lib/anime/apply-segment-episode-mapping.ts b/apps/web/lib/anime/apply-segment-episode-mapping.ts
new file mode 100644
index 00000000..2434e20c
--- /dev/null
+++ b/apps/web/lib/anime/apply-segment-episode-mapping.ts
@@ -0,0 +1,56 @@
+import {
+ findSegmentForEpisode,
+ toAnimeDisplayCoords,
+ type MappingSegment,
+} from "@/lib/anime/tmdb-anilist-map";
+import {
+ useEpisodeStore,
+ type AnimeEpisodeMappingContext,
+} from "@/lib/stores/episode-store";
+
+export const applySegmentAnimeEpisodeMapping = (
+ segments: readonly MappingSegment[],
+ episodeNumber: number,
+ input: AnimeEpisodeMappingContext & { isAdult: boolean },
+): boolean => {
+ const segment = findSegmentForEpisode(segments, episodeNumber);
+ const animeDisplay = toAnimeDisplayCoords(segments, episodeNumber);
+ if (!segment || !animeDisplay) {
+ return false;
+ }
+
+ const state = useEpisodeStore.getState();
+ if (
+ state.tvShowId !== input.tvShowId ||
+ state.seasonNumber !== input.seasonNumber ||
+ state.selectedEpisode?.episode_number !== input.episodeNumber
+ ) {
+ return false;
+ }
+
+ if (
+ state.animeCoordsStatus === "resolved" &&
+ state.mappingConfidence === "high" &&
+ state.anilistId === segment.anilistMediaId &&
+ state.relativeEpisodeNumber === animeDisplay.episodeNumber
+ ) {
+ return false;
+ }
+
+ useEpisodeStore.getState().applyAnimeEpisodeMapping(
+ {
+ animeInfo: {
+ anilistId: segment.anilistMediaId,
+ startEpisode: segment.startEpisode,
+ endEpisode: segment.endEpisode,
+ },
+ relativeEpisodeNumber: animeDisplay.episodeNumber,
+ confidence: "high",
+ isAdult: input.isAdult,
+ animeSeasonNumber: animeDisplay.seasonNumber,
+ },
+ input,
+ );
+
+ return true;
+};
diff --git a/apps/web/lib/anime/episode-playback-source.ts b/apps/web/lib/anime/episode-playback-source.ts
new file mode 100644
index 00000000..6ff115de
--- /dev/null
+++ b/apps/web/lib/anime/episode-playback-source.ts
@@ -0,0 +1,80 @@
+import type { Episode } from "@/lib/domain/typings";
+import type { MappingConfidence } from "@/lib/anime/tmdb-anilist-map";
+
+export type EpisodeAnilistPlayback = {
+ anilistId: number;
+ episodeNumber: number;
+};
+
+export type EpisodeAnimeSelection = {
+ animeInfo: {
+ anilistId: number;
+ startEpisode: number;
+ endEpisode: number;
+ };
+ mapping: {
+ confidence: MappingConfidence;
+ isAdult: boolean;
+ relativeEpisodeNumber: number;
+ animeSeasonNumber?: number | null;
+ genres?: string[];
+ };
+};
+
+export const readEpisodeAnilistPlayback = (
+ episode: Episode,
+): EpisodeAnilistPlayback | null => {
+ if (
+ typeof episode.sourceAnilistId !== "number" ||
+ !Number.isInteger(episode.sourceAnilistId) ||
+ episode.sourceAnilistId <= 0 ||
+ typeof episode.sourceEpisodeNumber !== "number" ||
+ !Number.isInteger(episode.sourceEpisodeNumber) ||
+ episode.sourceEpisodeNumber <= 0
+ ) {
+ return null;
+ }
+
+ return {
+ anilistId: episode.sourceAnilistId,
+ episodeNumber: episode.sourceEpisodeNumber,
+ };
+};
+
+export const animeInfoFromEpisodePlayback = (
+ episode: Episode,
+ playback: EpisodeAnilistPlayback,
+): EpisodeAnimeSelection["animeInfo"] => {
+ const segmentStart = episode.episode_number - playback.episodeNumber + 1;
+
+ return {
+ anilistId: playback.anilistId,
+ startEpisode: segmentStart,
+ endEpisode: segmentStart + playback.episodeNumber - 1,
+ };
+};
+
+export const resolveEpisodeAnimeSelection = (
+ episode: Episode,
+ options?: {
+ animeSeasonNumber?: number | null;
+ isAdult?: boolean;
+ genres?: string[];
+ },
+): EpisodeAnimeSelection | null => {
+ const playback = readEpisodeAnilistPlayback(episode);
+ if (!playback) {
+ return null;
+ }
+
+ return {
+ animeInfo: animeInfoFromEpisodePlayback(episode, playback),
+ mapping: {
+ confidence: "high",
+ isAdult: options?.isAdult === true,
+ relativeEpisodeNumber: playback.episodeNumber,
+ animeSeasonNumber: options?.animeSeasonNumber,
+ genres: options?.genres,
+ },
+ };
+};
diff --git a/apps/web/lib/anime/map-segment-appendix.ts b/apps/web/lib/anime/map-segment-appendix.ts
new file mode 100644
index 00000000..8ba37c40
--- /dev/null
+++ b/apps/web/lib/anime/map-segment-appendix.ts
@@ -0,0 +1,66 @@
+import "server-only";
+
+import { getCachedAnilistTvMedia } from "@/lib/anilist-tv-detail";
+import type { MappingSegment } from "@/lib/anime/tmdb-anilist-map";
+import {
+ appendMappingSegmentsBeyond,
+ buildFribbSeasonSegments,
+} from "@/lib/anime/split-cour-appendix";
+import { getFribbAnimeList } from "@/lib/fribb-mapping";
+
+const extendTailWithSpecialSequelSegments = async (
+ segments: MappingSegment[],
+): Promise => {
+ if (segments.length === 0) {
+ return segments;
+ }
+
+ const extended = [...segments];
+ let tail = extended[extended.length - 1]!;
+
+ for (let depth = 0; depth < 3; depth += 1) {
+ const media = await getCachedAnilistTvMedia(tail.anilistMediaId);
+ const sequelNode = media?.relations?.edges?.find(
+ (edge) =>
+ edge.relationType === "SEQUEL" &&
+ edge.node?.type === "ANIME" &&
+ edge.node.format === "SPECIAL" &&
+ typeof edge.node.id === "number" &&
+ edge.node.id > 0,
+ )?.node;
+
+ if (!sequelNode?.id) {
+ break;
+ }
+
+ if (extended.some((segment) => segment.anilistMediaId === sequelNode.id)) {
+ break;
+ }
+
+ const startEpisode = tail.endEpisode + 1;
+ const appendixSegment: MappingSegment = {
+ startEpisode,
+ endEpisode: startEpisode,
+ anilistMediaId: sequelNode.id,
+ };
+ extended.push(appendixSegment);
+ tail = appendixSegment;
+ }
+
+ return extended;
+};
+
+export const extendSeasonMapSegments = async (
+ segments: MappingSegment[],
+ tmdbShowId: number,
+ seasonNumber: number,
+): Promise => {
+ const fribbRows = await getFribbAnimeList();
+ const fribbSegments = buildFribbSeasonSegments(
+ fribbRows,
+ tmdbShowId,
+ seasonNumber,
+ );
+ const merged = appendMappingSegmentsBeyond(segments, fribbSegments);
+ return extendTailWithSpecialSequelSegments(merged);
+};
diff --git a/apps/web/lib/anime/resolve-coords-tmdb-show-id.ts b/apps/web/lib/anime/resolve-coords-tmdb-show-id.ts
new file mode 100644
index 00000000..35698fd6
--- /dev/null
+++ b/apps/web/lib/anime/resolve-coords-tmdb-show-id.ts
@@ -0,0 +1,45 @@
+import { isAnimeAnilistRouteId } from "@/lib/anilist-route-id";
+
+/** AniList routes need mapped TMDB id in the store before coords can resolve. */
+export const shouldAwaitPlaybackTmdbTvIdForMapping = (
+ routeId: string,
+ defaultAnilistId: number | null,
+ playbackTmdbTvId: number | null,
+): boolean =>
+ defaultAnilistId != null &&
+ playbackTmdbTvId == null &&
+ isAnimeAnilistRouteId(routeId);
+
+/**
+ * TMDB show id for `/api/anime/playback-coords` — never an AniList route slug.
+ * Returns null when the route id would collide with the AniList catalog id.
+ */
+export const resolveAnimeEpisodeMappingTmdbShowId = (
+ routeId: string,
+ defaultAnilistId: number | null,
+ playbackTmdbTvId: number | null,
+): number | null => {
+ if (typeof playbackTmdbTvId === "number" && playbackTmdbTvId > 0) {
+ return playbackTmdbTvId;
+ }
+
+ if (!defaultAnilistId) {
+ const numeric = Number(routeId);
+ return Number.isInteger(numeric) && numeric > 0 ? numeric : null;
+ }
+
+ if (isAnimeAnilistRouteId(routeId)) {
+ return null;
+ }
+
+ const numeric = Number(routeId);
+ if (!Number.isInteger(numeric) || numeric <= 0) {
+ return null;
+ }
+
+ if (defaultAnilistId > 0 && numeric === defaultAnilistId) {
+ return null;
+ }
+
+ return numeric;
+};
diff --git a/apps/web/lib/anime/resolve-playback-coords.ts b/apps/web/lib/anime/resolve-playback-coords.ts
index f8be96db..46122c1f 100644
--- a/apps/web/lib/anime/resolve-playback-coords.ts
+++ b/apps/web/lib/anime/resolve-playback-coords.ts
@@ -6,6 +6,7 @@ import {
resolveAniBridgeMalPlaybackTarget,
resolveAniBridgePlaybackCoords,
} from "@/lib/anime/anibridge-mappings";
+import { advanceFribbPlaybackAcrossSpecialSequels } from "@/lib/anime/split-cour-appendix";
import {
animeSeasonNumberForEpisode,
findSegmentForEpisode,
@@ -21,6 +22,7 @@ import {
fetchAnilistIdByMal,
fetchAnilistMediaMeta,
} from "@/lib/scrape/anime/anilist-meta";
+import { getCachedAnilistTvMedia } from "@/lib/anilist-tv-detail";
export type AnimePlaybackCoords = {
anilistId: number;
@@ -151,13 +153,30 @@ export const resolveAnimePlaybackCoords = async (input: {
);
if (!fribb) return null;
- return enrichCoordsWithAnilistMeta({
+ const baseMedia = await getCachedAnilistTvMedia(fribb.anilistId);
+ const sequelNode = baseMedia?.relations?.edges?.find(
+ (edge) =>
+ edge.relationType === "SEQUEL" &&
+ edge.node?.type === "ANIME" &&
+ edge.node.format === "SPECIAL" &&
+ typeof edge.node.id === "number" &&
+ edge.node.id > 0,
+ )?.node;
+ const adjusted = advanceFribbPlaybackAcrossSpecialSequels({
anilistId: fribb.anilistId,
- relativeEpisodeNumber: fribb.relativeEpisode,
+ relativeEpisode: fribb.relativeEpisode,
+ segmentStart: fribb.segmentStart,
+ episodeCount: baseMedia?.episodes,
+ sequelSpecialId: sequelNode?.id,
+ });
+
+ return enrichCoordsWithAnilistMeta({
+ anilistId: adjusted.anilistId,
+ relativeEpisodeNumber: adjusted.relativeEpisode,
animeSeasonNumber: input.seasonNumber,
animeInfo: {
- anilistId: fribb.anilistId,
- startEpisode: fribb.segmentStart,
+ anilistId: adjusted.anilistId,
+ startEpisode: adjusted.segmentStart,
endEpisode: Number.MAX_SAFE_INTEGER,
},
source: "fribb",
diff --git a/apps/web/lib/anime/scrape-start-gate.ts b/apps/web/lib/anime/scrape-start-gate.ts
new file mode 100644
index 00000000..517e190a
--- /dev/null
+++ b/apps/web/lib/anime/scrape-start-gate.ts
@@ -0,0 +1,42 @@
+import type { MappingConfidence } from "@/lib/anime/tmdb-anilist-map";
+
+import {
+ type AnimeCoordsStatus,
+ shouldHoldAnimePlaybackStart,
+} from "@/lib/anime/anime-playback-policy";
+
+export type { AnimeCoordsStatus };
+
+export type ScrapeStartGateInput = {
+ defaultAnilistId: number | null;
+ hasSelectedEpisode: boolean;
+ animeCoordsStatus: AnimeCoordsStatus;
+ mappingConfidence: MappingConfidence | null;
+ isDirectMode: boolean;
+ isAnimeScrapeActive: boolean;
+ animeCoordsReady?: boolean;
+ animeFirst?: boolean;
+};
+
+/** @deprecated Use shouldHoldAnimePlaybackStart from anime-playback-policy. */
+export const shouldHoldScrapeForAnimeCoords = (
+ input: ScrapeStartGateInput,
+): boolean => {
+ const animeCoordsReady =
+ input.animeCoordsReady ??
+ (input.isAnimeScrapeActive || input.mappingConfidence === "high");
+
+ return shouldHoldAnimePlaybackStart({
+ animeFirst: input.animeFirst ?? Boolean(input.defaultAnilistId),
+ hasSelectedEpisode: input.hasSelectedEpisode,
+ animeCoordsReady,
+ animeCoordsStatus: input.animeCoordsStatus,
+ });
+};
+
+export type ScrapeSessionKind = "direct" | "anime" | "tmdb";
+
+export const scrapeSessionKeyFor = (
+ kind: ScrapeSessionKind,
+ mediaKey: string,
+): string => `${kind}:${mediaKey}`;
diff --git a/apps/web/lib/anime/split-cour-appendix.ts b/apps/web/lib/anime/split-cour-appendix.ts
new file mode 100644
index 00000000..efb57e35
--- /dev/null
+++ b/apps/web/lib/anime/split-cour-appendix.ts
@@ -0,0 +1,153 @@
+import type { MappingSegment } from "@/lib/anime/tmdb-anilist-map";
+import type { FribbAnimeRow } from "@/lib/fribb-mapping";
+import { normalizeFribbTmdbShowId } from "@/lib/fribb-mapping";
+
+export const fribbTmdbSeasonNumberForRow = (row: FribbAnimeRow): number => {
+ const season = row.season?.tmdb;
+ if (season === undefined || season === null || season === 0) {
+ return 1;
+ }
+ return season;
+};
+
+export const collectFribbAnilistIdsForTmdbSeason = (
+ fribbRows: readonly FribbAnimeRow[],
+ tmdbShowId: number,
+ seasonNumber: number,
+): number[] =>
+ fribbRows
+ .filter(
+ (row) =>
+ normalizeFribbTmdbShowId(row.themoviedb_id) === tmdbShowId &&
+ fribbTmdbSeasonNumberForRow(row) === seasonNumber,
+ )
+ .sort((left, right) => {
+ const offsetDiff =
+ (left.episode_offset?.tmdb ?? 0) - (right.episode_offset?.tmdb ?? 0);
+ if (offsetDiff !== 0) return offsetDiff;
+ return left.anilist_id - right.anilist_id;
+ })
+ .map((row) => row.anilist_id);
+
+export const mergeAnilistIdsByFribbOffset = (
+ franchiseIds: readonly number[],
+ fribbIds: readonly number[],
+ fribbRows: readonly FribbAnimeRow[],
+): number[] => {
+ const merged = [...new Set([...franchiseIds, ...fribbIds])];
+ const offsetFor = (anilistId: number): number =>
+ fribbRows.find((row) => row.anilist_id === anilistId)?.episode_offset
+ ?.tmdb ?? 0;
+
+ return merged.sort(
+ (left, right) => offsetFor(left) - offsetFor(right) || left - right,
+ );
+};
+
+export const hasFribbSplitCourForTmdbSeason = (
+ fribbRows: readonly FribbAnimeRow[],
+ tmdbShowId: number,
+ seasonNumber: number,
+): boolean =>
+ collectFribbAnilistIdsForTmdbSeason(fribbRows, tmdbShowId, seasonNumber)
+ .length > 1;
+
+export const buildFribbSeasonSegments = (
+ fribbRows: readonly FribbAnimeRow[],
+ tmdbShowId: number,
+ seasonNumber: number,
+): MappingSegment[] => {
+ const anilistIds = collectFribbAnilistIdsForTmdbSeason(
+ fribbRows,
+ tmdbShowId,
+ seasonNumber,
+ );
+ const seasonRows = fribbRows
+ .filter((row) => anilistIds.includes(row.anilist_id))
+ .sort(
+ (left, right) =>
+ (left.episode_offset?.tmdb ?? 0) - (right.episode_offset?.tmdb ?? 0),
+ );
+
+ const segments: MappingSegment[] = [];
+
+ for (let index = 0; index < seasonRows.length; index += 1) {
+ const row = seasonRows[index]!;
+ const offset = row.episode_offset?.tmdb ?? 0;
+ const startEpisode =
+ offset > 0
+ ? offset + 1
+ : segments.length > 0
+ ? segments[segments.length - 1]!.endEpisode + 1
+ : 1;
+ const nextOffset = seasonRows[index + 1]?.episode_offset?.tmdb;
+ const endEpisode =
+ nextOffset != null && nextOffset > offset ? nextOffset : startEpisode;
+
+ segments.push({
+ startEpisode,
+ endEpisode,
+ anilistMediaId: row.anilist_id,
+ });
+ }
+
+ return segments;
+};
+
+export const appendMappingSegmentsBeyond = (
+ base: readonly MappingSegment[],
+ appendix: readonly MappingSegment[],
+): MappingSegment[] => {
+ if (appendix.length === 0) {
+ return [...base];
+ }
+
+ const coveredEnd =
+ base.length > 0
+ ? Math.max(...base.map((segment) => segment.endEpisode))
+ : 0;
+
+ const merged = [...base];
+ for (const segment of appendix) {
+ if (segment.startEpisode > coveredEnd) {
+ merged.push(segment);
+ }
+ }
+
+ return merged.sort((left, right) => left.startEpisode - right.startEpisode);
+};
+
+export const advanceFribbPlaybackAcrossSpecialSequels = (input: {
+ anilistId: number;
+ relativeEpisode: number;
+ segmentStart: number;
+ episodeCount: number | null | undefined;
+ sequelSpecialId: number | null | undefined;
+}): {
+ anilistId: number;
+ relativeEpisode: number;
+ segmentStart: number;
+} => {
+ const episodeCount =
+ typeof input.episodeCount === "number" && input.episodeCount > 0
+ ? input.episodeCount
+ : 1;
+
+ if (
+ input.relativeEpisode <= episodeCount ||
+ !input.sequelSpecialId ||
+ input.sequelSpecialId <= 0
+ ) {
+ return {
+ anilistId: input.anilistId,
+ relativeEpisode: input.relativeEpisode,
+ segmentStart: input.segmentStart,
+ };
+ }
+
+ return {
+ anilistId: input.sequelSpecialId,
+ relativeEpisode: input.relativeEpisode - episodeCount,
+ segmentStart: input.segmentStart + episodeCount,
+ };
+};
diff --git a/apps/web/lib/anime/tmdb-anilist-map.ts b/apps/web/lib/anime/tmdb-anilist-map.ts
index c27426f5..a20971cb 100644
--- a/apps/web/lib/anime/tmdb-anilist-map.ts
+++ b/apps/web/lib/anime/tmdb-anilist-map.ts
@@ -169,3 +169,37 @@ export const resolveAnimeEpisodeCoords = (input: {
confidence: "low",
};
};
+
+export type EpisodeListPresentation = {
+ splitCour: boolean;
+ showTmdbSeasonSelect: boolean;
+ showCourSelect: boolean;
+ courSelectReplacesSeasons: boolean;
+};
+
+export const episodeListPresentation = (input: {
+ tmdbSeasonCount: number;
+ segmentCount: number;
+}): EpisodeListPresentation => {
+ const splitCour = input.segmentCount > 1;
+ const courSelectReplacesSeasons = splitCour && input.tmdbSeasonCount <= 1;
+
+ return {
+ splitCour,
+ showTmdbSeasonSelect: !courSelectReplacesSeasons,
+ showCourSelect: splitCour,
+ courSelectReplacesSeasons,
+ };
+};
+
+export const formatCourSelectLabel = (
+ segment: MappingSegment,
+ index: number,
+ options: { besideTmdbSeasons: boolean },
+): string => {
+ const episodeCount = segment.endEpisode - segment.startEpisode + 1;
+ if (options.besideTmdbSeasons) {
+ return `Part ${index + 1} · ${episodeCount} ep${episodeCount === 1 ? "" : "s"}`;
+ }
+ return `Season ${index + 1}`;
+};
diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts
index 5a27781b..3c26b8ee 100644
--- a/apps/web/lib/api.ts
+++ b/apps/web/lib/api.ts
@@ -3,6 +3,7 @@ import type {
CanonicalPersonCard,
Genre,
} from "@/lib/domain/typings";
+import { withPageTvApiPath } from "@/lib/tv-detail-catalog";
export type SearchPreviewResult = CanonicalMediaCard;
@@ -159,7 +160,13 @@ export async function fetchTvSeason(
tvId: number,
seasonNumber: number,
): Promise {
- const response = await fetch(`/api/tv/${tvId}/season/${seasonNumber}`);
+ const pathname =
+ typeof window !== "undefined" ? (window.location?.pathname ?? "") : "";
+ const path = withPageTvApiPath(
+ `/api/tv/${tvId}/season/${seasonNumber}`,
+ pathname,
+ );
+ const response = await fetch(path);
if (!response.ok) {
throw new Error(`Failed to fetch season ${seasonNumber}`);
diff --git a/apps/web/lib/cast-person-href.ts b/apps/web/lib/cast-person-href.ts
new file mode 100644
index 00000000..8dcd3a8d
--- /dev/null
+++ b/apps/web/lib/cast-person-href.ts
@@ -0,0 +1,21 @@
+import { isRemoteImagePath } from "@/lib/media-poster-path";
+
+export const resolveCastPersonHref = (person: {
+ id: number;
+ href?: string | null;
+ profile_path?: string | null;
+}): string | null => {
+ if (person.href === null) {
+ return null;
+ }
+
+ if (typeof person.href === "string" && person.href.trim().length > 0) {
+ return person.href;
+ }
+
+ if (isRemoteImagePath(person.profile_path)) {
+ return null;
+ }
+
+ return `/person/${person.id}`;
+};
diff --git a/apps/web/lib/domain/typings.ts b/apps/web/lib/domain/typings.ts
index f6aef28f..1c9430ba 100644
--- a/apps/web/lib/domain/typings.ts
+++ b/apps/web/lib/domain/typings.ts
@@ -41,6 +41,8 @@ export const EpisodeSchema = z.object({
runtime: z.number().nullable(),
vote_average: z.number().optional(),
vote_count: z.number().optional(),
+ sourceAnilistId: z.number().int().positive().optional(),
+ sourceEpisodeNumber: z.number().int().positive().optional(),
});
export const SeasonSchema = z.object({
diff --git a/apps/web/lib/ffs/ffs-host-paths.ts b/apps/web/lib/ffs/ffs-host-paths.ts
new file mode 100644
index 00000000..deb94fbd
--- /dev/null
+++ b/apps/web/lib/ffs/ffs-host-paths.ts
@@ -0,0 +1,12 @@
+const FFS_HOST_ALLOWED_PREFIXES = [
+ "/ffs",
+ "/api/ffs",
+ "/api/auth",
+ "/api/cap",
+ "/api/site",
+] as const;
+
+export const isFfsHostAllowedPath = (pathname: string): boolean =>
+ FFS_HOST_ALLOWED_PREFIXES.some(
+ (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
+ );
diff --git a/apps/web/lib/flags/flags-sync.ts b/apps/web/lib/flags/flags-sync.ts
index fa0c369d..7973e47e 100644
--- a/apps/web/lib/flags/flags-sync.ts
+++ b/apps/web/lib/flags/flags-sync.ts
@@ -1,6 +1,3 @@
-/** Next.js `unstable_cache` / `revalidateTag` id for public site flags. */
-export const SITE_FLAGS_REVALIDATE_TAG = "site-flags-public";
-
/** Cross-tab signal after FFS saves so open tabs refetch `/api/site/flags`. */
export const FLAGS_UPDATED_BROADCAST_CHANNEL = "nyumatflix:site-flags-updated";
diff --git a/apps/web/lib/flags/flipt-admin.ts b/apps/web/lib/flags/flipt-admin.ts
index cc682cab..fa04f183 100644
--- a/apps/web/lib/flags/flipt-admin.ts
+++ b/apps/web/lib/flags/flipt-admin.ts
@@ -1,7 +1,6 @@
export {
readAdminFlagState,
writeAdminFlagState,
- invalidateFlagCache,
readAnnouncementBannerConfig,
readProviderMenuOrderConfig,
} from "@/lib/flags/flipt-client";
diff --git a/apps/web/lib/flags/flipt-client.ts b/apps/web/lib/flags/flipt-client.ts
index 53b7464c..c1a421f5 100644
--- a/apps/web/lib/flags/flipt-client.ts
+++ b/apps/web/lib/flags/flipt-client.ts
@@ -1,4 +1,3 @@
-import { IS_DEV } from "@/lib/cache-policy";
import {
ALL_FLAG_DEFINITIONS,
DEFAULT_FLAG_VALUES,
@@ -25,11 +24,35 @@ export const FLIPT_ENVIRONMENT = process.env.FLIPT_ENVIRONMENT ?? "default";
export const FLIPT_NAMESPACE = process.env.FLIPT_NAMESPACE ?? "default";
export const FLIPT_API_TOKEN = process.env.FLIPT_API_TOKEN ?? "";
-/** Flags change rarely; long TTL avoids Flipt fan-out under scrape load. */
-const CACHE_TTL_MS = IS_DEV
- ? 0
- : Number(process.env.FLIPT_FLAG_CACHE_TTL_MS ?? "600000");
const FLAG_TYPE_URL = "flipt.core.Flag";
+const ANNOUNCEMENT_FLAG_KEY = "global.announcement_banner";
+const PROVIDER_MENU_ORDER_FLAG_KEY = "global.provider_menu_order";
+
+export function readFliptMetadataValue(
+ metadata: Record | undefined,
+ key: string,
+): unknown {
+ const value = metadata?.[key];
+ if (typeof value !== "string") {
+ return value;
+ }
+ try {
+ return JSON.parse(value) as unknown;
+ } catch {
+ return undefined;
+ }
+}
+
+function withJsonMetadata(
+ current: Record | undefined,
+ key: string,
+ value: unknown,
+): Record {
+ return {
+ ...(current ?? {}),
+ [key]: JSON.stringify(value),
+ };
+}
/** Flipt v2 keys only allow [-_,A-Za-z0-9]; catalog keys use dots. */
export function toFliptStorageKey(catalogKey: string): string {
@@ -44,9 +67,7 @@ if (FLAG_DEFINITIONS_BY_STORAGE_KEY.size !== ALL_FLAG_DEFINITIONS.length) {
throw new Error("Flipt flag storage keys must be unique");
}
-type CacheEntry = { expiresAt: number; state: AdminFlagState };
-
-let flagCache: CacheEntry | null = null;
+let lastKnownFlagState: AdminFlagState | null = null;
let failureCacheExpiresAt = 0;
const FAILURE_CACHE_TTL_MS = 5000;
@@ -139,20 +160,18 @@ function authHeaders(): HeadersInit {
}
export function invalidateFlagCache(): void {
- flagCache = null;
+ lastKnownFlagState = null;
}
export function getCachedRawFlagsSync(): AdminFlagState | null {
- if (flagCache && flagCache.expiresAt > Date.now()) {
- return flagCache.state;
- }
- return null;
+ return lastKnownFlagState;
}
async function fliptFetch(path: string, init?: RequestInit): Promise {
const url = `${FLIPT_URL}${path}`;
return fetch(url, {
...init,
+ cache: "no-store",
headers: {
...authHeaders(),
...(init?.headers ?? {}),
@@ -180,12 +199,45 @@ async function listFlags(): Promise> {
return (await res.json()) as FliptResourceList;
}
+async function getFlag(storageKey: string): Promise {
+ const res = await fliptFetch(
+ `${flagsResourcePath()}/${encodeURIComponent(FLAG_TYPE_URL)}/${encodeURIComponent(storageKey)}`,
+ );
+ if (res.status === 404) {
+ return undefined;
+ }
+ if (!res.ok) {
+ throw await responseError(`Flipt get flag ${storageKey} failed`, res);
+ }
+ const body = (await res.json()) as FliptResourceResponse;
+ return body.resource?.payload;
+}
+
+async function hydrateFlagMetadata(
+ existing: Map,
+ storageKeys: readonly string[],
+): Promise {
+ await Promise.all(
+ storageKeys.map(async (storageKey) => {
+ const current = existing.get(storageKey);
+ if (current?.metadata && Object.keys(current.metadata).length > 0) {
+ return;
+ }
+ const fetched = await getFlag(storageKey);
+ if (fetched) {
+ existing.set(storageKey, fetched);
+ }
+ }),
+ );
+}
+
function flagPayload(
def: FlagDefinition,
enabled: boolean,
current?: FliptFlag,
metadata?: Record,
): FliptFlag {
+ const nextMetadata = metadata ?? current?.metadata;
return {
...current,
"@type": FLAG_TYPE_URL,
@@ -194,7 +246,7 @@ function flagPayload(
description: def.description ?? "",
enabled,
type: "BOOLEAN_FLAG_TYPE",
- ...(metadata ? { metadata } : {}),
+ ...(nextMetadata ? { metadata: nextMetadata } : {}),
};
}
@@ -235,15 +287,10 @@ export async function ensureFlagsSeeded(): Promise {
}
}
-export async function readAdminFlagState(options?: {
- skipCache?: boolean;
-}): Promise {
+export async function readAdminFlagState(): Promise {
const now = Date.now();
- if (!options?.skipCache && flagCache && flagCache.expiresAt > now) {
- return flagCache.state;
- }
if (failureCacheExpiresAt > now) {
- return { ...DEFAULT_FLAG_VALUES };
+ return lastKnownFlagState ?? { ...DEFAULT_FLAG_VALUES };
}
try {
@@ -258,11 +305,11 @@ export async function readAdminFlagState(options?: {
}
}
clearFailureCooldown();
- flagCache = { expiresAt: now + CACHE_TTL_MS, state };
+ lastKnownFlagState = state;
return state;
} catch (error) {
enterFailureCooldown(now, error);
- return { ...DEFAULT_FLAG_VALUES };
+ return lastKnownFlagState ?? { ...DEFAULT_FLAG_VALUES };
}
}
@@ -278,13 +325,17 @@ export async function writeAdminFlagState(
resource.payload,
]),
);
+ await hydrateFlagMetadata(existing, [
+ toFliptStorageKey(ANNOUNCEMENT_FLAG_KEY),
+ toFliptStorageKey(PROVIDER_MENU_ORDER_FLAG_KEY),
+ ]);
let revision = listed.revision;
for (const def of ALL_FLAG_DEFINITIONS) {
const enabled = state[def.key] ?? def.defaultValue;
const current = existing.get(toFliptStorageKey(def.key));
- const isAnnouncement = def.key === "global.announcement_banner";
- const isMenuOrder = def.key === "global.provider_menu_order";
+ const isAnnouncement = def.key === ANNOUNCEMENT_FLAG_KEY;
+ const isMenuOrder = def.key === PROVIDER_MENU_ORDER_FLAG_KEY;
const bannerConfig =
isAnnouncement && announcementBanner
? sanitizeAnnouncementBannerConfig(announcementBanner)
@@ -294,17 +345,23 @@ export async function writeAdminFlagState(
? sanitizeProviderMenuOrderConfig(providerMenuOrder)
: undefined;
const metadata = bannerConfig
- ? { ...(current?.metadata ?? {}), announcementBanner: bannerConfig }
+ ? withJsonMetadata(current?.metadata, "announcementBanner", bannerConfig)
: menuOrderConfig
- ? { ...(current?.metadata ?? {}), providerMenuOrder: menuOrderConfig }
+ ? withJsonMetadata(
+ current?.metadata,
+ "providerMenuOrder",
+ menuOrderConfig,
+ )
: undefined;
const configChanged = Boolean(
(bannerConfig &&
- JSON.stringify(current?.metadata?.announcementBanner) !==
- JSON.stringify(bannerConfig)) ||
+ JSON.stringify(
+ readFliptMetadataValue(current?.metadata, "announcementBanner"),
+ ) !== JSON.stringify(bannerConfig)) ||
(menuOrderConfig &&
- JSON.stringify(current?.metadata?.providerMenuOrder) !==
- JSON.stringify(menuOrderConfig)),
+ JSON.stringify(
+ readFliptMetadataValue(current?.metadata, "providerMenuOrder"),
+ ) !== JSON.stringify(menuOrderConfig)),
);
if (!current) {
revision = await mutateFlag(
@@ -337,13 +394,9 @@ export async function readAnnouncementBannerConfig(): Promise
- resource.key === toFliptStorageKey("global.announcement_banner"),
- );
+ const banner = await getFlag(toFliptStorageKey(ANNOUNCEMENT_FLAG_KEY));
return sanitizeAnnouncementBannerConfig(
- banner?.payload.metadata?.announcementBanner,
+ readFliptMetadataValue(banner?.metadata, "announcementBanner"),
);
} catch (error) {
enterFailureCooldown(now, error);
@@ -358,13 +411,11 @@ export async function readProviderMenuOrderConfig(): Promise
- resource.key === toFliptStorageKey("global.provider_menu_order"),
+ const menuOrderFlag = await getFlag(
+ toFliptStorageKey(PROVIDER_MENU_ORDER_FLAG_KEY),
);
return sanitizeProviderMenuOrderConfig(
- menuOrderFlag?.payload.metadata?.providerMenuOrder,
+ readFliptMetadataValue(menuOrderFlag?.metadata, "providerMenuOrder"),
);
} catch (error) {
enterFailureCooldown(now, error);
diff --git a/apps/web/lib/flags/revalidate-site-flags.ts b/apps/web/lib/flags/revalidate-site-flags.ts
deleted file mode 100644
index 9b5b5faf..00000000
--- a/apps/web/lib/flags/revalidate-site-flags.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import "server-only";
-
-import { SITE_FLAGS_REVALIDATE_TAG } from "@/lib/flags/flags-sync";
-import { revalidateTag } from "next/cache";
-
-/** Bust Next.js cached `/api/site/flags` after FFS writes. */
-export function revalidateSiteFlagsCache(): void {
- try {
- revalidateTag(SITE_FLAGS_REVALIDATE_TAG);
- } catch (error) {
- console.warn("[flags] revalidateTag failed:", error);
- }
-}
diff --git a/apps/web/lib/flags/site-flags.ts b/apps/web/lib/flags/site-flags.ts
index 3947c749..56b88071 100644
--- a/apps/web/lib/flags/site-flags.ts
+++ b/apps/web/lib/flags/site-flags.ts
@@ -122,11 +122,9 @@ export function resolveSiteFlags(
};
}
-export async function getSiteFlags(options?: {
- skipCache?: boolean;
-}): Promise {
+export async function getSiteFlags(): Promise {
const [raw, announcementConfig, menuOrder] = await Promise.all([
- readAdminFlagState(options),
+ readAdminFlagState(),
readAnnouncementBannerConfig(),
readProviderMenuOrderConfig(),
]);
@@ -143,6 +141,20 @@ export function getPlaybackModePolicy(flags: SiteFlags): PlaybackModePolicy {
return "choice";
}
+export function shouldSeedDefaultProxyPlayback(input: {
+ policy: PlaybackModePolicy;
+ defaultProxyPlayback: boolean;
+ hasUserSelectedPlaybackServer: boolean;
+ selectedServerIsScrape: boolean;
+}): boolean {
+ return (
+ input.policy === "choice" &&
+ input.defaultProxyPlayback &&
+ !input.hasUserSelectedPlaybackServer &&
+ !input.selectedServerIsScrape
+ );
+}
+
export function isEmbedProviderEnabled(
flags: SiteFlags,
id: EmbedProviderId | string,
diff --git a/apps/web/lib/fribb-mapping.ts b/apps/web/lib/fribb-mapping.ts
index 7a478e5e..86b9635d 100644
--- a/apps/web/lib/fribb-mapping.ts
+++ b/apps/web/lib/fribb-mapping.ts
@@ -275,7 +275,7 @@ export const resolveFribbPlaybackCoords = (
for (const row of seasonRows) {
const offset = row.episode_offset?.tmdb ?? 0;
if (
- episodeNumber >= offset &&
+ episodeNumber > offset &&
offset >= (chosen.episode_offset?.tmdb ?? 0)
) {
chosen = row;
diff --git a/apps/web/lib/mal/list-index-shared.ts b/apps/web/lib/mal/list-index-shared.ts
index 45eeaacd..5f4b189f 100644
--- a/apps/web/lib/mal/list-index-shared.ts
+++ b/apps/web/lib/mal/list-index-shared.ts
@@ -49,6 +49,11 @@ export type MalEntryOptimisticPatch = {
numEpisodesWatched?: number;
};
+export const shouldApplyMalPatchResponse = (
+ patchGeneration: number,
+ latestGeneration: number,
+): boolean => patchGeneration === latestGeneration;
+
export function applyMalEntryPatch(
entry: MalEntryResponse,
patch: MalEntryOptimisticPatch,
diff --git a/apps/web/lib/media-above-fold-server.ts b/apps/web/lib/media-above-fold-server.ts
index ff38c01e..aa45de2e 100644
--- a/apps/web/lib/media-above-fold-server.ts
+++ b/apps/web/lib/media-above-fold-server.ts
@@ -5,6 +5,7 @@ import {
getCachedTvShowDetail,
} from "@/lib/media-detail-cache";
import { CACHE_REVALIDATE_SECONDS } from "@/lib/http-cache";
+import { unwrapTmdbLookupId } from "@/lib/tmdb-anime-route-id";
import {
type MediaAboveFoldDetail,
type MediaAboveFoldType,
@@ -122,11 +123,12 @@ export const getCachedMediaAboveFoldDetail = cache(
mediaType: MediaAboveFoldType,
id: string,
): Promise => {
+ const tmdbId = unwrapTmdbLookupId(id);
const append =
mediaType === "movie"
? "images,videos,external_ids,release_dates"
: "images,videos,external_ids,content_ratings";
- const url = new URL(`${TMDB_BASE_URL}/${mediaType}/${id}`);
+ const url = new URL(`${TMDB_BASE_URL}/${mediaType}/${tmdbId}`);
url.searchParams.set("api_key", process.env.TMDB_API_KEY ?? "");
url.searchParams.set("language", "en-US");
url.searchParams.set("append_to_response", append);
diff --git a/apps/web/lib/media-detail-cache.ts b/apps/web/lib/media-detail-cache.ts
index 503b7fff..f2903d07 100644
--- a/apps/web/lib/media-detail-cache.ts
+++ b/apps/web/lib/media-detail-cache.ts
@@ -4,6 +4,7 @@ import {
parseAnimeAnilistRouteId,
} from "@/lib/anilist-route-id";
import { CACHE_REVALIDATE_SECONDS } from "@/lib/http-cache";
+import { unwrapTmdbLookupId } from "@/lib/tmdb-anime-route-id";
import { fetchTVShowDetails } from "@/lib/server/tvshow-api";
import { type MediaItem } from "@/lib/domain/typings";
import { pickEnglishLogo } from "@/lib/tmdb-logo";
@@ -45,13 +46,14 @@ const movieDetailFetchInit = (id: string) =>
export const getCachedMovieDetail = cache(
async (id: string): Promise => {
+ const tmdbId = unwrapTmdbLookupId(id);
try {
- const url = new URL(`${TMDB_BASE_URL}/movie/${id}`);
+ const url = new URL(`${TMDB_BASE_URL}/movie/${tmdbId}`);
url.searchParams.set("api_key", process.env.TMDB_API_KEY ?? "");
url.searchParams.set("language", "en-US");
url.searchParams.set("append_to_response", MOVIE_DETAIL_APPEND);
- const response = await fetch(url, movieDetailFetchInit(id));
+ const response = await fetch(url, movieDetailFetchInit(tmdbId));
if (!response.ok) {
return null;
@@ -74,16 +76,21 @@ export type TvShowDetailCacheOptions = {
animeCatalog?: boolean;
};
-export const getCachedTvShowDetail = cache(
- (id: string, options?: TvShowDetailCacheOptions) => {
- const useAnilist = options?.animeCatalog
+const getCachedTvShowDetailCached = cache(
+ (id: string, animeCatalog: boolean) => {
+ const useAnilist = animeCatalog
? parseAnimeAnilistRouteId(id) !== null
: isAnilistTvRouteId(id);
return useAnilist
? getCachedAnilistTvShowDetail(id, {
- acceptBareNumeric: options?.animeCatalog === true,
+ acceptBareNumeric: animeCatalog,
})
- : fetchTVShowDetails(id);
+ : fetchTVShowDetails(unwrapTmdbLookupId(id));
},
);
+
+export const getCachedTvShowDetail = (
+ id: string,
+ options?: TvShowDetailCacheOptions,
+) => getCachedTvShowDetailCached(id, options?.animeCatalog === true);
diff --git a/apps/web/lib/media-detail-tab-client.ts b/apps/web/lib/media-detail-tab-client.ts
index 004e8f33..e62bf1c2 100644
--- a/apps/web/lib/media-detail-tab-client.ts
+++ b/apps/web/lib/media-detail-tab-client.ts
@@ -15,7 +15,7 @@ import type {
import {
TV_DETAIL_CATALOG_ANIME,
TV_DETAIL_CATALOG_QUERY,
- withTvDetailCatalogQuery,
+ toTvApiRouteId,
type TvDetailCatalog,
} from "@/lib/tv-detail-catalog";
@@ -55,8 +55,9 @@ async function fetchMediaTabResource(
}
const query = params.toString();
+ const apiId = mediaType === "tv" ? toTvApiRouteId(id, catalog ?? null) : id;
const response = await fetch(
- `/api/media/${mediaType}/${id}/${resource}${query ? `?${query}` : ""}`,
+ `/api/media/${mediaType}/${apiId}/${resource}${query ? `?${query}` : ""}`,
);
if (!response.ok) {
@@ -141,22 +142,65 @@ export const fetchTvAllSeasonsClient = (
catalog,
);
-export const fetchTvImagesClient = (id: string) =>
- fetchMediaTabResource("tv", id, "images");
+export const fetchTvImagesClient = (
+ id: string,
+ catalog?: TvDetailCatalog | null,
+) =>
+ fetchMediaTabResource(
+ "tv",
+ id,
+ "images",
+ undefined,
+ catalog,
+ );
-export const fetchTvVideosClient = (id: string) =>
- fetchMediaTabResource("tv", id, "videos");
+export const fetchTvVideosClient = (
+ id: string,
+ catalog?: TvDetailCatalog | null,
+) =>
+ fetchMediaTabResource(
+ "tv",
+ id,
+ "videos",
+ undefined,
+ catalog,
+ );
-export const fetchTvReviewsPageClient = (id: string, page: string) =>
- fetchMediaTabResource>("tv", id, "reviews", page);
+export const fetchTvReviewsPageClient = (
+ id: string,
+ page: string,
+ catalog?: TvDetailCatalog | null,
+) =>
+ fetchMediaTabResource>(
+ "tv",
+ id,
+ "reviews",
+ page,
+ catalog,
+ );
-export const fetchTvRecommendationsPageClient = (id: string, page: string) =>
+export const fetchTvRecommendationsPageClient = (
+ id: string,
+ page: string,
+ catalog?: TvDetailCatalog | null,
+) =>
fetchMediaTabResource>(
"tv",
id,
"recommendations",
page,
+ catalog,
);
-export const fetchTvSimilarPageClient = (id: string, page: string) =>
- fetchMediaTabResource>("tv", id, "similar", page);
+export const fetchTvSimilarPageClient = (
+ id: string,
+ page: string,
+ catalog?: TvDetailCatalog | null,
+) =>
+ fetchMediaTabResource>(
+ "tv",
+ id,
+ "similar",
+ page,
+ catalog,
+ );
diff --git a/apps/web/lib/media-poster-path.ts b/apps/web/lib/media-poster-path.ts
index a6b79f13..6873ccf0 100644
--- a/apps/web/lib/media-poster-path.ts
+++ b/apps/web/lib/media-poster-path.ts
@@ -1,6 +1,9 @@
const isNonEmptyPath = (p: string | null | undefined): boolean =>
typeof p === "string" && p.trim() !== "";
+export const isRemoteImagePath = (path: string | null | undefined): boolean =>
+ typeof path === "string" && /^https?:\/\//i.test(path.trim());
+
export const hasPosterPath = (item: { poster_path?: string | null }): boolean =>
isNonEmptyPath(item.poster_path);
diff --git a/apps/web/lib/personalization/because-you-watched.ts b/apps/web/lib/personalization/because-you-watched.ts
index 79f64457..62a64975 100644
--- a/apps/web/lib/personalization/because-you-watched.ts
+++ b/apps/web/lib/personalization/because-you-watched.ts
@@ -4,6 +4,7 @@ import {
fetchTvRecommendationsPageClient,
} from "@/lib/media-detail-tab-client";
import type { RecentlyWatchedStub } from "@/lib/playback/recently-watched";
+import { fetchTvShowDetailClient } from "@/lib/tv-show-detail-client";
import type { MovieWithMediaType, TvShowWithMediaType } from "@/tmdb/models";
export const BECAUSE_YOU_WATCHED_LIMIT = 20;
@@ -20,38 +21,43 @@ export type BecauseYouWatchedResult =
items: TvShowWithMediaType[];
};
-type MediaDetailResponse = {
+type MovieDetailResponse = {
id?: number;
title?: string;
- name?: string;
};
-const isMatchingTmdbDetail = (
+const isMatchingMovieDetail = (
stub: RecentlyWatchedStub,
- detail: MediaDetailResponse,
+ detail: MovieDetailResponse,
): boolean => detail.id === stub.contentId;
async function fetchSeedTitle(
stub: RecentlyWatchedStub,
-): Promise {
- const path =
- stub.mediaType === "movie"
- ? `/api/movies/${stub.contentId}`
- : `/api/tv/${stub.contentId}`;
-
- const response = await fetch(path);
- if (!response.ok) {
- return stub.title ?? null;
+): Promise<{ title: string; catalog: "anime" | null } | null> {
+ if (stub.mediaType === "movie") {
+ const response = await fetch(`/api/movies/${stub.contentId}`);
+ if (!response.ok) {
+ return stub.title ? { title: stub.title, catalog: null } : null;
+ }
+
+ const detail = (await response.json()) as MovieDetailResponse;
+ if (!isMatchingMovieDetail(stub, detail)) {
+ return null;
+ }
+
+ const title = detail.title ?? stub.title ?? null;
+ return title ? { title, catalog: null } : null;
}
- const detail = (await response.json()) as MediaDetailResponse;
- if (!isMatchingTmdbDetail(stub, detail)) {
- return null;
+ const fetched = await fetchTvShowDetailClient(stub.contentId, {
+ expectedTitle: stub.title,
+ });
+ if (!fetched) {
+ return stub.title ? { title: stub.title, catalog: null } : null;
}
- return stub.mediaType === "movie"
- ? (detail.title ?? stub.title ?? null)
- : (detail.name ?? stub.title ?? null);
+ const title = fetched.detail.name ?? stub.title ?? null;
+ return title ? { title, catalog: fetched.catalog } : null;
}
export async function fetchBecauseYouWatchedRow(
@@ -61,10 +67,11 @@ export async function fetchBecauseYouWatchedRow(
const seen = new Set(excludeIds);
for (const stub of stubs) {
- const seedTitle = await fetchSeedTitle(stub);
- if (!seedTitle) {
+ const seed = await fetchSeedTitle(stub);
+ if (!seed) {
continue;
}
+ const { title: seedTitle, catalog } = seed;
if (stub.mediaType === "movie") {
const page = await fetchMovieRecommendationsPageClient(
@@ -97,6 +104,7 @@ export async function fetchBecauseYouWatchedRow(
const page = await fetchTvRecommendationsPageClient(
String(stub.contentId),
"1",
+ catalog,
);
const items = takeUniqueByIdInOrder(
(page.results ?? []).map(
diff --git a/apps/web/lib/personalization/up-next.ts b/apps/web/lib/personalization/up-next.ts
index 552afbf9..078ac897 100644
--- a/apps/web/lib/personalization/up-next.ts
+++ b/apps/web/lib/personalization/up-next.ts
@@ -1,3 +1,4 @@
+import { buildAnilistTvDetailHref } from "@/lib/anilist-route-id";
import type { EpisodeCoordinates, EpisodeInfo } from "@/lib/domain/episodes";
import type { WatchlistItem } from "@/lib/domain/watchlist";
@@ -7,23 +8,56 @@ export type UpNextCandidate = {
episodeInfo: EpisodeInfo;
};
-export const buildUpNextHref = (
- contentId: number,
+const upNextEpisodeQuery = (
nextUnwatchedEpisode: EpisodeCoordinates | null,
fallbackSeason?: number | null,
fallbackEpisode?: number | null,
-): string => {
+): { season: number; episode: number } => {
if (nextUnwatchedEpisode) {
- return `/tvshows/${contentId}?season=${nextUnwatchedEpisode.seasonNumber}&episode=${nextUnwatchedEpisode.episodeNumber}&autoplay=true`;
+ return {
+ season: nextUnwatchedEpisode.seasonNumber,
+ episode: nextUnwatchedEpisode.episodeNumber,
+ };
}
- const season = fallbackSeason && fallbackSeason > 0 ? fallbackSeason : 1;
- const episode =
- fallbackEpisode && fallbackEpisode > 0 ? fallbackEpisode + 1 : 1;
+ return {
+ season: fallbackSeason && fallbackSeason > 0 ? fallbackSeason : 1,
+ episode: fallbackEpisode && fallbackEpisode > 0 ? fallbackEpisode + 1 : 1,
+ };
+};
+export const buildUpNextHref = (
+ contentId: number,
+ nextUnwatchedEpisode: EpisodeCoordinates | null,
+ fallbackSeason?: number | null,
+ fallbackEpisode?: number | null,
+): string => {
+ const { season, episode } = upNextEpisodeQuery(
+ nextUnwatchedEpisode,
+ fallbackSeason,
+ fallbackEpisode,
+ );
return `/tvshows/${contentId}?season=${season}&episode=${episode}&autoplay=true`;
};
+export const buildAnimeUpNextHref = (
+ contentId: number,
+ nextUnwatchedEpisode: EpisodeCoordinates | null,
+ fallbackSeason?: number | null,
+ fallbackEpisode?: number | null,
+): string => {
+ const { season, episode } = upNextEpisodeQuery(
+ nextUnwatchedEpisode,
+ fallbackSeason,
+ fallbackEpisode,
+ );
+ const base = buildAnilistTvDetailHref(contentId, { season });
+ const params = new URLSearchParams();
+ params.set("episode", String(episode));
+ params.set("autoplay", "true");
+ return `${base}${base.includes("?") ? "&" : "?"}${params.toString()}`;
+};
+
export const isUpNextInboxCandidate = (
item: WatchlistItem,
episodeInfo: EpisodeInfo | undefined,
diff --git a/apps/web/lib/playback/introdb.ts b/apps/web/lib/playback/introdb.ts
index d5edaa90..a82027ca 100644
--- a/apps/web/lib/playback/introdb.ts
+++ b/apps/web/lib/playback/introdb.ts
@@ -519,6 +519,8 @@ const formatVttTimestamp = (seconds: number): string => {
const segmentTitle = (type: IntroDbSegmentType): string =>
type.charAt(0).toUpperCase() + type.slice(1);
+export { segmentTitle as formatIntroDbSegmentTitle };
+
const introDbChapterColors: Record = {
intro: "rgb(217 70 239 / 0.92)",
recap: "rgb(245 158 11 / 0.92)",
diff --git a/apps/web/lib/playback/movi-subtitle-preference.ts b/apps/web/lib/playback/movi-subtitle-preference.ts
new file mode 100644
index 00000000..ecdb32c8
--- /dev/null
+++ b/apps/web/lib/playback/movi-subtitle-preference.ts
@@ -0,0 +1,116 @@
+import {
+ isJapaneseAudioPreference,
+ pickTrackIndexByLanguage,
+ type TrackLanguageFields,
+} from "@/lib/playback/track-matching";
+import type { MoviPlayerElement } from "@/lib/player/player-element";
+import {
+ getTrackPreferences,
+ type SubtitleTrackPreference,
+} from "@/lib/playback/track-preferences-storage";
+
+export type MoviSubtitleTrackFields = TrackLanguageFields & {
+ id: string | number;
+};
+
+export const resolvePreferredSubtitleLang = (
+ scopeKey: string,
+ options?: {
+ preferEnglishSubtitles?: boolean;
+ preferredAudioLang?: string | null;
+ },
+): SubtitleTrackPreference | null => {
+ const saved = getTrackPreferences(scopeKey)?.subtitleLang;
+ if (saved) {
+ return saved;
+ }
+
+ if (
+ options?.preferEnglishSubtitles ||
+ isJapaneseAudioPreference(options?.preferredAudioLang)
+ ) {
+ return "english";
+ }
+
+ return null;
+};
+
+const listExternalSubtitleTracks = (
+ player: MoviPlayerElement,
+): MoviSubtitleTrackFields[] =>
+ (player.getSubtitleLangs?.() ?? []).map((track) => ({
+ id: track.id,
+ lang: track.lang,
+ label: track.label,
+ }));
+
+const listMuxedSubtitleTracks = (
+ player: MoviPlayerElement,
+): MoviSubtitleTrackFields[] =>
+ (player.getSubtitleTracks?.() ?? []).map((track) => ({
+ id: track.id,
+ language: track.language,
+ label: track.label,
+ lang: track.language,
+ }));
+
+export const readActiveMoviSubtitlePreference = (
+ player: MoviPlayerElement,
+): SubtitleTrackPreference => {
+ const reader = (
+ player as MoviPlayerElement & {
+ getActiveSubtitlePreference?: () => string;
+ }
+ ).getActiveSubtitlePreference;
+
+ if (typeof reader === "function") {
+ return reader.call(player);
+ }
+
+ const activeExternal = listExternalSubtitleTracks(player).find((track) =>
+ (player.getSubtitleLangs?.() ?? []).some(
+ (entry) => entry.id === track.id && entry.active,
+ ),
+ );
+
+ if (activeExternal) {
+ return activeExternal.lang ?? activeExternal.label ?? "unknown";
+ }
+
+ return "off";
+};
+
+export const applyMoviSubtitlePreference = async (
+ player: MoviPlayerElement,
+ preferred: SubtitleTrackPreference | null | undefined,
+): Promise => {
+ if (!preferred || preferred === "off") {
+ await player.selectExternalSubtitle?.(null);
+ await player.selectSubtitleTrack?.(null);
+ return true;
+ }
+
+ const external = listExternalSubtitleTracks(player);
+ if (external.length > 0) {
+ const index = pickTrackIndexByLanguage(external, preferred);
+ if (index != null) {
+ const track = external[index];
+ if (track && typeof track.id === "string") {
+ return (await player.selectExternalSubtitle?.(track.id)) ?? false;
+ }
+ }
+ }
+
+ const muxed = listMuxedSubtitleTracks(player);
+ if (muxed.length > 0) {
+ const index = pickTrackIndexByLanguage(muxed, preferred);
+ if (index != null) {
+ const track = muxed[index];
+ if (track && typeof track.id === "number") {
+ return (await player.selectSubtitleTrack?.(track.id)) ?? false;
+ }
+ }
+ }
+
+ return false;
+};
diff --git a/apps/web/lib/playback/playback-preferences.ts b/apps/web/lib/playback/playback-preferences.ts
index 1461a4cf..bec89c04 100644
--- a/apps/web/lib/playback/playback-preferences.ts
+++ b/apps/web/lib/playback/playback-preferences.ts
@@ -67,9 +67,18 @@ export type ScorableAnimeScrapePayload = {
directFileName?: string;
};
+/** JustAnime Megaplay paths scrape fast but decode slowly — prefer direct HLS siblings. */
+export const JUSTANIME_MEGAPLAY_SCORE_PENALTY = 800;
+
const MULTI_AUDIO_AND_SUBS_BONUS = 3_000;
const MULTI_AUDIO_BONUS = 2_000;
const NATIVE_SUBTITLE_MENU_BONUS = 400;
+const ENGLISH_SUBTITLE_MATCH_BONUS = 500;
+const ENGLISH_SUBTITLE_MISS_PENALTY = 20;
+const AUDIO_PREFERENCE_MATCH_BONUS = 1_000;
+const DUB_AVAILABLE_BONUS = 3_000;
+const QUALITY_ABOVE_TARGET_BONUS_SCALE = 0.25;
+const QUALITY_BELOW_TARGET_PENALTY_SCALE = 0.5;
const uniqueAudioVersionLangs = (
audioVersions: ScrapeAudioVersion[] | undefined,
@@ -202,6 +211,23 @@ export const getPayloadMaxQualityHeight = (
return 0;
};
+export const payloadHasDubAudio = (
+ payload: ScorableAnimeScrapePayload,
+): boolean => payloadMatchesAudioPreference(payload, "dub");
+
+/** First-win only for in-player sub/dub menus — not dedicated English Megaplay. */
+export const payloadMatchesAnimeEarlyWin = (
+ payload: ScorableAnimeScrapePayload & { providerId?: string },
+): boolean => {
+ if (payload.providerId === "justanime") {
+ return false;
+ }
+
+ return (
+ payloadHasDubAudio(payload) && payloadHasInPlayerAudioSwitching(payload)
+ );
+};
+
export const payloadMatchesAudioPreference = (
payload: ScorableAnimeScrapePayload,
audio: PlaybackAudioPreference,
@@ -250,12 +276,28 @@ export const payloadMatchesQualityPreference = (
return false;
};
-export const payloadMatchesPlaybackPreferences = (
+/** Early playback win — audio (and optional subs), not resolution. */
+export const payloadMatchesCorePlaybackPreferences = (
payload: ScorableAnimeScrapePayload,
preferences: PlaybackPreferences,
-): boolean =>
- payloadMatchesAudioPreference(payload, preferences.playbackAudio) &&
- payloadMatchesQualityPreference(payload, preferences.playbackQuality);
+): boolean => {
+ if (!payloadMatchesAudioPreference(payload, preferences.playbackAudio)) {
+ return false;
+ }
+
+ if (
+ preferences.playbackEnglishSubtitles &&
+ !hasEnglishSubtitles(payload.subtitles) &&
+ !payloadHasInPlayerSubtitleSwitching(payload)
+ ) {
+ return false;
+ }
+
+ return true;
+};
+
+export const payloadMatchesPlaybackPreferences =
+ payloadMatchesCorePlaybackPreferences;
export const scoreAnimeScrapePayload = (
payload: ScorableAnimeScrapePayload,
@@ -266,24 +308,29 @@ export const scoreAnimeScrapePayload = (
const maxHeight =
heights.length > 0 ? Math.max(...heights) : ADAPTIVE_QUALITY_NEUTRAL_HEIGHT;
- if (
- matchesAudioPreference(
- payload.preferredAudioLang,
- preferences.playbackAudio,
- )
- ) {
- score += 1_000;
+ if (payloadMatchesAudioPreference(payload, preferences.playbackAudio)) {
+ score += AUDIO_PREFERENCE_MATCH_BONUS;
+ }
+
+ if (payloadHasDubAudio(payload)) {
+ score += DUB_AVAILABLE_BONUS;
}
const targetHeight = PLAYBACK_QUALITY_MIN_HEIGHT[preferences.playbackQuality];
- score += Math.max(0, maxHeight - targetHeight);
- score += heights.length * 10;
+ score +=
+ Math.max(0, maxHeight - targetHeight) * QUALITY_ABOVE_TARGET_BONUS_SCALE;
+ score += heights.length * 5;
if (maxHeight < targetHeight) {
- score -= (targetHeight - maxHeight) * 2;
+ score -= (targetHeight - maxHeight) * QUALITY_BELOW_TARGET_PENALTY_SCALE;
}
if (preferences.playbackEnglishSubtitles) {
- score += hasEnglishSubtitles(payload.subtitles) ? 80 : -40;
+ const hasSubs =
+ hasEnglishSubtitles(payload.subtitles) ||
+ payloadHasInPlayerSubtitleSwitching(payload);
+ score += hasSubs
+ ? ENGLISH_SUBTITLE_MATCH_BONUS
+ : -ENGLISH_SUBTITLE_MISS_PENALTY;
}
const multiAudio = payloadHasInPlayerAudioSwitching(payload);
@@ -300,6 +347,17 @@ export const scoreAnimeScrapePayload = (
return score;
};
+export const scoreAnimePlaybackRacePayload = (
+ payload: ScorableAnimeScrapePayload & { providerId?: string },
+ preferences: PlaybackPreferences,
+): number => {
+ let score = scoreAnimeScrapePayload(payload, preferences);
+ if (payload.providerId === "justanime") {
+ score -= JUSTANIME_MEGAPLAY_SCORE_PENALTY;
+ }
+ return score;
+};
+
export const pickScrapeQualityIndexForPreference = (
options: readonly Pick[],
quality: PlaybackQualityPreference,
diff --git a/apps/web/lib/playback/playbackStart.ts b/apps/web/lib/playback/playbackStart.ts
index 58b7de44..112d0c21 100644
--- a/apps/web/lib/playback/playbackStart.ts
+++ b/apps/web/lib/playback/playbackStart.ts
@@ -23,9 +23,21 @@ export function hlsStartPosition(resumeTime: number): number {
export const START_DRIFT_TOLERANCE_SEC = 2;
+/** Try a warmed sibling source if the winner has not decoded a frame yet. */
+export const PLAYBACK_STALL_FAILOVER_MS = 8_000;
+
/** How long to wait for first frame before failing over to another source. */
export const PLAYBACK_START_TIMEOUT_MS = 45_000;
+export const VIDSTACK_START_TIMEOUT_MS = 30_000;
+
+export function vidstackScrapeStartTimeoutMs(sourceUrl: string): number {
+ if (sourceUrl.includes("/transcode/")) {
+ return PLAYBACK_START_TIMEOUT_MS;
+ }
+ return VIDSTACK_START_TIMEOUT_MS;
+}
+
export const HLS_START_AT_ZERO_CONFIG = {
startPosition: 0,
liveSyncMode: "buffered" as const,
diff --git a/apps/web/lib/playback/progress-change-events.ts b/apps/web/lib/playback/progress-change-events.ts
new file mode 100644
index 00000000..9c4d3b51
--- /dev/null
+++ b/apps/web/lib/playback/progress-change-events.ts
@@ -0,0 +1,45 @@
+import { PLAYBACK_PROGRESS_STORAGE_KEY } from "@/lib/playback/progress-storage";
+import { VIDSRC_PROGRESS_STORAGE_KEY } from "@/lib/playback/vidsrc-progress-storage";
+
+export const PLAYBACK_PROGRESS_CHANGED_EVENT =
+ "nyumatflix:playback-progress-changed";
+
+let playbackProgressRevision = 0;
+
+export const getPlaybackProgressRevision = (): number =>
+ playbackProgressRevision;
+
+export const notifyPlaybackProgressChanged = (): void => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ playbackProgressRevision += 1;
+ window.dispatchEvent(new CustomEvent(PLAYBACK_PROGRESS_CHANGED_EVENT));
+};
+
+export const subscribePlaybackProgressChanged = (
+ listener: () => void,
+): (() => void) => {
+ if (typeof window === "undefined") {
+ return () => undefined;
+ }
+
+ const onStorage = (event: StorageEvent) => {
+ if (
+ event.key === PLAYBACK_PROGRESS_STORAGE_KEY ||
+ event.key === VIDSRC_PROGRESS_STORAGE_KEY
+ ) {
+ playbackProgressRevision += 1;
+ listener();
+ }
+ };
+
+ window.addEventListener(PLAYBACK_PROGRESS_CHANGED_EVENT, listener);
+ window.addEventListener("storage", onStorage);
+
+ return () => {
+ window.removeEventListener(PLAYBACK_PROGRESS_CHANGED_EVENT, listener);
+ window.removeEventListener("storage", onStorage);
+ };
+};
diff --git a/apps/web/lib/playback/progress-storage.ts b/apps/web/lib/playback/progress-storage.ts
index c5c8e76d..04d620c6 100644
--- a/apps/web/lib/playback/progress-storage.ts
+++ b/apps/web/lib/playback/progress-storage.ts
@@ -1,4 +1,7 @@
+import { notifyPlaybackProgressChanged } from "@/lib/playback/progress-change-events";
+
export const PLAYBACK_PROGRESS_STORAGE_KEY = "nyumatflix.playback.progress";
+export const LAST_TV_EPISODE_STORAGE_KEY = "nyumatflix.tv.last-episode";
export type PlaybackMediaType = "movie" | "tv";
@@ -24,6 +27,12 @@ export type PlaybackProgressEntry = {
export type PlaybackProgressMap = Record;
+export type TvEpisodeCoords = {
+ seasonNumber: number;
+ episodeNumber: number;
+ updatedAt: number;
+};
+
/** Within this window of the end, treat saved progress as finished (resume from start). */
export const PLAYBACK_FINISH_BUFFER_SECONDS = 120;
@@ -73,6 +82,57 @@ const writeMap = (map: PlaybackProgressMap): void => {
}
};
+const PROGRESS_STORAGE_LOCK = "nyumatflix:playback-progress";
+const LAST_TV_EPISODE_LOCK = "nyumatflix:tv-last-episode";
+
+const runWithOptionalLock = (lockName: string, task: () => void): void => {
+ if (typeof navigator !== "undefined" && navigator.locks?.request) {
+ void navigator.locks.request(lockName, task);
+ return;
+ }
+
+ task();
+};
+
+export const mergePlaybackProgressEntry = (
+ current: PlaybackProgressEntry | undefined,
+ incoming: Omit,
+ updatedAt: number,
+): PlaybackProgressEntry => {
+ if (!current) {
+ return { ...incoming, updatedAt };
+ }
+
+ if (updatedAt >= current.updatedAt) {
+ return { ...incoming, updatedAt };
+ }
+
+ if (incoming.watched > current.watched) {
+ return {
+ watched: incoming.watched,
+ duration: Math.max(current.duration, incoming.duration),
+ updatedAt: current.updatedAt,
+ };
+ }
+
+ return current;
+};
+
+const mergeLastTvEpisode = (
+ current: TvEpisodeCoords | undefined,
+ next: TvEpisodeCoords,
+): TvEpisodeCoords => {
+ if (!current) {
+ return next;
+ }
+
+ if (next.updatedAt >= current.updatedAt) {
+ return next;
+ }
+
+ return current;
+};
+
export type ParsedProgressStorageKey = {
mediaType: PlaybackMediaType;
contentId: number;
@@ -185,16 +245,157 @@ export const playbackProgressRatio = (entry: {
return Math.max(0, Math.min(1, entry.watched / entry.duration));
};
+type LastTvEpisodeMap = Record;
+
+const lastTvEpisodeKey = (contentId: number): string => String(contentId);
+
+const readLastTvEpisodeMap = (): LastTvEpisodeMap => {
+ if (typeof window === "undefined") {
+ return {};
+ }
+
+ try {
+ const raw = window.localStorage.getItem(LAST_TV_EPISODE_STORAGE_KEY);
+ if (!raw) {
+ return {};
+ }
+
+ const parsed = JSON.parse(raw) as LastTvEpisodeMap;
+ return parsed && typeof parsed === "object" ? parsed : {};
+ } catch {
+ return {};
+ }
+};
+
+const writeLastTvEpisodeMap = (map: LastTvEpisodeMap): void => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ window.localStorage.setItem(
+ LAST_TV_EPISODE_STORAGE_KEY,
+ JSON.stringify(map),
+ );
+ } catch {
+ void 0;
+ }
+};
+
+export const rememberLastTvEpisode = (
+ contentId: number,
+ seasonNumber: number,
+ episodeNumber: number,
+ updatedAt: number = Date.now(),
+): void => {
+ if (
+ !Number.isInteger(contentId) ||
+ contentId <= 0 ||
+ !Number.isInteger(seasonNumber) ||
+ seasonNumber <= 0 ||
+ !Number.isInteger(episodeNumber) ||
+ episodeNumber <= 0
+ ) {
+ return;
+ }
+
+ runWithOptionalLock(LAST_TV_EPISODE_LOCK, () => {
+ writeLastTvEpisodeCoords(contentId, seasonNumber, episodeNumber, updatedAt);
+ });
+};
+
+const writeLastTvEpisodeCoords = (
+ contentId: number,
+ seasonNumber: number,
+ episodeNumber: number,
+ updatedAt: number,
+): void => {
+ const map = readLastTvEpisodeMap();
+ const key = lastTvEpisodeKey(contentId);
+ const next: TvEpisodeCoords = {
+ seasonNumber,
+ episodeNumber,
+ updatedAt,
+ };
+ map[key] = mergeLastTvEpisode(map[key], next);
+ writeLastTvEpisodeMap(map);
+};
+
+export const getRememberedLastTvEpisode = (
+ contentId: number,
+): TvEpisodeCoords | null => {
+ const entry = readLastTvEpisodeMap()[lastTvEpisodeKey(contentId)];
+ if (
+ !entry ||
+ !Number.isInteger(entry.seasonNumber) ||
+ entry.seasonNumber <= 0 ||
+ !Number.isInteger(entry.episodeNumber) ||
+ entry.episodeNumber <= 0
+ ) {
+ return null;
+ }
+
+ return entry;
+};
+
+export const getLatestTvPlaybackCoords = (
+ contentId: number,
+): TvEpisodeCoords | null => {
+ const latest = listPlaybackProgress().find(
+ (entry) =>
+ entry.mediaType === "tv" &&
+ entry.contentId === contentId &&
+ entry.seasonNumber != null &&
+ entry.episodeNumber != null,
+ );
+
+ if (
+ latest?.seasonNumber == null ||
+ latest.episodeNumber == null ||
+ latest.seasonNumber <= 0 ||
+ latest.episodeNumber <= 0
+ ) {
+ return null;
+ }
+
+ return {
+ seasonNumber: latest.seasonNumber,
+ episodeNumber: latest.episodeNumber,
+ updatedAt: latest.updatedAt,
+ };
+};
+
export const setPlaybackProgress = (
key: PlaybackProgressKey,
entry: Omit,
): void => {
- const map = readMap();
- map[progressStorageKey(key)] = {
- ...entry,
- updatedAt: Date.now(),
+ const apply = () => {
+ const storageKey = progressStorageKey(key);
+ const updatedAt = Date.now();
+ const map = readMap();
+ map[storageKey] = mergePlaybackProgressEntry(
+ map[storageKey],
+ entry,
+ updatedAt,
+ );
+ writeMap(map);
+ notifyPlaybackProgressChanged();
+
+ if (
+ key.mediaType === "tv" &&
+ key.seasonNumber != null &&
+ key.episodeNumber != null
+ ) {
+ writeLastTvEpisodeCoords(
+ key.contentId,
+ key.seasonNumber,
+ key.episodeNumber,
+ updatedAt,
+ );
+ }
};
- writeMap(map);
+
+ runWithOptionalLock(PROGRESS_STORAGE_LOCK, apply);
};
export const resolveResumeTime = (
diff --git a/apps/web/lib/playback/recently-watched.ts b/apps/web/lib/playback/recently-watched.ts
index db30f32f..992a9239 100644
--- a/apps/web/lib/playback/recently-watched.ts
+++ b/apps/web/lib/playback/recently-watched.ts
@@ -1,4 +1,5 @@
import type { WatchlistItem } from "@/lib/domain/watchlist";
+import { buildAnilistTvDetailHref } from "@/lib/anilist-route-id";
import {
filterDismissedContinueWatching,
readContinueWatchingDismissals,
@@ -10,11 +11,17 @@ import {
playbackProgressRatio,
type ListedPlaybackProgress,
type PlaybackMediaType,
+ type TvEpisodeCoords,
} from "@/lib/playback/progress-storage";
+import {
+ readVidsrcProgressEntries,
+ VIDSRC_PROGRESS_STORAGE_KEY,
+ type VidsrcProgressEntry,
+} from "@/lib/playback/vidsrc-progress-storage";
export const RECENTLY_WATCHED_LIMIT = 12;
export const WATCH_HISTORY_LIMIT = 100;
-export const VIDSRC_PROGRESS_STORAGE_KEY = "vidsrcwtf-Progress";
+export { VIDSRC_PROGRESS_STORAGE_KEY, readVidsrcProgressEntries };
export type RecentlyWatchedStub = {
mediaType: PlaybackMediaType;
@@ -52,18 +59,6 @@ export type RecentlyWatchedItem = {
export type RecentlyWatchedScope = "all" | "movie" | "tv" | "anime";
-type VidsrcProgressEntry = {
- id: string;
- type: "movie" | "tv";
- title?: string;
- poster_path?: string;
- backdrop_path?: string;
- progress?: { watched: number; duration: number };
- last_updated?: number;
- last_season_watched?: string;
- last_episode_watched?: string;
-};
-
const titleKey = (mediaType: PlaybackMediaType, contentId: number) =>
`${mediaType}:${contentId}`;
@@ -80,6 +75,34 @@ const parseOptionalInt = (value: string | undefined): number | undefined => {
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
};
+export const getVidsrcLastTvEpisode = (
+ contentId: number,
+): TvEpisodeCoords | null => {
+ const match = readVidsrcProgressEntries().find((entry) => {
+ if (entry.type !== "tv") {
+ return false;
+ }
+ const id = Number.parseInt(entry.id, 10);
+ return id === contentId;
+ });
+
+ if (!match) {
+ return null;
+ }
+
+ const seasonNumber = parseOptionalInt(match.last_season_watched);
+ const episodeNumber = parseOptionalInt(match.last_episode_watched);
+ if (!seasonNumber || !episodeNumber) {
+ return null;
+ }
+
+ return {
+ seasonNumber,
+ episodeNumber,
+ updatedAt: match.last_updated ?? 0,
+ };
+};
+
export const buildRecentlyWatchedHref = (
mediaType: PlaybackMediaType,
contentId: number,
@@ -96,41 +119,6 @@ export const buildRecentlyWatchedHref = (
return `/tvshows/${contentId}`;
};
-export const readVidsrcProgressEntries = (): VidsrcProgressEntry[] => {
- if (typeof window === "undefined") {
- return [];
- }
-
- try {
- const raw = window.localStorage.getItem(VIDSRC_PROGRESS_STORAGE_KEY);
- if (!raw) {
- return [];
- }
-
- const parsed = JSON.parse(raw) as
- | VidsrcProgressEntry
- | Record;
-
- if (
- parsed &&
- typeof parsed === "object" &&
- "id" in parsed &&
- typeof (parsed as VidsrcProgressEntry).id === "string"
- ) {
- return [parsed as VidsrcProgressEntry];
- }
-
- return Object.values(parsed ?? {}).filter(
- (entry): entry is VidsrcProgressEntry =>
- Boolean(entry) &&
- typeof entry.id === "string" &&
- (entry.type === "movie" || entry.type === "tv"),
- );
- } catch {
- return [];
- }
-};
-
const stubsFromPlayback = (
playback: ListedPlaybackProgress[],
): RecentlyWatchedStub[] =>
@@ -487,11 +475,18 @@ export const toRecentlyWatchedItem = (
mediaType: stub.mediaType,
contentId: stub.contentId,
title: media.title || stub.title || "Untitled",
- href: buildRecentlyWatchedHref(
- stub.mediaType,
- stub.contentId,
- stub.seasonNumber,
- ),
+ href: media.isAnime
+ ? buildAnilistTvDetailHref(
+ stub.contentId,
+ stub.seasonNumber && stub.seasonNumber > 0
+ ? { season: stub.seasonNumber }
+ : undefined,
+ )
+ : buildRecentlyWatchedHref(
+ stub.mediaType,
+ stub.contentId,
+ stub.seasonNumber,
+ ),
backdropPath: media.backdropPath ?? stub.backdropPath ?? null,
posterPath: media.posterPath ?? stub.posterPath ?? null,
progressRatio: stub.progressRatio,
diff --git a/apps/web/lib/playback/subtitle-appearance-storage.ts b/apps/web/lib/playback/subtitle-appearance-storage.ts
index 459e9f39..4117c137 100644
--- a/apps/web/lib/playback/subtitle-appearance-storage.ts
+++ b/apps/web/lib/playback/subtitle-appearance-storage.ts
@@ -162,3 +162,19 @@ export const resetSubtitleAppearance = (): SubtitleAppearance => {
return DEFAULT_SUBTITLE_APPEARANCE;
};
+
+export const subscribeSubtitleAppearance = (
+ listener: () => void,
+): (() => void) => {
+ if (typeof window === "undefined") {
+ return () => undefined;
+ }
+
+ window.addEventListener(SUBTITLE_APPEARANCE_CHANGE_EVENT, listener);
+ window.addEventListener("storage", listener);
+
+ return () => {
+ window.removeEventListener(SUBTITLE_APPEARANCE_CHANGE_EVENT, listener);
+ window.removeEventListener("storage", listener);
+ };
+};
diff --git a/apps/web/lib/playback/vidsrc-progress-storage.ts b/apps/web/lib/playback/vidsrc-progress-storage.ts
new file mode 100644
index 00000000..55428c0b
--- /dev/null
+++ b/apps/web/lib/playback/vidsrc-progress-storage.ts
@@ -0,0 +1,151 @@
+import { notifyPlaybackProgressChanged } from "@/lib/playback/progress-change-events";
+
+export const VIDSRC_PROGRESS_STORAGE_KEY = "vidsrcwtf-Progress";
+
+export type VidsrcProgressEntry = {
+ id: string;
+ type: "movie" | "tv";
+ title?: string;
+ poster_path?: string;
+ backdrop_path?: string;
+ progress?: { watched: number; duration: number };
+ last_updated?: number;
+ number_of_episodes?: number;
+ number_of_seasons?: number;
+ last_season_watched?: string;
+ last_episode_watched?: string;
+};
+
+export type VidsrcProgressMap = Record;
+
+const isValidEntry = (entry: unknown): entry is VidsrcProgressEntry =>
+ Boolean(entry) &&
+ typeof entry === "object" &&
+ typeof (entry as VidsrcProgressEntry).id === "string" &&
+ ((entry as VidsrcProgressEntry).type === "movie" ||
+ (entry as VidsrcProgressEntry).type === "tv");
+
+export const isSingleVidsrcProgressEntry = (
+ data: VidsrcProgressEntry | VidsrcProgressMap,
+): data is VidsrcProgressEntry => {
+ const candidate = data as VidsrcProgressEntry;
+ return typeof candidate.id === "string" && typeof candidate.type === "string";
+};
+
+const entryStorageKey = (entry: VidsrcProgressEntry): string => entry.id;
+
+const readRawVidsrcProgressPayload = ():
+ | VidsrcProgressEntry
+ | VidsrcProgressMap
+ | null => {
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ try {
+ const raw = window.localStorage.getItem(VIDSRC_PROGRESS_STORAGE_KEY);
+ if (!raw) {
+ return null;
+ }
+
+ const parsed = JSON.parse(raw) as
+ | VidsrcProgressEntry
+ | Record;
+
+ if (!parsed || typeof parsed !== "object") {
+ return null;
+ }
+
+ if (isSingleVidsrcProgressEntry(parsed)) {
+ return parsed;
+ }
+
+ return parsed;
+ } catch {
+ return null;
+ }
+};
+
+export const readVidsrcProgressMap = (): VidsrcProgressMap => {
+ const payload = readRawVidsrcProgressPayload();
+ if (!payload) {
+ return {};
+ }
+
+ if (isSingleVidsrcProgressEntry(payload)) {
+ return { [entryStorageKey(payload)]: payload };
+ }
+
+ const map: VidsrcProgressMap = {};
+ for (const entry of Object.values(payload)) {
+ if (!isValidEntry(entry)) {
+ continue;
+ }
+ map[entryStorageKey(entry)] = entry;
+ }
+ return map;
+};
+
+export const readVidsrcProgressEntries = (): VidsrcProgressEntry[] =>
+ Object.values(readVidsrcProgressMap()).filter(isValidEntry);
+
+const shouldReplaceEntry = (
+ current: VidsrcProgressEntry | undefined,
+ next: VidsrcProgressEntry,
+): boolean => {
+ if (!current) {
+ return true;
+ }
+ return (next.last_updated ?? 0) >= (current.last_updated ?? 0);
+};
+
+const mergeSingleEntry = (
+ existing: VidsrcProgressMap,
+ entry: VidsrcProgressEntry,
+): VidsrcProgressMap => {
+ const key = entryStorageKey(entry);
+ const current = existing[key];
+ if (!shouldReplaceEntry(current, entry)) {
+ return existing;
+ }
+ return { ...existing, [key]: entry };
+};
+
+const normalizeIncomingMap = (data: VidsrcProgressMap): VidsrcProgressMap => {
+ const map: VidsrcProgressMap = {};
+ for (const entry of Object.values(data)) {
+ if (!isValidEntry(entry)) {
+ continue;
+ }
+ const key = entryStorageKey(entry);
+ const current = map[key];
+ if (shouldReplaceEntry(current, entry)) {
+ map[key] = entry;
+ }
+ }
+ return map;
+};
+
+export const persistVidsrcProgressPayload = (
+ data: VidsrcProgressEntry | VidsrcProgressMap,
+): void => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ if (isSingleVidsrcProgressEntry(data)) {
+ const merged = mergeSingleEntry(readVidsrcProgressMap(), data);
+ window.localStorage.setItem(
+ VIDSRC_PROGRESS_STORAGE_KEY,
+ JSON.stringify(merged),
+ );
+ notifyPlaybackProgressChanged();
+ return;
+ }
+
+ window.localStorage.setItem(
+ VIDSRC_PROGRESS_STORAGE_KEY,
+ JSON.stringify(normalizeIncomingMap(data)),
+ );
+ notifyPlaybackProgressChanged();
+};
diff --git a/apps/web/lib/player/load-player.ts b/apps/web/lib/player/load-player.ts
index 8d11419b..406e90ac 100644
--- a/apps/web/lib/player/load-player.ts
+++ b/apps/web/lib/player/load-player.ts
@@ -3,10 +3,18 @@ import { PLAYER_ASSET_VERSION } from "@/lib/player/player-asset.generated";
export const PLAYER_SCRIPT = `/vendor/player/element.js?v=${PLAYER_ASSET_VERSION}`;
export const MOVI_PLAYER_SCRIPT = PLAYER_SCRIPT;
+export const MOVI_PLAYER_COMPAT_SCRIPT = `/vendor/player/compat.js?v=${PLAYER_ASSET_VERSION}`;
+
+const PLAYER_VERSION_STORAGE_KEY = "movi-player-asset-version";
let loadPromise: Promise | null = null;
+let compatLoadPromise: Promise | null = null;
+let fullScriptRequested = false;
+let injectionMutex: Promise = Promise.resolve();
let loaded = false;
+let compatLoaded = false;
const loadedListeners = new Set<() => void>();
+const compatLoadedListeners = new Set<() => void>();
function isMoviPlayerRegistered(): boolean {
return (
@@ -14,14 +22,62 @@ function isMoviPlayerRegistered(): boolean {
);
}
+function resetPlayerLoadState(): void {
+ loadPromise = null;
+ compatLoadPromise = null;
+ loaded = false;
+ compatLoaded = false;
+}
+
+function removePlayerScripts(): void {
+ document
+ .querySelectorAll(
+ 'script[data-player="full"], script[data-player="compat"]',
+ )
+ .forEach((node) => node.remove());
+}
+
+function ensureFreshPlayerAssetVersion(): void {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ const previousVersion = sessionStorage.getItem(PLAYER_VERSION_STORAGE_KEY);
+ if (
+ previousVersion &&
+ previousVersion !== PLAYER_ASSET_VERSION &&
+ isMoviPlayerRegistered()
+ ) {
+ sessionStorage.setItem(PLAYER_VERSION_STORAGE_KEY, PLAYER_ASSET_VERSION);
+ window.location.reload();
+ return;
+ }
+
+ sessionStorage.setItem(PLAYER_VERSION_STORAGE_KEY, PLAYER_ASSET_VERSION);
+}
+
function markMoviPlayerLoaded(): void {
if (loaded) {
return;
}
loaded = true;
+ compatLoaded = true;
for (const listener of [...loadedListeners]) {
listener();
}
+ for (const listener of [...compatLoadedListeners]) {
+ listener();
+ }
+}
+
+function markMoviCompatLoaded(): void {
+ if (compatLoaded) {
+ return;
+ }
+ compatLoaded = true;
+ for (const listener of [...compatLoadedListeners]) {
+ listener();
+ }
}
export function isMoviPlayerLoaded(): boolean {
@@ -31,6 +87,13 @@ export function isMoviPlayerLoaded(): boolean {
return loaded;
}
+export function isMoviCompatLoaded(): boolean {
+ if (!compatLoaded && isMoviPlayerRegistered()) {
+ compatLoaded = true;
+ }
+ return compatLoaded;
+}
+
export function subscribeMoviPlayerLoaded(listener: () => void): () => void {
loadedListeners.add(listener);
return () => {
@@ -38,50 +101,131 @@ export function subscribeMoviPlayerLoaded(listener: () => void): () => void {
};
}
-function injectPlayerScript(): Promise {
- return new Promise((resolve, reject) => {
+export function subscribeMoviCompatLoaded(listener: () => void): () => void {
+ compatLoadedListeners.add(listener);
+ return () => {
+ compatLoadedListeners.delete(listener);
+ };
+}
+
+function withInjectionLock(task: () => Promise): Promise {
+ const run = injectionMutex.then(task, task);
+ injectionMutex = run.then(
+ () => undefined,
+ () => undefined,
+ );
+ return run;
+}
+
+function injectPlayerScript(src: string, datasetKey: string): Promise {
+ return withInjectionLock(async () => {
+ if (isMoviPlayerRegistered()) {
+ return;
+ }
+
+ const expectedSrc = new URL(src, window.location.origin).href;
const existing = document.querySelector(
- `script[data-player="true"]`,
+ `script[data-player="${datasetKey}"]`,
);
if (existing) {
- existing.addEventListener("load", () => resolve(), { once: true });
- existing.addEventListener(
- "error",
- () => reject(new Error("Failed to load player")),
- { once: true },
- );
+ const sameAsset =
+ existing.src === expectedSrc &&
+ existing.dataset.playerVersion === PLAYER_ASSET_VERSION;
+ if (sameAsset) {
+ if (existing.dataset.loaded === "true") {
+ return;
+ }
+ await new Promise((resolve, reject) => {
+ existing.addEventListener("load", () => resolve(), { once: true });
+ existing.addEventListener(
+ "error",
+ () => reject(new Error(`Failed to load ${src}`)),
+ { once: true },
+ );
+ });
+ return;
+ }
+ if (!isMoviPlayerRegistered()) {
+ existing.remove();
+ }
+ }
+
+ if (isMoviPlayerRegistered()) {
return;
}
- const script = document.createElement("script");
- script.type = "module";
- script.src = PLAYER_SCRIPT;
- script.dataset.player = "true";
- script.addEventListener("load", () => resolve(), { once: true });
- script.addEventListener(
- "error",
- () => reject(new Error(`Failed to load ${PLAYER_SCRIPT}`)),
- { once: true },
- );
- document.head.appendChild(script);
+ await new Promise((resolve, reject) => {
+ const script = document.createElement("script");
+ script.type = "module";
+ script.src = src;
+ script.dataset.player = datasetKey;
+ script.dataset.playerVersion = PLAYER_ASSET_VERSION;
+ script.addEventListener(
+ "load",
+ () => {
+ script.dataset.loaded = "true";
+ resolve();
+ },
+ { once: true },
+ );
+ script.addEventListener(
+ "error",
+ () => reject(new Error(`Failed to load ${src}`)),
+ { once: true },
+ );
+ document.head.appendChild(script);
+ });
});
}
+export function loadMoviCompat(): Promise {
+ if (typeof window === "undefined") {
+ return Promise.resolve();
+ }
+ ensureFreshPlayerAssetVersion();
+ if (fullScriptRequested || loadPromise) {
+ return loadMoviPlayer();
+ }
+ if (isMoviPlayerRegistered()) {
+ markMoviCompatLoaded();
+ return Promise.resolve();
+ }
+ if (!compatLoadPromise) {
+ compatLoadPromise = injectPlayerScript(MOVI_PLAYER_COMPAT_SCRIPT, "compat")
+ .then(() => {
+ markMoviCompatLoaded();
+ })
+ .catch((error) => {
+ compatLoadPromise = null;
+ throw error;
+ });
+ }
+ return compatLoadPromise;
+}
+
+export function loadMoviFull(): Promise {
+ return loadMoviPlayer();
+}
+
export function loadMoviPlayer(): Promise {
if (typeof window === "undefined") {
return Promise.resolve();
}
+ fullScriptRequested = true;
+ ensureFreshPlayerAssetVersion();
if (isMoviPlayerRegistered()) {
markMoviPlayerLoaded();
return Promise.resolve();
}
if (!loadPromise) {
- loadPromise = injectPlayerScript()
+ loadPromise = injectPlayerScript(PLAYER_SCRIPT, "full")
.then(() => {
markMoviPlayerLoaded();
})
.catch((error) => {
loadPromise = null;
+ removePlayerScripts();
+ resetPlayerLoadState();
throw error;
});
}
@@ -98,3 +242,13 @@ export function resolveMoviMediaUrl(src: string): string {
}
export const resolvePlayerMediaUrl = resolveMoviMediaUrl;
+
+export function invalidateMoviPlayerCache(): void {
+ removePlayerScripts();
+ resetPlayerLoadState();
+ fullScriptRequested = false;
+ injectionMutex = Promise.resolve();
+ if (typeof window !== "undefined") {
+ sessionStorage.removeItem(PLAYER_VERSION_STORAGE_KEY);
+ }
+}
diff --git a/apps/web/lib/player/movi-cast-fallback.ts b/apps/web/lib/player/movi-cast-fallback.ts
new file mode 100644
index 00000000..f25488e2
--- /dev/null
+++ b/apps/web/lib/player/movi-cast-fallback.ts
@@ -0,0 +1,107 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import {
+ readMoviCanCast,
+ readMoviPresentationMode,
+} from "@/lib/player/movi-host-api";
+import type { MoviPlayerElement } from "@/lib/player/player-element";
+
+type UseMoviCastFallbackOptions = {
+ fallbackUrl?: string | null;
+ enabled?: boolean;
+};
+
+export const useMoviCastFallback = ({
+ fallbackUrl,
+ enabled = true,
+}: UseMoviCastFallbackOptions) => {
+ const [castFallbackUrl, setCastFallbackUrl] = useState(null);
+ const [showCastFallback, setShowCastFallback] = useState(false);
+ const fallbackUrlRef = useRef(fallbackUrl);
+ fallbackUrlRef.current = fallbackUrl;
+
+ useEffect(() => {
+ setCastFallbackUrl(null);
+ setShowCastFallback(false);
+ }, [fallbackUrl]);
+
+ const handleCastFallback = useCallback(() => {
+ if (!fallbackUrlRef.current) {
+ return;
+ }
+ setCastFallbackUrl(fallbackUrlRef.current);
+ setShowCastFallback(false);
+ }, []);
+
+ const handleCanvasCastBlocked = useCallback(
+ (player: MoviPlayerElement) => {
+ if (!enabled || !fallbackUrlRef.current || castFallbackUrl) {
+ return;
+ }
+ const mode = readMoviPresentationMode(player);
+ const canCast = readMoviCanCast(player);
+ if (mode === "canvas" && !canCast) {
+ setShowCastFallback(true);
+ }
+ },
+ [castFallbackUrl, enabled],
+ );
+
+ const bindCastFallbackPlayer = useCallback(
+ (container: HTMLElement | null) => {
+ if (!container || !enabled) {
+ return undefined;
+ }
+
+ const inspectPlayer = () => {
+ const player = container.querySelector("movi-player");
+ if (player instanceof HTMLElement) {
+ handleCanvasCastBlocked(player as MoviPlayerElement);
+ }
+ };
+
+ const onCastFallback = (event: Event) => {
+ const detail = (event as CustomEvent<{ src?: string }>).detail;
+ if (detail?.src && fallbackUrlRef.current) {
+ setShowCastFallback(true);
+ }
+ };
+
+ const observer = new MutationObserver(inspectPlayer);
+ observer.observe(container, { childList: true, subtree: true });
+ inspectPlayer();
+
+ container.addEventListener(
+ "castfallback",
+ onCastFallback as EventListener,
+ );
+
+ const player = container.querySelector("movi-player");
+ const onCanCastChange = () => {
+ if (player instanceof HTMLElement) {
+ handleCanvasCastBlocked(player as MoviPlayerElement);
+ }
+ };
+ player?.addEventListener("cancastchange", onCanCastChange);
+
+ return () => {
+ observer.disconnect();
+ container.removeEventListener(
+ "castfallback",
+ onCastFallback as EventListener,
+ );
+ player?.removeEventListener("cancastchange", onCanCastChange);
+ };
+ },
+ [enabled, handleCanvasCastBlocked],
+ );
+
+ return {
+ castFallbackUrl,
+ showCastFallback,
+ handleCastFallback,
+ handleCanvasCastBlocked,
+ bindCastFallbackPlayer,
+ dismissCastFallback: () => setShowCastFallback(false),
+ };
+};
diff --git a/apps/web/lib/player/movi-host-api.ts b/apps/web/lib/player/movi-host-api.ts
new file mode 100644
index 00000000..84b4723b
--- /dev/null
+++ b/apps/web/lib/player/movi-host-api.ts
@@ -0,0 +1,82 @@
+import type { MoviPlayerElement } from "@/lib/player/player-element";
+import type {
+ ChapterMarker,
+ ExternalQualityEntry,
+ PresentationMode,
+} from "@/lib/player/player-element";
+
+export type { ChapterMarker, ExternalQualityEntry, PresentationMode };
+
+export type MoviCanCastChangeDetail = {
+ canCast: boolean;
+};
+
+export const isMoviHostElement = (
+ value: unknown,
+): value is MoviPlayerElement & MoviHostApi =>
+ value instanceof HTMLElement && value.tagName.toLowerCase() === "movi-player";
+
+export type MoviHostApi = {
+ setExternalQualities?: (qualities: ExternalQualityEntry[]) => void;
+ getExternalQualities?: () => ExternalQualityEntry[];
+ setChapterMarkers?: (chapters: ChapterMarker[]) => void;
+ getChapterMarkers?: () => ChapterMarker[];
+ getPresentationMode?: () => PresentationMode;
+ canCast?: () => boolean;
+ headers?: Record | null;
+ source?: string | File | MoviSplitSource | MoviSourceEntry[] | null;
+};
+
+export type MoviSourceEntry = {
+ src: string;
+ type?: string;
+};
+
+export type MoviSplitSource = {
+ video: MoviSourceEntry;
+ audio?: MoviSourceEntry | MoviSourceEntry[];
+ subtitles?: Array<{
+ id?: string;
+ src: string;
+ lang: string;
+ label: string;
+ format?: string;
+ }>;
+};
+
+export type MoviExternalSubtitleEntry = {
+ id?: string;
+ src: string;
+ lang: string;
+ label: string;
+ format?: string;
+ default?: boolean;
+};
+
+export const applyMoviExternalSubtitles = (
+ player: MoviPlayerElement,
+ subtitles: MoviExternalSubtitleEntry[],
+): void => {
+ player.setExternalSubtitles?.(subtitles);
+};
+
+export const applyMoviExternalQualities = (
+ player: MoviPlayerElement,
+ qualities: ExternalQualityEntry[],
+): void => {
+ player.setExternalQualities?.(qualities);
+};
+
+export const applyMoviChapterMarkers = (
+ player: MoviPlayerElement,
+ chapters: ChapterMarker[],
+): void => {
+ player.setChapterMarkers?.(chapters);
+};
+
+export const readMoviPresentationMode = (
+ player: MoviPlayerElement,
+): PresentationMode | null => player.getPresentationMode?.() ?? null;
+
+export const readMoviCanCast = (player: MoviPlayerElement): boolean =>
+ player.canCast?.() ?? false;
diff --git a/apps/web/lib/player/movi-scrape-headers.ts b/apps/web/lib/player/movi-scrape-headers.ts
new file mode 100644
index 00000000..b0751b9e
--- /dev/null
+++ b/apps/web/lib/player/movi-scrape-headers.ts
@@ -0,0 +1,47 @@
+import {
+ NYUMAT_CLIENT_HEADER,
+ NYUMAT_CLIENT_VALUE,
+} from "@/lib/api/request-guard";
+import { readAccessTokenFromUrl } from "@/lib/direct/transcode-hls-auth";
+
+const INTERNAL_PLAYBACK_PREFIXES = [
+ "/api/scrape/play/",
+ "/api/live/play/",
+ "/api/direct/media",
+ "/api/direct/transcode/",
+] as const;
+
+const isInternalPlaybackPath = (pathname: string): boolean =>
+ INTERNAL_PLAYBACK_PREFIXES.some((prefix) => pathname.startsWith(prefix));
+
+const isInternalPlaybackUrl = (url: string): boolean => {
+ if (INTERNAL_PLAYBACK_PREFIXES.some((prefix) => url.startsWith(prefix))) {
+ return true;
+ }
+
+ try {
+ return isInternalPlaybackPath(
+ new URL(url, "http://local.invalid").pathname,
+ );
+ } catch {
+ return false;
+ }
+};
+
+/** Headers for movi-player native HLS / progressive requests (parity with scrape HLS fetchSetup). */
+export const buildMoviScrapePlaybackHeaders = (
+ playbackUrl: string,
+): Record => {
+ const headers: Record = {};
+
+ if (isInternalPlaybackUrl(playbackUrl)) {
+ headers[NYUMAT_CLIENT_HEADER] = NYUMAT_CLIENT_VALUE;
+ }
+
+ const token = readAccessTokenFromUrl(playbackUrl);
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+
+ return headers;
+};
diff --git a/apps/web/lib/player/player-asset.generated.ts b/apps/web/lib/player/player-asset.generated.ts
index a88606ba..e1480630 100644
--- a/apps/web/lib/player/player-asset.generated.ts
+++ b/apps/web/lib/player/player-asset.generated.ts
@@ -1,3 +1,3 @@
// Generated by @nyumatflix/player copy:vendor — do not edit.
-export const PLAYER_ASSET_VERSION = "f210c4f3";
+export const PLAYER_ASSET_VERSION = "0fc814cd";
export const MOVI_PLAYER_ASSET_VERSION = PLAYER_ASSET_VERSION;
diff --git a/apps/web/lib/player/player-element.ts b/apps/web/lib/player/player-element.ts
index e13b1303..70aaa08b 100644
--- a/apps/web/lib/player/player-element.ts
+++ b/apps/web/lib/player/player-element.ts
@@ -10,21 +10,109 @@ export interface MoviPlayerElement extends MoviHostElement {
volume?: number;
playsinline?: boolean;
theme?: string;
+ presentation?: PresentationMode;
currentTime: number;
duration: number;
ended: boolean;
+ paused: boolean;
playing?: boolean;
play?: () => Promise;
+ headers?: Record | null;
+ source?:
+ | ((
+ value?:
+ | string
+ | { src: string; type?: string }
+ | Array<{ src: string; type?: string }>
+ | {
+ video: { src: string; type?: string };
+ audio?:
+ | { src: string; type?: string }
+ | Array<{
+ src: string;
+ type?: string;
+ lang: string;
+ label: string;
+ }>;
+ subtitles?: Array<{
+ src: string;
+ lang: string;
+ label: string;
+ format?: string;
+ }>;
+ },
+ ) => {
+ src: string | File | null;
+ type: string;
+ audioSrc?: string | null;
+ } | void)
+ | string
+ | File
+ | {
+ video: { src: string; type?: string };
+ audio?:
+ | { src: string; type?: string }
+ | Array<{ src: string; type?: string }>;
+ subtitles?: Array<{
+ src: string;
+ lang: string;
+ label: string;
+ format?: string;
+ }>;
+ }
+ | Array<{ src: string; type?: string }>
+ | null;
getAudioLangs?: () => Array<{ lang: string; label: string; active: boolean }>;
selectAudioLang?: (lang: string) => boolean;
getSubtitleLangs?: () => Array<{
+ id: string;
lang: string;
label: string;
active: boolean;
}>;
+ getSubtitleTracks?: () => Array<{
+ id: number;
+ language?: string;
+ label?: string;
+ }>;
+ selectExternalSubtitle?: (id: string | null) => Promise;
selectSubtitleLang?: (lang: string | null) => Promise;
+ selectSubtitleTrack?: (trackId: number | null) => Promise;
+ getActiveSubtitlePreference?: () => string;
+ setExternalQualities?: (qualities: ExternalQualityEntry[]) => void;
+ getExternalQualities?: () => ExternalQualityEntry[];
+ setChapterMarkers?: (chapters: ChapterMarker[]) => void;
+ getChapterMarkers?: () => ChapterMarker[];
+ getPresentationMode?: () => PresentationMode;
+ canCast?: () => boolean;
+ dispose?: () => void;
+ setExternalSubtitles?: (
+ subtitles: Array<{
+ id?: string;
+ src: string;
+ lang: string;
+ label: string;
+ format?: string;
+ default?: boolean;
+ }>,
+ ) => void;
}
+export type PresentationMode = "native" | "canvas";
+
+export type ExternalQualityEntry = {
+ label: string;
+ url: string;
+ height?: number;
+ type?: string;
+};
+
+export type ChapterMarker = {
+ title: string;
+ start: number;
+ end?: number;
+};
+
export function resolveMoviPosterUrl(
poster: string | null | undefined,
): string | undefined {
@@ -81,6 +169,7 @@ export function applyMoviSource(
el.volume = 1;
el.playsinline = true;
el.theme = "dark";
+ el.setAttribute("fastseek", "");
el.setAttribute("buffersize", "256");
const play = el.play;
if (typeof play === "function") {
@@ -93,6 +182,12 @@ export function disposeMoviPlayer(el: MoviPlayerElement | null): void {
return;
}
+ try {
+ el.dispose?.();
+ } catch {
+ // movi-player may already be torn down.
+ }
+
try {
el.removeAttribute("src");
} catch {
diff --git a/apps/web/lib/player/player-playback-ready.ts b/apps/web/lib/player/player-playback-ready.ts
index aa2c51fb..9093c8f2 100644
--- a/apps/web/lib/player/player-playback-ready.ts
+++ b/apps/web/lib/player/player-playback-ready.ts
@@ -6,7 +6,12 @@ export interface MoviHostElement extends HTMLElement {
export function getMoviVideoElement(
player: MoviHostElement,
): HTMLVideoElement | null {
- return player.shadowRoot?.querySelector("video") ?? null;
+ const fromShadow = player.shadowRoot?.querySelector("video");
+ if (fromShadow instanceof HTMLVideoElement) {
+ return fromShadow;
+ }
+ const fromLight = player.querySelector("video");
+ return fromLight instanceof HTMLVideoElement ? fromLight : null;
}
export function isMoviVideoPlaybackReady(video: HTMLVideoElement): boolean {
diff --git a/apps/web/lib/providers/registry.ts b/apps/web/lib/providers/registry.ts
index 15727565..38336400 100644
--- a/apps/web/lib/providers/registry.ts
+++ b/apps/web/lib/providers/registry.ts
@@ -19,7 +19,6 @@ export type AnimeScrapeProviderId =
| "kickassanime"
| "animeonsen"
| "allmanga"
- | "animestream"
| "animegg"
| "justanime"
| "kyren"
@@ -102,11 +101,11 @@ export const EMBED_PROVIDER_REGISTRY: ProviderDefinition[] = [
];
/**
- * Order from latency bench + reliability audits.
- * VidNest scrape removed (resolvers down / poisoned streams). VixSrc is embed-only
- * in the iframe chain, not listed here.
+ * Direct leads so the overlay solos it first. Remaining order is from latency
+ * bench + reliability audits. VidNest scrape removed. VixSrc is embed-only.
*/
export const TMDB_SCRAPE_PROVIDER_REGISTRY: ProviderDefinition[] = [
+ provider("direct", "Direct", { embed: false, tmdbScrape: true }),
provider("vidsrc", "VidSrc", { embed: true, tmdbScrape: true }),
provider("vidking", "VidKing", { embed: true, tmdbScrape: true }),
provider("videasy", "VidEasy", {
@@ -116,7 +115,6 @@ export const TMDB_SCRAPE_PROVIDER_REGISTRY: ProviderDefinition[] = [
}),
provider("vidrock", "VidRock", { embed: false, tmdbScrape: true }),
provider("bingr", "Bingr", { embed: false, tmdbScrape: true }),
- provider("direct", "Direct", { embed: false, tmdbScrape: true }),
provider("2embed", "2Embed", { embed: true, tmdbScrape: true }),
];
@@ -128,14 +126,14 @@ export const TMDB_SCRAPE_PROVIDER_REGISTRY: ProviderDefinition[] = [
* races Direct with the first two anime scrapers on high-confidence TMDB mappings.
*
* Embed-only (not scraped): hentaini (iframe), animepahe (VidNest animepahe route).
- * Removed from scrape: anikuro (redundant allmanga wrapper).
+ * Removed from scrape: anikuro (redundant allmanga wrapper), animestream
+ * (Indonesian hardsub-only, no English/softsub/dub variants).
*/
export const ANIME_SCRAPE_PROVIDER_REGISTRY: ProviderDefinition[] = [
provider("kickassanime", "KickAssAnime", { embed: false, animeScrape: true }),
provider("anizone", "AniZone", { embed: false, animeScrape: true }),
provider("allmanga", "AllManga", { embed: false, animeScrape: true }),
provider("animeonsen", "AnimeOnsen", { embed: false, animeScrape: true }),
- provider("animestream", "AnimeStream", { embed: false, animeScrape: true }),
provider("animegg", "AnimeGG", { embed: false, animeScrape: true }),
provider("kyren", "Kyren", { embed: false, animeScrape: true }),
provider("justanime", "JustAnime", { embed: false, animeScrape: true }),
diff --git a/apps/web/lib/query-client.tsx b/apps/web/lib/query-client.tsx
index c9515ba2..cd70fdd9 100644
--- a/apps/web/lib/query-client.tsx
+++ b/apps/web/lib/query-client.tsx
@@ -5,6 +5,7 @@ import {
QueryClientProvider,
isServer,
} from "@tanstack/react-query";
+import { cache } from "react";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { type ReactNode, useEffect, useState } from "react";
@@ -33,9 +34,11 @@ function makeQueryClient() {
let browserQueryClient: QueryClient | undefined = undefined;
let persister: ReturnType | undefined = undefined;
+const getServerQueryClient = cache(() => makeQueryClient());
+
function getQueryClient() {
if (isServer) {
- return makeQueryClient();
+ return getServerQueryClient();
}
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
diff --git a/apps/web/lib/query-keys.ts b/apps/web/lib/query-keys.ts
index 86013b3d..d1240200 100644
--- a/apps/web/lib/query-keys.ts
+++ b/apps/web/lib/query-keys.ts
@@ -16,6 +16,8 @@ export const queryKeys = {
media: () => [...queryKeys.all, "media"] as const,
tvSeason: (tvId: number, seasonNumber: number) =>
[...queryKeys.media(), "tv", tvId, "season", seasonNumber] as const,
+ tvSeasonRoute: (tvRouteId: string, seasonNumber: number) =>
+ [...queryKeys.media(), "tv", tvRouteId, "season", seasonNumber] as const,
movieDetails: (movieId: number) =>
[...queryKeys.media(), "movie", movieId] as const,
tvDetails: (tvId: number) => [...queryKeys.media(), "tv", tvId] as const,
@@ -23,6 +25,18 @@ export const queryKeys = {
[...queryKeys.media(), "tv", tvRouteId, "details"] as const,
mediaAboveFold: (mediaType: "movie" | "tv", id: string) =>
[...queryKeys.media(), mediaType, id, "above-fold"] as const,
+ animeSeasonMap: (
+ tmdbShowId: number,
+ seasonNumber: number,
+ sourceAnilistId: number,
+ ) =>
+ [
+ ...queryKeys.media(),
+ "anime-season-map",
+ tmdbShowId,
+ seasonNumber,
+ sourceAnilistId,
+ ] as const,
tvAllSeasons: (tvId: string) =>
[...queryKeys.media(), "tv", tvId, "all-seasons"] as const,
diff --git a/apps/web/lib/scrape/abort.ts b/apps/web/lib/scrape/abort.ts
new file mode 100644
index 00000000..1c5bbf27
--- /dev/null
+++ b/apps/web/lib/scrape/abort.ts
@@ -0,0 +1,8 @@
+export const isScrapeAborted = (signal?: AbortSignal): boolean =>
+ signal?.aborted ?? false;
+
+export const throwIfScrapeAborted = (signal?: AbortSignal): void => {
+ if (isScrapeAborted(signal)) {
+ throw new DOMException("Scrape aborted", "AbortError");
+ }
+};
diff --git a/apps/web/lib/scrape/anime/anilist-meta.ts b/apps/web/lib/scrape/anime/anilist-meta.ts
index 021a7014..06c5666b 100644
--- a/apps/web/lib/scrape/anime/anilist-meta.ts
+++ b/apps/web/lib/scrape/anime/anilist-meta.ts
@@ -250,7 +250,7 @@ export const fetchAnilistIdByMal = async (
};
export type AnimeSearchContext = {
- /** Keywords to submit to a provider's search endpoint (generic query first). */
+ /** Keywords to submit to a provider's search endpoint (season-specific titles first). */
searchQueries: string[];
/**
* Titles a search hit must match — season-specific AniList titles only.
@@ -270,7 +270,7 @@ export const buildAnimeSearchContext = (
const trimmed = query?.trim();
const searchQueries = [
...new Set(
- [trimmed, ...anilistTitles].filter((title): title is string =>
+ [...anilistTitles, trimmed].filter((title): title is string =>
Boolean(title),
),
),
diff --git a/apps/web/lib/scrape/anime/index.ts b/apps/web/lib/scrape/anime/index.ts
index 89b81d8f..702137a5 100644
--- a/apps/web/lib/scrape/anime/index.ts
+++ b/apps/web/lib/scrape/anime/index.ts
@@ -1,4 +1,3 @@
-import { scrapeAnimestream } from "./providers/animestream";
import { scrapeAnimegg } from "./providers/animegg";
import { scrapeAnimeonsen } from "./providers/animeonsen";
import { scrapeAllmanga } from "./providers/allmanga";
@@ -22,6 +21,7 @@ import type { ScrapeSubtitle } from "../types";
import { attachSubtitlesToQualities, dedupeSubtitles } from "../linked-config";
import { stampDonorSubtitles } from "../subtitle-harvest";
import { attachHlsTrackCapabilities } from "../hls-track-capabilities";
+import { isScrapeAborted } from "../abort";
import { probeScrapePlaybackPath } from "../playback-probe";
import { fetchScrapeFallbackSubtitles } from "../subtitles";
import { resolveAnimeSubtitleTmdbLookup } from "./resolve-subtitle-tmdb-lookup";
@@ -37,7 +37,6 @@ const ANIME_SCRAPERS: Record<
kickassanime: scrapeKickassanime,
animeonsen: scrapeAnimeonsen,
allmanga: scrapeAllmanga,
- animestream: scrapeAnimestream,
animegg: scrapeAnimegg,
justanime: scrapeJustanime,
kyren: scrapeKyren,
@@ -87,6 +86,14 @@ export async function scrapeAnimeProvider(
providerId: AnimeScrapeProviderId,
input: AnimeScrapeInput,
): Promise {
+ if (isScrapeAborted(input.signal)) {
+ return {
+ ok: false,
+ providerId,
+ error: "Request aborted",
+ };
+ }
+
const scraper = ANIME_SCRAPERS[providerId];
const result = await scraper(input);
@@ -94,6 +101,14 @@ export async function scrapeAnimeProvider(
return result;
}
+ if (isScrapeAborted(input.signal)) {
+ return {
+ ok: false,
+ providerId,
+ error: "Request aborted",
+ };
+ }
+
let next = result;
if (!result.validated) {
@@ -105,6 +120,7 @@ export async function scrapeAnimeProvider(
cookies: next.cookies,
},
next.streamKind,
+ { signal: input.signal },
);
if (!playProbeOk) {
return {
@@ -125,6 +141,14 @@ export async function scrapeAnimeProvider(
};
}
+ if (isScrapeAborted(input.signal)) {
+ return {
+ ok: false,
+ providerId,
+ error: "Request aborted",
+ };
+ }
+
const mergedSubtitles = await mergeAnimeCatalogSubtitles(
input,
next.subtitles,
@@ -149,6 +173,14 @@ export async function scrapeAllAnimeProviders(
providerIds: readonly AnimeScrapeProviderId[] = ANIME_SCRAPE_PROVIDER_ORDER,
): Promise {
for (const providerId of providerIds) {
+ if (isScrapeAborted(input.signal)) {
+ return {
+ ok: false,
+ providerId,
+ error: "Request aborted",
+ };
+ }
+
const result = await scrapeAnimeProvider(providerId, input);
if (result.ok) {
return result;
diff --git a/apps/web/lib/scrape/anime/providers/allmanga.ts b/apps/web/lib/scrape/anime/providers/allmanga.ts
index 599b25d0..e364be0b 100644
--- a/apps/web/lib/scrape/anime/providers/allmanga.ts
+++ b/apps/web/lib/scrape/anime/providers/allmanga.ts
@@ -31,7 +31,7 @@ export type AllanimeShowEdge = {
name?: string;
englishName?: string | null;
nativeName?: string | null;
- aniListId?: string | null;
+ aniListId?: string | number | null;
};
type AllanimeSearchResponse = {
@@ -54,7 +54,8 @@ export const selectAllmangaShow = (
if (anilistId != null) {
const anilistKey = String(anilistId);
const byAniListId = shows.find(
- (show) => Boolean(show._id) && show.aniListId === anilistKey,
+ (show) =>
+ Boolean(show._id) && String(show.aniListId ?? "") === anilistKey,
);
if (byAniListId) {
return byAniListId;
diff --git a/apps/web/lib/scrape/anime/providers/animestream.ts b/apps/web/lib/scrape/anime/providers/animestream.ts
deleted file mode 100644
index 8944e659..00000000
--- a/apps/web/lib/scrape/anime/providers/animestream.ts
+++ /dev/null
@@ -1,356 +0,0 @@
-import {
- fetchAnilistMediaMeta,
- resolveAnimeSearchContext,
-} from "../anilist-meta";
-import {
- animeFranchisePrefixMatch,
- animeSearchLabelMatches,
- isExactAnimeTitleMatch,
-} from "../title-match";
-import type { AnimeScrapeInput, AnimeScrapeResult } from "../types";
-import { cancelResponseBody, scrapeFetch, scrapeFetchText } from "../../fetch";
-import {
- SCRAPE_PLAY_PROBE_RETRY_ATTEMPTS,
- SCRAPE_PLAY_PROBE_TIMEOUT_MS,
- probeScrapePlaybackPath,
-} from "../../playback-probe";
-import { raceFirstOk } from "../../race-first";
-
-/** Site captured in animestream.my.id HAR (replaces dead UniqueStream API). */
-const ANIMESTREAM_ORIGIN = "https://animestream.my.id";
-const ANIMESTREAM_REFERER = `${ANIMESTREAM_ORIGIN}/`;
-const animestreamFetchOptions = {
- timeoutMs: SCRAPE_PLAY_PROBE_TIMEOUT_MS,
- retryAttempts: SCRAPE_PLAY_PROBE_RETRY_ATTEMPTS,
-};
-
-type AnimestreamSearchHit = {
- title?: string;
- slug?: string;
- type?: string;
- year?: string;
-};
-
-type AnimestreamSearchResponse = {
- status?: string;
- data?: AnimestreamSearchHit[];
-};
-
-const parseSetCookieHeader = (header: string): string =>
- header
- .split(/,(?=\s*[A-Za-z0-9_-]+=)/)
- .map((part) => part.split(";")[0]?.trim())
- .filter(Boolean)
- .join("; ");
-
-const decodeDataUrlValue = (raw: string): string | null => {
- const trimmed = raw.trim();
- if (!trimmed) return null;
-
- if (
- trimmed.startsWith("/") ||
- trimmed.startsWith("http://") ||
- trimmed.startsWith("https://")
- ) {
- return trimmed;
- }
-
- try {
- const decoded = Buffer.from(trimmed, "base64").toString("utf8").trim();
- return decoded || null;
- } catch {
- return null;
- }
-};
-
-const warmAnimestreamSession = async (): Promise => {
- const home = await scrapeFetch(ANIMESTREAM_ORIGIN, {
- headers: { Referer: ANIMESTREAM_REFERER },
- ...animestreamFetchOptions,
- });
- if (!home.ok) {
- await cancelResponseBody(home);
- return null;
- }
-
- await cancelResponseBody(home);
- const cookie = parseSetCookieHeader(home.headers.get("set-cookie") ?? "");
- return cookie || null;
-};
-
-const sessionHeaders = (cookie: string | null): Record => ({
- Referer: ANIMESTREAM_REFERER,
- ...(cookie ? { Cookie: cookie } : {}),
-});
-
-const searchAnimestream = async (
- query: string,
- cookie: string | null,
-): Promise => {
- const url = `${ANIMESTREAM_ORIGIN}/ajax.php?action=ajax-proxy&endpoint=/ajax-search&q=${encodeURIComponent(query)}`;
- const response = await scrapeFetchText(
- url,
- {
- ...sessionHeaders(cookie),
- "X-Requested-With": "XMLHttpRequest",
- Accept: "application/json, text/plain, */*",
- },
- animestreamFetchOptions,
- );
- if (response.status !== 200) {
- return [];
- }
-
- try {
- const payload = JSON.parse(response.text) as AnimestreamSearchResponse;
- if (payload.status !== "success" || !Array.isArray(payload.data)) {
- return [];
- }
- return payload.data;
- } catch {
- return [];
- }
-};
-
-const slugifyTitle = (title: string): string =>
- title
- .normalize("NFKC")
- .toLocaleLowerCase("en-US")
- .replace(/['']/g, "")
- .replace(/[^\p{L}\p{N}]+/gu, "-")
- .replace(/^-+|-+$/g, "");
-
-const mediaTypeRank = (
- type: string | undefined,
- preferMovie: boolean,
-): number => {
- const normalized = (type ?? "").toUpperCase();
- if (preferMovie) {
- return normalized === "MOVIE" ? 3 : normalized === "TV" ? 2 : 1;
- }
- return normalized === "TV" ? 3 : normalized === "MOVIE" ? 1 : 2;
-};
-
-const rankSearchHit = (
- hit: AnimestreamSearchHit,
- expectedTitles: readonly string[],
- preferMovie: boolean,
-): number => {
- const title = hit.title ?? "";
- let score = -1;
-
- if (isExactAnimeTitleMatch(title, expectedTitles)) {
- score = 90;
- } else if (animeSearchLabelMatches(title, expectedTitles)) {
- score = 70;
- } else if (
- expectedTitles.some((expected) =>
- animeFranchisePrefixMatch(title, expected),
- )
- ) {
- score = 55;
- }
-
- if (score < 0) {
- return -1;
- }
-
- return score + mediaTypeRank(hit.type, preferMovie);
-};
-
-const selectSearchHit = (
- hits: AnimestreamSearchHit[],
- expectedTitles: readonly string[],
- preferMovie: boolean,
-): AnimestreamSearchHit | undefined => {
- const withSlug = hits.filter((hit) => Boolean(hit.slug && hit.title));
- return [...withSlug]
- .map((hit) => ({
- hit,
- score: rankSearchHit(hit, expectedTitles, preferMovie),
- }))
- .filter((entry) => entry.score >= 0)
- .sort((a, b) => b.score - a.score)[0]?.hit;
-};
-
-const fetchEpisodePage = async (
- episodePath: string,
- cookie: string | null,
-): Promise<{ status: number; text: string }> =>
- scrapeFetchText(
- `${ANIMESTREAM_ORIGIN}${episodePath}`,
- sessionHeaders(cookie),
- animestreamFetchOptions,
- );
-
-const resolveEpisodePage = async (
- seriesSlug: string,
- episodeNumber: number,
- cookie: string | null,
-): Promise<{ text: string } | null> => {
- const paths = [`${seriesSlug}/episode-${episodeNumber}`];
- if (episodeNumber === 1) {
- paths.push(`${seriesSlug}/episode-1`);
- }
-
- for (const episodePath of paths) {
- const episodePage = await fetchEpisodePage(episodePath, cookie);
- if (episodePage.text.length < 800) {
- continue;
- }
-
- if (
- /page not found|404/i.test(
- episodePage.text.match(/([^<]+)/i)?.[1] ?? "",
- )
- ) {
- continue;
- }
-
- const playerId = extractPlayerId(episodePage.text);
- if (playerId) {
- return { text: episodePage.text };
- }
- }
-
- return null;
-};
-
-const normalizeSeriesSlug = (slug: string): string => {
- const trimmed = slug.trim();
- if (!trimmed) return "";
- try {
- const path = trimmed.startsWith("http")
- ? new URL(trimmed).pathname
- : trimmed;
- return path.replace(/^\/anime\//, "/").replace(/\/+$/, "") || "";
- } catch {
- return trimmed.replace(/^\/anime\//, "/").replace(/\/+$/, "");
- }
-};
-
-const extractPlayerId = (episodeHtml: string): string | null => {
- for (const match of episodeHtml.matchAll(/data-url=["']([^"']+)["']/gi)) {
- const decoded = decodeDataUrlValue(match[1] ?? "");
- if (!decoded) continue;
- const fromPlayer = decoded.match(
- /player\.tikungan\.store\/([A-Za-z0-9_-]+)/i,
- )?.[1];
- if (fromPlayer) return fromPlayer;
- }
-
- return (
- episodeHtml.match(/player\.tikungan\.store\/([A-Za-z0-9_-]+)/i)?.[1] ?? null
- );
-};
-
-const buildMasterPlaylistUrl = (playerId: string): string =>
- `https://player.tikungan.store/img.banter03.click/${playerId}/master.m3u8`;
-
-export async function scrapeAnimestream(
- input: AnimeScrapeInput,
-): Promise {
- const providerId = "animestream" as const;
-
- try {
- const { searchQueries, matchTitles } =
- await resolveAnimeSearchContext(input);
- const mediaMeta = await fetchAnilistMediaMeta(input.anilistId);
- const preferMovie =
- mediaMeta?.format === "MOVIE" || mediaMeta?.episodes === 1;
-
- const cookie = await warmAnimestreamSession();
- const searches = await Promise.all(
- searchQueries.map((title) => searchAnimestream(title, cookie)),
- );
- let hit: AnimestreamSearchHit | undefined;
- for (const results of searches) {
- hit = selectSearchHit(results, matchTitles, preferMovie);
- if (hit?.slug) {
- break;
- }
- }
-
- let seriesSlug = hit?.slug ? normalizeSeriesSlug(hit.slug) : "";
-
- if (!seriesSlug) {
- const slugHit = await raceFirstOk(matchTitles, async (title) => {
- const slug = slugifyTitle(title);
- if (!slug) {
- return null;
- }
- const probe = await resolveEpisodePage(
- `/${slug}`,
- input.episodeNumber,
- cookie,
- );
- return probe ? `/${slug}` : null;
- });
- seriesSlug = slugHit?.value ?? "";
- }
-
- if (!seriesSlug) {
- return {
- ok: false,
- providerId,
- error: "AnimeStream series search miss",
- };
- }
-
- const episodePage = await resolveEpisodePage(
- seriesSlug,
- input.episodeNumber,
- cookie,
- );
- if (!episodePage) {
- return {
- ok: false,
- providerId,
- error: `AnimeStream episode ${input.episodeNumber} not found`,
- };
- }
-
- const playerId = extractPlayerId(episodePage.text);
- if (!playerId) {
- return {
- ok: false,
- providerId,
- error: "AnimeStream player embed missing",
- };
- }
-
- const streamUrl = buildMasterPlaylistUrl(playerId);
- const playable = await probeScrapePlaybackPath(
- {
- url: streamUrl,
- referer: ANIMESTREAM_REFERER,
- },
- "hls",
- );
- if (!playable) {
- return {
- ok: false,
- providerId,
- error: "AnimeStream stream failed playback-path probe",
- };
- }
-
- return {
- ok: true,
- providerId,
- validated: true,
- streamUrl,
- streamKind: "hls",
- referer: ANIMESTREAM_REFERER,
- };
- } catch (error) {
- return {
- ok: false,
- providerId,
- error:
- error instanceof Error ? error.message : "AnimeStream scrape failed",
- };
- }
-}
-
-export { resolveScrapeAudioVariantUrl as resolveUniqueStreamVariantUrl } from "../../audio-versions";
diff --git a/apps/web/lib/scrape/anime/types.ts b/apps/web/lib/scrape/anime/types.ts
index dc0dd83c..144bdb60 100644
--- a/apps/web/lib/scrape/anime/types.ts
+++ b/apps/web/lib/scrape/anime/types.ts
@@ -21,6 +21,7 @@ export type AnimeScrapeInput = {
translationType?: AnimeTranslationType;
/** Override search query (defaults to AniList romaji/english title). */
query?: string;
+ signal?: AbortSignal;
/**
* Optional TMDB coords for softsub fallback. When omitted, the scrape layer
* resolves AniList → TMDB via movie routes, Fribb, or AniBridge before
diff --git a/apps/web/lib/scrape/api-handlers.ts b/apps/web/lib/scrape/api-handlers.ts
index ab95fa88..6b6920db 100644
--- a/apps/web/lib/scrape/api-handlers.ts
+++ b/apps/web/lib/scrape/api-handlers.ts
@@ -24,6 +24,7 @@ import {
isVidsrcPlaybackRefresh,
isVixsrcPlaybackRefresh,
} from "@/lib/scrape/playback-refresh";
+import { resolveMegaplayStreamAtScrapeTime } from "@/lib/scrape/megaplay-playback";
import { primeVidKingSession } from "@/lib/scrape/vidking-playback";
import { primeVidsrcJwtSession } from "@/lib/scrape/vidsrc-playback";
import { primeVixsrcSession } from "@/lib/scrape/vixsrc-playback";
@@ -156,15 +157,16 @@ export async function handleScrapePost(request: Request) {
}
if (parsed.mediaKind === "anime") {
- return handleAnimeScrapePost(parsed);
+ return handleAnimeScrapePost(parsed, request.signal);
}
- return handleTmdbScrapePost(parsed, request.signal);
+ return handleTmdbScrapePost(parsed, request.signal, request);
}
async function handleTmdbScrapePost(
input: z.infer & { mediaKind?: "tmdb" },
signal: AbortSignal,
+ request: Request,
) {
const flags = await getSiteFlags();
if (input.providerId === "direct" && !isDirectScrapeProviderConfigured()) {
@@ -296,7 +298,11 @@ async function handleTmdbScrapePost(
async function handleAnimeScrapePost(
input: z.infer,
+ signal: AbortSignal,
) {
+ if (signal.aborted) {
+ return new NextResponse(null, { status: 499 });
+ }
const flags = await getSiteFlags();
if (
input.providerId &&
@@ -322,6 +328,7 @@ async function handleAnimeScrapePost(
episodeNumber: input.episodeNumber,
translationType: input.translationType,
query: input.query,
+ signal,
...(input.tmdb ? { tmdb: input.tmdb } : {}),
};
@@ -342,12 +349,24 @@ async function handleAnimeScrapePost(
});
}
+ let streamUrl = result.streamUrl;
+ let megaplayRefresh = isMegaplayPlaybackRefresh(result.playbackRefresh)
+ ? result.playbackRefresh
+ : undefined;
+
+ if (megaplayRefresh) {
+ const resolved = await resolveMegaplayStreamAtScrapeTime(
+ streamUrl,
+ megaplayRefresh,
+ );
+ streamUrl = resolved.streamUrl;
+ megaplayRefresh = resolved.refresh;
+ }
+
const playbackToken: ScrapePlaybackToken = {
- url: result.streamUrl,
+ url: streamUrl,
referer: result.referer,
- ...(isMegaplayPlaybackRefresh(result.playbackRefresh)
- ? { refresh: result.playbackRefresh }
- : {}),
+ ...(megaplayRefresh ? { refresh: megaplayRefresh } : {}),
...(result.cookies ? { cookies: result.cookies } : {}),
};
@@ -445,7 +464,7 @@ export async function handleAnimeScrapeGet() {
naruto: {
anilistId: 20,
nyumatflixApiQuery: {
- providerId: "animestream",
+ providerId: "animegg",
anilistId: 20,
episodeNumber: 1,
query: "Naruto",
diff --git a/apps/web/lib/scrape/calluspirates-fetch.ts b/apps/web/lib/scrape/calluspirates-fetch.ts
index 7d8ea130..6f457df0 100644
--- a/apps/web/lib/scrape/calluspirates-fetch.ts
+++ b/apps/web/lib/scrape/calluspirates-fetch.ts
@@ -40,6 +40,7 @@ export async function fetchCalluspirates(
return fetch(input, {
...rest,
+ cache: rest.cache ?? "no-store",
headers,
signal: rest.signal
? AbortSignal.any([rest.signal, timeoutSignal])
diff --git a/apps/web/lib/scrape/client-playback-startup-probe.ts b/apps/web/lib/scrape/client-playback-startup-probe.ts
new file mode 100644
index 00000000..4f4d372c
--- /dev/null
+++ b/apps/web/lib/scrape/client-playback-startup-probe.ts
@@ -0,0 +1,159 @@
+"use client";
+
+import { resolveHlsPlaylistUrl } from "./hls-url";
+import { extractHlsProbeTargets } from "./hls-probe-targets";
+
+export type ClientPlaybackStartupProbeResult = {
+ ok: boolean;
+ ms: number;
+};
+
+export const CLIENT_PLAYBACK_STARTUP_PROBE_TIMEOUT_MS = 12_000;
+
+const SEGMENT_RANGE = "bytes=0-65535";
+
+const resolveClientPlayUrl = (playUrl: string): string => {
+ if (playUrl.startsWith("/")) {
+ return `${window.location.origin}${playUrl}`;
+ }
+ return playUrl;
+};
+
+const mergeSignals = (
+ primary: AbortSignal,
+ secondary: AbortSignal,
+): AbortSignal => {
+ if (typeof AbortSignal.any === "function") {
+ return AbortSignal.any([primary, secondary]);
+ }
+
+ const merged = new AbortController();
+ const abort = () => merged.abort();
+ if (primary.aborted || secondary.aborted) {
+ merged.abort();
+ return merged.signal;
+ }
+ primary.addEventListener("abort", abort, { once: true });
+ secondary.addEventListener("abort", abort, { once: true });
+ return merged.signal;
+};
+
+const fetchWithRange = async (
+ url: string,
+ signal: AbortSignal,
+ range?: string,
+): Promise => {
+ const headers: HeadersInit = range ? { Range: range } : {};
+ const response = await fetch(url, {
+ signal,
+ headers,
+ cache: "no-store",
+ });
+ if (!response.ok) {
+ return false;
+ }
+
+ await response.arrayBuffer();
+ return true;
+};
+
+const firstMediaSegmentUrl = (
+ body: string,
+ playlistUrl: string,
+): string | null => {
+ const { childPlaylist, requiredAssets } = extractHlsProbeTargets(
+ body,
+ playlistUrl,
+ );
+ if (childPlaylist) {
+ return childPlaylist;
+ }
+ return requiredAssets[0] ?? null;
+};
+
+const probeHlsStartup = async (
+ playUrl: string,
+ signal: AbortSignal,
+): Promise => {
+ const absolute = resolveClientPlayUrl(playUrl);
+ const masterResponse = await fetch(absolute, { signal, cache: "no-store" });
+ if (!masterResponse.ok) {
+ return false;
+ }
+
+ const masterBody = await masterResponse.text();
+ if (!masterBody.includes("#EXTM3U")) {
+ return false;
+ }
+
+ const nextTarget = firstMediaSegmentUrl(masterBody, absolute);
+ if (!nextTarget) {
+ return false;
+ }
+
+ if (nextTarget.includes(".m3u8") || nextTarget.includes("/asset.m3u8")) {
+ const variantResponse = await fetch(nextTarget, {
+ signal,
+ cache: "no-store",
+ });
+ if (!variantResponse.ok) {
+ return false;
+ }
+ const variantBody = await variantResponse.text();
+ const segmentUrl = firstMediaSegmentUrl(variantBody, nextTarget);
+ if (!segmentUrl || segmentUrl.includes(".m3u8")) {
+ return false;
+ }
+ return fetchWithRange(segmentUrl, signal, SEGMENT_RANGE);
+ }
+
+ return fetchWithRange(nextTarget, signal, SEGMENT_RANGE);
+};
+
+export async function probeClientPlaybackStartup(
+ playUrl: string,
+ options?: { signal?: AbortSignal; timeoutMs?: number },
+): Promise {
+ const timeoutMs =
+ options?.timeoutMs ?? CLIENT_PLAYBACK_STARTUP_PROBE_TIMEOUT_MS;
+ const timeoutController = new AbortController();
+ const timeoutId = window.setTimeout(
+ () => timeoutController.abort(),
+ timeoutMs,
+ );
+ const signal = options?.signal
+ ? mergeSignals(options.signal, timeoutController.signal)
+ : timeoutController.signal;
+
+ const started = performance.now();
+
+ try {
+ const absolute = resolveClientPlayUrl(playUrl);
+ if (/\.mp4(?:[?#]|$)/i.test(absolute)) {
+ const ok = await fetchWithRange(absolute, signal, SEGMENT_RANGE);
+ return { ok, ms: performance.now() - started };
+ }
+
+ if (/\.mpd(?:[?#]|$)/i.test(absolute)) {
+ const ok = await fetchWithRange(absolute, signal);
+ return { ok, ms: performance.now() - started };
+ }
+
+ const ok = await probeHlsStartup(playUrl, signal);
+ return { ok, ms: performance.now() - started };
+ } catch {
+ return { ok: false, ms: performance.now() - started };
+ } finally {
+ window.clearTimeout(timeoutId);
+ }
+}
+
+export const prefetchScrapePlayback = (playUrl: string): void => {
+ if (typeof window === "undefined" || !playUrl) {
+ return;
+ }
+
+ void probeClientPlaybackStartup(playUrl, {
+ timeoutMs: CLIENT_PLAYBACK_STARTUP_PROBE_TIMEOUT_MS,
+ }).catch(() => undefined);
+};
diff --git a/apps/web/lib/scrape/hls-probe-targets.ts b/apps/web/lib/scrape/hls-probe-targets.ts
new file mode 100644
index 00000000..f7934f09
--- /dev/null
+++ b/apps/web/lib/scrape/hls-probe-targets.ts
@@ -0,0 +1,51 @@
+import { resolveHlsPlaylistUrl } from "./hls-url";
+
+const HLS_URI_ATTRIBUTE_PATTERN = /\bURI=(?:"([^"]+)"|'([^']+)')/i;
+
+export type HlsProbeTargets = {
+ childPlaylist: string | null;
+ requiredAssets: string[];
+};
+
+export const extractHlsProbeTargets = (
+ body: string,
+ playlistUrl: string,
+): HlsProbeTargets => {
+ const lines = body.split(/\r?\n/).map((line) => line.trim());
+ const requiredAssets: string[] = [];
+
+ for (const line of lines) {
+ if (!line.startsWith("#EXT-X-MAP") && !line.startsWith("#EXT-X-KEY")) {
+ continue;
+ }
+
+ const match = line.match(HLS_URI_ATTRIBUTE_PATTERN);
+ const value = match?.[1] ?? match?.[2];
+ if (value) {
+ const resolved = resolveHlsPlaylistUrl(value, playlistUrl);
+ if (resolved && !requiredAssets.includes(resolved)) {
+ requiredAssets.push(resolved);
+ }
+ }
+ }
+
+ let childPlaylist: string | null = null;
+ for (const line of lines) {
+ if (line && !line.startsWith("#")) {
+ const resolved = resolveHlsPlaylistUrl(line, playlistUrl);
+ if (!resolved) {
+ continue;
+ }
+
+ if (body.includes("#EXT-X-STREAM-INF")) {
+ childPlaylist = resolved;
+ break;
+ }
+
+ requiredAssets.push(resolved);
+ break;
+ }
+ }
+
+ return { childPlaylist, requiredAssets };
+};
diff --git a/apps/web/lib/scrape/hls-quality.ts b/apps/web/lib/scrape/hls-quality.ts
index 740817ab..c7538296 100644
--- a/apps/web/lib/scrape/hls-quality.ts
+++ b/apps/web/lib/scrape/hls-quality.ts
@@ -8,6 +8,11 @@ const STALL_RECOVERY_DETAILS = new Set([
]);
const STALL_RECOVERY_COOLDOWN_MS = 2_000;
+const STALL_RECOVERY_STARTUP_GRACE_MS = 5_000;
+const UNRECOVERABLE_HLS_HTTP_STATUS = new Set([403, 404, 410, 451]);
+
+export const PLAYING_FRAG_LOADING_TIMEOUT_MS = 15_000;
+export const PLAYING_FRAG_LOADING_MAX_RETRY = 3;
/** HLS renditions without a parsed height show up as "0p" in Vidstack's quality menu. */
export const isRenderableVideoQualityHeight = (height: number): boolean =>
@@ -54,8 +59,6 @@ export const isRecoverableScrapeHlsStall = (detail: ErrorData) =>
!detail.fatal &&
Boolean(detail.details && STALL_RECOVERY_DETAILS.has(detail.details));
-const STALL_RECOVERY_STARTUP_GRACE_MS = 5_000;
-
export const pickHighestHlsLevelIndex = (levels: readonly Level[]): number => {
let bestIndex = 0;
let bestHeight = 0;
@@ -71,9 +74,224 @@ export const pickHighestHlsLevelIndex = (levels: readonly Level[]): number => {
return bestIndex;
};
-export const configureScrapeHlsInstance = (hls: Hls) => {
+export const pickNextLowerHlsLevelIndex = (
+ levels: readonly Level[],
+ currentLevel: number,
+): number | null => {
+ if (currentLevel < 0 || currentLevel >= levels.length || levels.length < 2) {
+ return null;
+ }
+
+ const currentHeight = inferHeightFromLevel(levels[currentLevel]!);
+ let bestIndex: number | null = null;
+ let bestHeight = Number.NEGATIVE_INFINITY;
+
+ for (let index = 0; index < levels.length; index += 1) {
+ if (index === currentLevel) {
+ continue;
+ }
+
+ const height = inferHeightFromLevel(levels[index]!);
+ if (height <= 0 || height >= currentHeight || height <= bestHeight) {
+ continue;
+ }
+
+ bestHeight = height;
+ bestIndex = index;
+ }
+
+ return bestIndex;
+};
+
+export const nextScrapeHlsLevelAfterError = (
+ levels: readonly Level[],
+ currentLevel: number,
+): number | null => {
+ const lower = pickNextLowerHlsLevelIndex(levels, currentLevel);
+ if (lower !== null) {
+ return lower;
+ }
+
+ if (currentLevel >= 0 && levels.length > 1) {
+ return -1;
+ }
+
+ return null;
+};
+
+export type BufferedMediaLike = {
+ currentTime: number;
+ buffered: {
+ length: number;
+ start: (index: number) => number;
+ end: (index: number) => number;
+ };
+};
+
+export const bufferedAheadSeconds = (
+ media: BufferedMediaLike | null,
+): number => {
+ if (!media || media.buffered.length === 0) {
+ return 0;
+ }
+
+ const playhead = media.currentTime;
+ for (let index = 0; index < media.buffered.length; index += 1) {
+ const start = media.buffered.start(index);
+ const end = media.buffered.end(index);
+ if (playhead >= start && playhead <= end) {
+ return Math.max(0, end - playhead);
+ }
+ }
+
+ return 0;
+};
+
+export const isScrapeHlsMidstream = (
+ media: BufferedMediaLike | null,
+): boolean => {
+ if (!media) {
+ return false;
+ }
+
+ return media.currentTime > 1 || bufferedAheadSeconds(media) > 1;
+};
+
+export const hlsErrorHttpStatus = (
+ detail: Pick,
+): number | undefined => {
+ const code = detail.response?.code;
+ return typeof code === "number" && Number.isFinite(code) ? code : undefined;
+};
+
+export const isUnrecoverableScrapeHlsError = (detail: ErrorData): boolean => {
+ const status = hlsErrorHttpStatus(detail);
+ if (status !== undefined && UNRECOVERABLE_HLS_HTTP_STATUS.has(status)) {
+ return true;
+ }
+
+ return (
+ detail.type === Hls.ErrorTypes.MUX_ERROR ||
+ detail.type === Hls.ErrorTypes.KEY_SYSTEM_ERROR
+ );
+};
+
+export type ScrapeHlsRecoveryBudget = {
+ network: number;
+ media: number;
+};
+
+export const scrapeHlsRecoveryBudget = (
+ bufferedAhead: number,
+): ScrapeHlsRecoveryBudget => {
+ if (bufferedAhead >= 10) {
+ return { network: 3, media: 2 };
+ }
+
+ if (bufferedAhead >= 3) {
+ return { network: 2, media: 1 };
+ }
+
+ return { network: 1, media: 1 };
+};
+
+export type ScrapeHlsFatalRecoveryAction =
+ | "fail-fast"
+ | "unrecoverable"
+ | "recover-network"
+ | "recover-media"
+ | "exhausted";
+
+export const decideScrapeHlsFatalRecovery = (input: {
+ midstream: boolean;
+ unrecoverable: boolean;
+ errorType: ErrorData["type"] | undefined;
+ networkRetries: number;
+ mediaRetries: number;
+ budget: ScrapeHlsRecoveryBudget;
+}): ScrapeHlsFatalRecoveryAction => {
+ if (!input.midstream) {
+ return "fail-fast";
+ }
+
+ if (input.unrecoverable) {
+ return "unrecoverable";
+ }
+
+ if (
+ input.errorType === Hls.ErrorTypes.NETWORK_ERROR &&
+ input.networkRetries < input.budget.network
+ ) {
+ return "recover-network";
+ }
+
+ if (
+ input.errorType === Hls.ErrorTypes.MEDIA_ERROR &&
+ input.mediaRetries < input.budget.media
+ ) {
+ return "recover-media";
+ }
+
+ return "exhausted";
+};
+
+export const shouldFailoverScrapeHlsFatal = (
+ detail: ErrorData,
+ midstream: boolean,
+): boolean => {
+ if (!detail.fatal) {
+ return false;
+ }
+
+ if (!midstream) {
+ return true;
+ }
+
+ return isUnrecoverableScrapeHlsError(detail);
+};
+
+const markHlsErrorNonFatal = (detail: ErrorData): void => {
+ detail.fatal = false;
+};
+
+const resumeLoadPosition = (hls: Hls): number => {
+ const resumeAt = hls.media?.currentTime;
+ return typeof resumeAt === "number" && Number.isFinite(resumeAt)
+ ? resumeAt
+ : -1;
+};
+
+export type ScrapeHlsInstanceOptions = {
+ onRecoveryExhausted?: (detail: ErrorData) => void;
+};
+
+export const configureScrapeHlsInstance = (
+ hls: Hls,
+ options?: ScrapeHlsInstanceOptions,
+) => {
let lastStallRecoveryAt = 0;
let manifestLoadedAt = 0;
+ let networkRetries = 0;
+ let mediaRetries = 0;
+ let tightenedLoading = false;
+ let downshifted = false;
+
+ const downshiftOnError = () => {
+ if (downshifted) {
+ return;
+ }
+
+ const nextLevel = nextScrapeHlsLevelAfterError(
+ hls.levels,
+ hls.currentLevel,
+ );
+ if (nextLevel === null) {
+ return;
+ }
+
+ downshifted = true;
+ hls.currentLevel = nextLevel;
+ };
hls.on(Hls.Events.MANIFEST_PARSED, () => {
manifestLoadedAt = Date.now();
@@ -83,25 +301,71 @@ export const configureScrapeHlsInstance = (hls: Hls) => {
hls.levels.length > 0 ? pickHighestHlsLevelIndex(hls.levels) : -1;
});
+ hls.on(Hls.Events.FRAG_LOADED, () => {
+ networkRetries = 0;
+ mediaRetries = 0;
+
+ if (tightenedLoading) {
+ return;
+ }
+
+ tightenedLoading = true;
+ hls.config.fragLoadingTimeOut = PLAYING_FRAG_LOADING_TIMEOUT_MS;
+ hls.config.fragLoadingMaxRetry = PLAYING_FRAG_LOADING_MAX_RETRY;
+ });
+
hls.on(Hls.Events.ERROR, (_event, detail) => {
- if (!isRecoverableScrapeHlsStall(detail)) {
+ if (isRecoverableScrapeHlsStall(detail)) {
+ const now = Date.now();
+ if (now - manifestLoadedAt < STALL_RECOVERY_STARTUP_GRACE_MS) {
+ return;
+ }
+
+ if (now - lastStallRecoveryAt < STALL_RECOVERY_COOLDOWN_MS) {
+ return;
+ }
+
+ lastStallRecoveryAt = now;
+ hls.startLoad(resumeLoadPosition(hls));
return;
}
- const now = Date.now();
- if (now - manifestLoadedAt < STALL_RECOVERY_STARTUP_GRACE_MS) {
+ if (!detail.fatal) {
return;
}
- if (now - lastStallRecoveryAt < STALL_RECOVERY_COOLDOWN_MS) {
+ const media = hls.media;
+ const action = decideScrapeHlsFatalRecovery({
+ midstream: isScrapeHlsMidstream(media),
+ unrecoverable: isUnrecoverableScrapeHlsError(detail),
+ errorType: detail.type,
+ networkRetries,
+ mediaRetries,
+ budget: scrapeHlsRecoveryBudget(bufferedAheadSeconds(media)),
+ });
+
+ if (action === "recover-network") {
+ networkRetries += 1;
+ if (networkRetries > 1) {
+ downshiftOnError();
+ }
+ markHlsErrorNonFatal(detail);
+ hls.startLoad(resumeLoadPosition(hls));
return;
}
- lastStallRecoveryAt = now;
- // resume from the media clock — startLoad(0) rewinds to t=0 and desyncs seeks
- const resumeAt = hls.media?.currentTime;
- hls.startLoad(
- typeof resumeAt === "number" && Number.isFinite(resumeAt) ? resumeAt : -1,
- );
+ if (action === "recover-media") {
+ mediaRetries += 1;
+ downshiftOnError();
+ markHlsErrorNonFatal(detail);
+ hls.recoverMediaError();
+ return;
+ }
+
+ if (action === "fail-fast") {
+ return;
+ }
+
+ options?.onRecoveryExhausted?.(detail);
});
};
diff --git a/apps/web/lib/scrape/megaplay-playback.ts b/apps/web/lib/scrape/megaplay-playback.ts
index 727ac9df..c1c8c981 100644
--- a/apps/web/lib/scrape/megaplay-playback.ts
+++ b/apps/web/lib/scrape/megaplay-playback.ts
@@ -62,6 +62,22 @@ export async function resolveMegaplayPlaybackUrl(
return fresh ?? upstreamUrl;
}
+/** Resolve Megaplay once at scrape time so mount → play is one hop. */
+export async function resolveMegaplayStreamAtScrapeTime(
+ streamUrl: string,
+ refresh: MegaplayPlaybackRefresh,
+): Promise<{ streamUrl: string; refresh: MegaplayPlaybackRefresh }> {
+ const fresh = await fetchMegaplayStreamUrl(refresh);
+ if (!fresh) {
+ return { streamUrl, refresh };
+ }
+
+ return {
+ streamUrl: fresh,
+ refresh: { ...refresh, seedStreamUrl: fresh },
+ };
+}
+
export const isRetryableMegaplayUpstreamStatus = (status: number) =>
status === 401 ||
status === 403 ||
diff --git a/apps/web/lib/scrape/playback-startup-score.ts b/apps/web/lib/scrape/playback-startup-score.ts
new file mode 100644
index 00000000..00ed5584
--- /dev/null
+++ b/apps/web/lib/scrape/playback-startup-score.ts
@@ -0,0 +1,102 @@
+import type { ScorableAnimeScrapePayload } from "@/lib/playback/playback-preferences";
+
+export type ScrapeStartupProbeFields = {
+ startupProbeMs?: number;
+ startupProbeOk?: boolean;
+};
+
+export const STARTUP_PROBE_PENDING_PENALTY = 50_000;
+export const STARTUP_PROBE_FAILED_PENALTY = 40_000;
+export const STARTUP_PROBE_MAX_BONUS = 4_000;
+
+/** Lower latency → higher bonus (caps at STARTUP_PROBE_MAX_BONUS). */
+export const scorePlaybackStartupMs = (ms: number): number => {
+ if (!Number.isFinite(ms) || ms < 0) {
+ return 0;
+ }
+
+ const capped = Math.min(ms, 12_000);
+ return Math.max(0, STARTUP_PROBE_MAX_BONUS - Math.round(capped / 3));
+};
+
+export const scorePayloadWithStartupProbe = (
+ baseScore: number,
+ payload: ScrapeStartupProbeFields,
+): number => {
+ if (payload.startupProbeOk === undefined) {
+ return baseScore - STARTUP_PROBE_PENDING_PENALTY;
+ }
+
+ if (!payload.startupProbeOk) {
+ return baseScore - STARTUP_PROBE_FAILED_PENALTY;
+ }
+
+ return baseScore + scorePlaybackStartupMs(payload.startupProbeMs ?? 12_000);
+};
+
+export const payloadStartupProbeReady = (
+ payload: ScrapeStartupProbeFields,
+): boolean => payload.startupProbeOk !== undefined;
+
+export const payloadStartupProbePassed = (
+ payload: ScrapeStartupProbeFields,
+): boolean => payload.startupProbeOk === true;
+
+export type StartupAwareScorablePayload = ScorableAnimeScrapePayload &
+ ScrapeStartupProbeFields;
+
+export const pickBestCachedFallback = <
+ TProviderId extends string,
+ TPayload extends ScrapeStartupProbeFields,
+>(options: {
+ order: readonly TProviderId[];
+ cache: ReadonlyMap;
+ failed: ReadonlySet;
+ excludeIds?: ReadonlySet;
+ scorePayload?: (payload: TPayload) => number;
+ requireStartupProbePass?: boolean;
+}): { providerId: TProviderId; payload: TPayload } | null => {
+ const excludeIds = options.excludeIds ?? new Set();
+ let best: {
+ providerId: TProviderId;
+ payload: TPayload;
+ score: number;
+ } | null = null;
+
+ for (const providerId of options.order) {
+ if (options.failed.has(providerId) || excludeIds.has(providerId)) {
+ continue;
+ }
+
+ const payload = options.cache.get(providerId);
+ if (!payload) {
+ continue;
+ }
+
+ if (options.requireStartupProbePass && payload.startupProbeOk === false) {
+ continue;
+ }
+
+ if (!options.scorePayload) {
+ return { providerId, payload };
+ }
+
+ const score = options.scorePayload(payload);
+ if (!best || score > best.score) {
+ best = { providerId, payload, score };
+ }
+ }
+
+ if (best) {
+ return { providerId: best.providerId, payload: best.payload };
+ }
+
+ if (options.requireStartupProbePass && options.scorePayload) {
+ return pickBestCachedFallback({
+ ...options,
+ requireStartupProbePass: false,
+ });
+ }
+
+ return null;
+};
diff --git a/apps/web/lib/scrape/player-sources.ts b/apps/web/lib/scrape/player-sources.ts
index 45293deb..e01d7d33 100644
--- a/apps/web/lib/scrape/player-sources.ts
+++ b/apps/web/lib/scrape/player-sources.ts
@@ -1,5 +1,9 @@
import type { PlayerSrc } from "@vidstack/react";
+import {
+ progressStorageKey,
+ type PlaybackProgressKey,
+} from "@/lib/playback/progress-storage";
import { vidstackCaptionMenuValue } from "@/lib/playback/vidstack-caption-menu";
import {
@@ -436,6 +440,7 @@ export const buildScrapePlayerKey = (input: {
qualities?: ScrapeQuality[];
subtitles?: ScrapeSubtitle[];
audioVersions?: ScrapeAudioVersion[];
+ progressKey?: PlaybackProgressKey;
}) => {
const qualityKey =
input.qualities?.map((quality) => quality.label).join(",") ?? "";
@@ -446,6 +451,9 @@ export const buildScrapePlayerKey = (input: {
`${version.lang}:${version.hardSubs?.map((track) => track.lang).join(",") ?? ""}`,
)
.join("|") ?? "";
+ const coordsKey = input.progressKey
+ ? progressStorageKey(input.progressKey)
+ : "";
- return `${input.playUrl}-${qualityKey}-${audioKey}`;
+ return `${input.playUrl}-${qualityKey}-${audioKey}-${coordsKey}`;
};
diff --git a/apps/web/lib/scrape/preferred-provider.ts b/apps/web/lib/scrape/preferred-provider.ts
index 33e29ff0..9a1315ee 100644
--- a/apps/web/lib/scrape/preferred-provider.ts
+++ b/apps/web/lib/scrape/preferred-provider.ts
@@ -59,17 +59,11 @@ export const getPreferredScrapeProvider = (mediaKey: string): string | null => {
return normalizePreferredProviderId(entry.providerId);
};
-import { UNTRUSTED_PREFERRED_SCRAPE_PROVIDERS } from "@/lib/scrape/provider-race";
-
export const setPreferredScrapeProvider = (
mediaKey: string,
providerId: string,
): void => {
- if (
- !mediaKey ||
- !providerId ||
- UNTRUSTED_PREFERRED_SCRAPE_PROVIDERS.has(providerId)
- ) {
+ if (!mediaKey || !providerId) {
return;
}
diff --git a/apps/web/lib/scrape/prefetch-scrape-player.ts b/apps/web/lib/scrape/prefetch-scrape-player.ts
new file mode 100644
index 00000000..0a6b0307
--- /dev/null
+++ b/apps/web/lib/scrape/prefetch-scrape-player.ts
@@ -0,0 +1,10 @@
+import { loadMoviCompat } from "@/lib/player/load-player";
+
+/** Warm Movi WASM while scrape races run. */
+export function prefetchMoviScrapePlayer(): void {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ void loadMoviCompat().catch(() => undefined);
+}
diff --git a/apps/web/lib/scrape/provider-race.ts b/apps/web/lib/scrape/provider-race.ts
index 0f6125f7..3acd6b97 100644
--- a/apps/web/lib/scrape/provider-race.ts
+++ b/apps/web/lib/scrape/provider-race.ts
@@ -9,11 +9,8 @@ export const SCRAPE_DIRECT_ATTEMPT_TIMEOUT_MS = 300_000;
/** Run alone before parallel races (legacy default). */
export const SOLO_FIRST_SCRAPE_PROVIDERS = new Set(["direct"]);
-/**
- * TMDB overlay races Direct with the first scraper batch so obscure titles do not
- * wait out a solo Direct crawl before Bingr/VidEasy/VidKing start.
- */
-export const TMDB_SCRAPE_SOLO_FIRST_PROVIDERS = new Set();
+/** TMDB overlay solos Direct first so it is the default source when healthy. */
+export const TMDB_SCRAPE_SOLO_FIRST_PROVIDERS = new Set(["direct"]);
/** Anime playback races Direct with leading anime scrapers instead of soloing it. */
export const ANIME_PLAYBACK_SOLO_FIRST_PROVIDERS = new Set();
@@ -30,7 +27,6 @@ const PROVIDER_ATTEMPT_TIMEOUT_MS: Partial> = {
vidnest: 30_000,
videasy: 60_000,
vidking: 30_000,
- animestream: 20_000,
animegg: 18_000,
anizone: 20_000,
kickassanime: 20_000,
@@ -58,11 +54,6 @@ export function getScrapeAttemptTimeoutMs(
return SCRAPE_ATTEMPT_TIMEOUT_MS;
}
-/** Fast scrapers that often pass probes but fail in the player — never pin as preferred. */
-export const UNTRUSTED_PREFERRED_SCRAPE_PROVIDERS = new Set([
- "vidrock",
-]);
-
export type RaceBatchResult = {
batch: T[];
nextIndex: number;
diff --git a/apps/web/lib/scrape/providers/bingr.ts b/apps/web/lib/scrape/providers/bingr.ts
index 1b78598b..f2096a8d 100644
--- a/apps/web/lib/scrape/providers/bingr.ts
+++ b/apps/web/lib/scrape/providers/bingr.ts
@@ -82,24 +82,51 @@ const encodeLooseUrl = (url: string): string => {
/** @deprecated Use unwrapProxyUrl from source-resolve */
export const unwrapBingrProxyUrl = unwrapProxyUrl;
+const mergeAbortSignals = (
+ primary: AbortSignal,
+ secondary: AbortSignal,
+): AbortSignal => {
+ if (typeof AbortSignal.any === "function") {
+ return AbortSignal.any([primary, secondary]);
+ }
+
+ const merged = new AbortController();
+ const abort = () => merged.abort();
+ if (primary.aborted || secondary.aborted) {
+ merged.abort();
+ return merged.signal;
+ }
+ primary.addEventListener("abort", abort, { once: true });
+ secondary.addEventListener("abort", abort, { once: true });
+ return merged.signal;
+};
+
export const raceWithTimeout = async (
- promise: Promise,
+ work: (signal: AbortSignal) => Promise,
timeoutMs: number,
onTimeout: T,
+ options?: { signal?: AbortSignal },
): Promise<{ value: T; timedOut: boolean }> => {
let winner: "work" | "timeout" | undefined;
let timeoutId: ReturnType | undefined;
+ const cancelController = new AbortController();
+ const cancelSignal = options?.signal
+ ? mergeAbortSignals(cancelController.signal, options.signal)
+ : cancelController.signal;
const timeoutPromise = new Promise((resolve) => {
timeoutId = setTimeout(() => {
winner ??= "timeout";
+ if (!cancelController.signal.aborted) {
+ cancelController.abort();
+ }
resolve(onTimeout);
}, timeoutMs);
});
try {
const value = await Promise.race([
- promise.then((result) => {
+ work(cancelSignal).then((result) => {
winner ??= "work";
return result;
}),
@@ -311,6 +338,7 @@ const postStream = async (
input: ScrapeMediaInput,
server: BingrServer,
details: BingrDetails | null,
+ signal?: AbortSignal,
): Promise => {
try {
const response = await scrapeFetch(`${BINGR_API}/stream`, {
@@ -322,6 +350,7 @@ const postStream = async (
body: JSON.stringify(buildBingrStreamBody(input, server, details)),
timeoutMs: BINGR_SERVER_TIMEOUT_MS,
retryAttempts: 1,
+ signal,
});
if (!response.ok) {
@@ -338,12 +367,14 @@ const postStream = async (
const probeHlsMaster = async (
streamUrl: string,
referer: string,
+ signal?: AbortSignal,
): Promise<{ body: string; qualities: ScrapeQuality[] } | null> => {
try {
const response = await scrapeFetch(streamUrl, {
headers: { Referer: referer },
timeoutMs: BINGR_HLS_PROBE_TIMEOUT_MS,
retryAttempts: 1,
+ signal,
});
if (!response.ok) {
await cancelResponseBody(response);
@@ -367,6 +398,7 @@ const probeHlsMaster = async (
const probeMp4 = async (
streamUrl: string,
referer: string,
+ signal?: AbortSignal,
): Promise