diff --git a/.dockerignore b/.dockerignore index 2a50608c..7b7d0d3c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,4 +13,6 @@ yarn-debug.log* .DS_Store .env .env.* +apps/web/.env +apps/web/.env.* certificates diff --git a/.env.example b/.env.example index 3969555f..0270be21 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,6 @@ FLIPT_URL=http://127.0.0.1:8090 FLIPT_ENVIRONMENT=default FLIPT_NAMESPACE=default FLIPT_API_TOKEN= -FLIPT_FLAG_CACHE_TTL_MS=600000 # Public asset CDN origin (must match next.config assetPrefix / Bunny pull zone) NEXT_PUBLIC_CDN_ORIGIN=https://cdn.nyumatflix.com IMGPROXY_PORT=9081 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e2a915d..aa9c7731 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,6 @@ env: FLIPT_URL: http://flipt:8080 FLIPT_ENVIRONMENT: default FLIPT_NAMESPACE: default - FLIPT_FLAG_CACHE_TTL_MS: "30000" on: push: @@ -87,7 +86,6 @@ jobs: FLIPT_ENVIRONMENT=${FLIPT_ENVIRONMENT} FLIPT_NAMESPACE=${FLIPT_NAMESPACE} FLIPT_API_TOKEN=${FLIPT_API_TOKEN} - FLIPT_FLAG_CACHE_TTL_MS=${FLIPT_FLAG_CACHE_TTL_MS} EOF - name: Build WASM and app diff --git a/.gitignore b/.gitignore index 4218cf2c..94d15900 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules/ # testing /coverage +/benchmark-results/ # next.js **/.next/ diff --git a/.husky/check-emoji.js b/.husky/check-emoji.js index 8039bb8c..36f15aa8 100644 --- a/.husky/check-emoji.js +++ b/.husky/check-emoji.js @@ -1,22 +1,21 @@ import fs from "fs"; -import emojiRegex from "emojibase-regex"; const msgFile = process.argv[2]; if (!msgFile) { console.error("No commit message file found"); - process.exit(1); + process.exit(2); } const msg = fs.readFileSync(msgFile, "utf8"); const firstLine = (msg.split(/\r?\n/)[0] ?? "").trim(); -// emojibase-regex covers most sequences; avoid the `g` flag so .test() does not -// depend on lastIndex. Extended_Pictographic catches edge cases the base pattern misses. -const emojibasePattern = new RegExp(emojiRegex.source, "u"); const hasEmojiInSubject = - emojibasePattern.test(firstLine) || - /\p{Extended_Pictographic}/u.test(firstLine); + /\p{Extended_Pictographic}/u.test(firstLine) || + /\p{Emoji_Presentation}/u.test(firstLine); + +if (hasEmojiInSubject) { + process.exit(0); +} -if (hasEmojiInSubject) process.exit(0); process.exit(1); diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg index 4eecb5a3..188a64e9 100644 --- a/.husky/prepare-commit-msg +++ b/.husky/prepare-commit-msg @@ -20,12 +20,40 @@ if ! command -v node >/dev/null 2>&1; then fi fi -# Always run the checker when possible so we skip emojify if the subject already has an emoji. -# Previously, the no-bun path skipped the checker and always ran emojify (duplicate emojis). -if command -v bun >/dev/null 2>&1; then - bun "$SCRIPT_DIR/check-emoji.js" "$1" || bunx --bun @humankode/git-commit-emojify@latest "$1" -elif command -v node >/dev/null 2>&1; then - node "$SCRIPT_DIR/check-emoji.js" "$1" || npx --yes @humankode/git-commit-emojify@latest "$1" -else +run_checker() { + if command -v bun >/dev/null 2>&1; then + bun "$SCRIPT_DIR/check-emoji.js" "$1" + return $? + fi + + if command -v node >/dev/null 2>&1; then + node "$SCRIPT_DIR/check-emoji.js" "$1" + return $? + fi + + return 2 +} + +run_emojify() { + if command -v bun >/dev/null 2>&1; then + bunx --bun @humankode/git-commit-emojify@latest "$1" + return $? + fi + npx --yes @humankode/git-commit-emojify@latest "$1" +} + +run_checker "$1" +status=$? + +if [ "$status" -eq 0 ]; then + exit 0 fi + +if [ "$status" -eq 1 ]; then + run_emojify "$1" + exit $? +fi + +echo "prepare-commit-msg: emoji check failed (exit $status)" >&2 +exit "$status" diff --git a/Dockerfile b/Dockerfile index fc5537a4..51c5eba5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,9 +10,11 @@ FROM oven/bun:1 AS builder WORKDIR /app ENV NEXT_TELEMETRY_DISABLED=1 +ENV SKIP_ENSURE_ENV=1 ARG TMDB_API_KEY ARG CAP_API_ENDPOINT +ARG SKIP_PLAYER_BUILD=0 ENV TMDB_API_KEY=$TMDB_API_KEY ENV CAP_API_ENDPOINT=$CAP_API_ENDPOINT @@ -23,7 +25,15 @@ COPY packages ./packages RUN test -f packages/player/dist/wasm/movi.js || (echo "missing packages/player/dist/wasm/movi.js — run: bunx turbo build:wasm --filter=@nyumatflix/player" && exit 1) RUN node apps/web/scripts/prepare-anime-mappings.mjs RUN bunx turbo build --filter=@calluspirates/shared --filter=@nyumatflix/playback -RUN cd packages/player && bun run build +RUN if [ "$SKIP_PLAYER_BUILD" = "1" ] && \ + [ -f packages/player/dist/element.js ] && \ + [ -f packages/player/dist/wasm/movi.js ] && \ + [ -f apps/web/public/vendor/player/element.js ]; then \ + echo "[build] player artifacts present — skipping player rebuild"; \ + else \ + cd packages/player && bun run build; \ + fi +RUN cd apps/web && bun install --foreground-scripts sharp @img/sharp-linux-x64 RUN cd apps/web && bun run build FROM node:24.15.0-slim AS runner diff --git a/app/api/search-prompts/route.ts b/app/api/search-prompts/route.ts deleted file mode 100644 index 76ef84bf..00000000 --- a/app/api/search-prompts/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { catalogCacheHeaders } from "@/lib/http-cache"; -import { buildSearchPrompts } from "@/lib/search/search-prompts"; -import { NextResponse } from "next/server"; - -export async function GET() { - if (!process.env.TMDB_API_KEY) { - return NextResponse.json( - { error: "TMDB API key is not configured" }, - { status: 500 }, - ); - } - - try { - const data = await buildSearchPrompts(); - return NextResponse.json(data, { headers: catalogCacheHeaders() }); - } catch (error) { - console.error("Error in search prompts API route:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); - } -} diff --git a/app/api/user/update-name/route.ts b/app/api/user/update-name/route.ts deleted file mode 100644 index af1ae7cb..00000000 --- a/app/api/user/update-name/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { auth } from "@/auth"; -import { db, users } from "@/db/schema"; -import { eq } from "drizzle-orm"; -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; - -const updateNameSchema = z.object({ - name: z - .string() - .min(1, "Name is required") - .max(100, "Name must be less than 100 characters"), -}); - -export async function PATCH(request: NextRequest) { - try { - const session = await auth(); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json(); - const validatedData = updateNameSchema.parse(body); - - await db - .update(users) - .set({ name: validatedData.name }) - .where(eq(users.id, session.user.id)); - - return NextResponse.json( - { message: "Name updated successfully" }, - { status: 200 }, - ); - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: "Invalid input", details: error.errors }, - { status: 400 }, - ); - } - - console.error("Error updating user name:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); - } -} diff --git a/app/history/history-client.tsx b/app/history/history-client.tsx deleted file mode 100644 index 71b710b5..00000000 --- a/app/history/history-client.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client"; - -import { RecentlyWatchedCard } from "@/components/home/recently-watched-card"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useWatchHistory } from "@/hooks/use-watch-history"; -import type { RecentlyWatchedScope } from "@/lib/playback/recently-watched"; -import Link from "next/link"; -import { useState } from "react"; - -const HISTORY_SCOPES = [ - { value: "all", label: "All" }, - { value: "movie", label: "Movies" }, - { value: "tv", label: "TV Shows" }, - { value: "anime", label: "Anime" }, -] as const satisfies ReadonlyArray<{ - value: RecentlyWatchedScope; - label: string; -}>; - -function HistoryGridFallback() { - return ( -
- {Array.from({ length: 6 }).map((_, index) => ( - - ))} -
- ); -} - -export function HistoryClient() { - const [scope, setScope] = useState("all"); - const { items, isLoading } = useWatchHistory(scope); - - return ( -
-
- -
-
-
-

- Watch History -

-

- Everything you have started on this device, plus synced progress - when you are signed in. -

-
-
- -
- setScope(value as RecentlyWatchedScope)} - className="w-full md:w-auto" - > - - {HISTORY_SCOPES.map((entry) => ( - - {entry.label} - - ))} - - -
- -
- {isLoading ? ( - - ) : items.length === 0 ? ( -
-

No watch history yet

-

- Start something and it will show up here. -

-
- - -
-
- ) : ( -
- {items.map((item, index) => ( - - ))} -
- )} -
-
-
- ); -} diff --git a/app/history/page.tsx b/app/history/page.tsx deleted file mode 100644 index 9a372f0f..00000000 --- a/app/history/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { StaticHero } from "@/components/hero/hero-static"; -import { ContentContainer } from "@/components/layout/content-container"; -import type { Metadata } from "next"; -import { HistoryClient } from "./history-client"; - -export const metadata: Metadata = { - title: "Watch History | NyumatFlix", - description: "Browse everything you have watched on NyumatFlix", -}; - -export default function HistoryPage() { - return ( -
- - - - -
- ); -} diff --git a/apps/web/__tests__/anilist-franchise-display.test.ts b/apps/web/__tests__/anilist-franchise-display.test.ts index ce1c8adc..f51ca7b2 100644 --- a/apps/web/__tests__/anilist-franchise-display.test.ts +++ b/apps/web/__tests__/anilist-franchise-display.test.ts @@ -22,8 +22,29 @@ const aotFribbMapping = { 104578: { tv: 1429, season: 3 }, 110277: { tv: 1429, season: 4 }, 131681: { tv: 1429, season: 4 }, + 146984: { tv: 1429, season: 4 }, }; +const aotFribbRows: FribbAnimeRow[] = [ + { + anilist_id: 110277, + themoviedb_id: { tv: 1429 }, + season: { tmdb: 4 }, + }, + { + anilist_id: 131681, + themoviedb_id: { tv: 1429 }, + season: { tmdb: 4 }, + episode_offset: { tmdb: 16 }, + }, + { + anilist_id: 146984, + themoviedb_id: { tv: 1429 }, + season: { tmdb: 4 }, + episode_offset: { tmdb: 28 }, + }, +]; + const buildMedia = (id: number, episodes: number): AniListTvMedia => ({ id, type: "ANIME", @@ -47,12 +68,17 @@ const buildEpisodes = (media: AniListTvMedia) => describe("groupFranchiseSeasonsByTmdb", () => { it("collapses split-cour Attack on Titan entries to four TMDB seasons", () => { expect( - groupFranchiseSeasonsByTmdb(aotFranchiseSeasons, aotFribbMapping, 1429), + groupFranchiseSeasonsByTmdb( + aotFranchiseSeasons, + aotFribbMapping, + 1429, + aotFribbRows, + ), ).toEqual([ { seasonNumber: 1, anilistIds: [16498] }, { seasonNumber: 2, anilistIds: [20958] }, { seasonNumber: 3, anilistIds: [99147, 104578] }, - { seasonNumber: 4, anilistIds: [110277, 131681] }, + { seasonNumber: 4, anilistIds: [110277, 131681, 146984] }, ]); }); @@ -90,11 +116,62 @@ describe("buildMergedEpisodesForTmdbSeason", () => { anilistIds: [99147, 104578], seasonsByAnilistId, fribbRows: aotFribbRows, - buildEpisodes, + buildEpisodes: (media) => buildEpisodes(media as AniListTvMedia), }); expect(episodes.map((episode) => episode.episode_number)).toEqual([ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, ]); }); + + it("appends sequel-only split-cour segments after the last Fribb offset", () => { + const seasonsByAnilistId = new Map([ + [110277, buildMedia(110277, 16)], + [131681, buildMedia(131681, 12)], + [146984, buildMedia(146984, 1)], + [162314, buildMedia(162314, 1)], + ]); + + const episodes = buildMergedEpisodesForTmdbSeason({ + tmdbShowId: 1429, + seasonNumber: 4, + anilistIds: [110277, 131681, 146984, 162314], + seasonsByAnilistId, + fribbRows: [ + { + anilist_id: 110277, + themoviedb_id: { tv: 1429 }, + season: { tmdb: 4 }, + }, + { + anilist_id: 131681, + themoviedb_id: { tv: 1429 }, + season: { tmdb: 4 }, + episode_offset: { tmdb: 16 }, + }, + { + anilist_id: 146984, + themoviedb_id: { tv: 1429 }, + season: { tmdb: 4 }, + episode_offset: { tmdb: 28 }, + }, + ], + buildEpisodes: (media) => buildEpisodes(media as AniListTvMedia), + }); + + expect(episodes.map((episode) => episode.episode_number)).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, + 22, 23, 24, 25, 26, 27, 28, 29, 30, + ]); + expect(episodes[28]).toMatchObject({ + episode_number: 29, + sourceAnilistId: 146984, + sourceEpisodeNumber: 1, + }); + expect(episodes[29]).toMatchObject({ + episode_number: 30, + sourceAnilistId: 162314, + sourceEpisodeNumber: 1, + }); + }); }); diff --git a/apps/web/__tests__/anilist-page-hrefs.test.ts b/apps/web/__tests__/anilist-page-hrefs.test.ts index 6fdf1113..eee9203d 100644 --- a/apps/web/__tests__/anilist-page-hrefs.test.ts +++ b/apps/web/__tests__/anilist-page-hrefs.test.ts @@ -11,7 +11,7 @@ describe("withAnimePageHref", () => { sourceAnilistId: 154587, href: "https://anilist.co/anime/154587", } as unknown as MediaItem).href, - ).toBe("/anime/154587"); + ).toBe("/anime/anilist-154587"); }); it("routes unmapped AniList fallback items to AniList-backed anime detail pages", () => { @@ -24,7 +24,7 @@ describe("withAnimePageHref", () => { sourceAnilistId: 188, href: "https://anilist.co/anime/188", } as unknown as MediaItem).href, - ).toBe("/anime/188"); + ).toBe("/anime/anilist-188"); }); it("prefers /anime routes over existing internal tv detail hrefs when anilistId is known", () => { @@ -35,7 +35,7 @@ describe("withAnimePageHref", () => { sourceAnilistId: 7, href: "/tvshows/42", } as unknown as MediaItem).href, - ).toBe("/anime/7"); + ).toBe("/anime/anilist-7"); }); it("routes mapped anime movies through TMDB movie detail pages", () => { diff --git a/apps/web/__tests__/anilist-route-collision-playback.test.ts b/apps/web/__tests__/anilist-route-collision-playback.test.ts new file mode 100644 index 00000000..02c37ed4 --- /dev/null +++ b/apps/web/__tests__/anilist-route-collision-playback.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { resolveEpisodeAnimeSelection } from "@/lib/anime/episode-playback-source"; +import { resolveAnimeEpisodeMappingTmdbShowId } from "@/lib/anime/resolve-coords-tmdb-show-id"; +import type { Episode } from "@/lib/domain/typings"; + +const buildEpisode = ( + overrides: Partial & Pick, +): Episode => ({ + id: overrides.id, + episode_number: overrides.episode_number, + name: overrides.name ?? `Episode ${overrides.episode_number}`, + overview: "", + air_date: "", + still_path: null, + runtime: 24, + sourceAnilistId: overrides.sourceAnilistId, + sourceEpisodeNumber: overrides.sourceEpisodeNumber, +}); + +describe("anilist route id collision playback", () => { + it("detects when /anime/9936 cannot use 9936 as a TMDB id", () => { + expect(resolveAnimeEpisodeMappingTmdbShowId("9936", 9936, null)).toBeNull(); + }); + + it("resolves Maken-Ki episode coords from embedded AniList fields", () => { + expect( + resolveEpisodeAnimeSelection( + buildEpisode({ + id: 99360001, + episode_number: 1, + sourceAnilistId: 9936, + sourceEpisodeNumber: 1, + }), + { animeSeasonNumber: 1 }, + ), + ).toEqual({ + animeInfo: { + anilistId: 9936, + startEpisode: 1, + endEpisode: 1, + }, + mapping: { + confidence: "high", + isAdult: false, + relativeEpisodeNumber: 1, + animeSeasonNumber: 1, + }, + }); + }); +}); diff --git a/apps/web/__tests__/anilist-route-id.test.ts b/apps/web/__tests__/anilist-route-id.test.ts index 7e983fe8..29b7728c 100644 --- a/apps/web/__tests__/anilist-route-id.test.ts +++ b/apps/web/__tests__/anilist-route-id.test.ts @@ -3,6 +3,7 @@ import { fromAnilistTvRouteId, isAnimeAnilistRouteId, isAnilistTvRouteId, + normalizeAnilistAnimeDetailHref, normalizeAnilistTvRouteSlug, parseAnimeAnilistRouteId, resolveAnilistIdFromTvRoute, @@ -12,11 +13,23 @@ import { import { describe, expect, it } from "vitest"; describe("anilist route ids", () => { - it("encodes AniList ids as bare anime detail hrefs", () => { + it("encodes AniList ids as prefixed anime detail hrefs", () => { expect(toAnilistTvRouteSlug(188)).toBe("anilist-188"); - expect(buildAnilistTvDetailHref(188)).toBe("/anime/188"); + expect(buildAnilistTvDetailHref(188)).toBe("/anime/anilist-188"); expect(buildAnilistTvDetailHref(200637, { season: 3 })).toBe( - "/anime/200637?season=3", + "/anime/anilist-200637?season=3", + ); + }); + + it("upgrades legacy bare anime detail hrefs", () => { + expect(normalizeAnilistAnimeDetailHref("/anime/9936")).toBe( + "/anime/anilist-9936", + ); + expect(normalizeAnilistAnimeDetailHref("/anime/anilist-9936")).toBe( + "/anime/anilist-9936", + ); + expect(normalizeAnilistAnimeDetailHref("/anime/tmdb-123")).toBe( + "/anime/tmdb-123", ); }); @@ -48,7 +61,9 @@ describe("anilist route ids", () => { expect(resolveTvDetailRouteId("/tvshows/anilist-113417", 113417)).toBe( "anilist-113417", ); - expect(resolveTvDetailRouteId("/anime/113417", 113417)).toBe("113417"); + expect(resolveTvDetailRouteId("/anime/113417", 113417)).toBe( + "anilist-113417", + ); expect(resolveTvDetailRouteId("/tvshows/85937", 85937)).toBe("85937"); expect( resolveTvDetailRouteId("/tvshows/85937?anilistId=154587", 85937), diff --git a/apps/web/__tests__/anilist-tv-tmdb-enrich.test.ts b/apps/web/__tests__/anilist-tv-tmdb-enrich.test.ts index 437d449e..53dd1968 100644 --- a/apps/web/__tests__/anilist-tv-tmdb-enrich.test.ts +++ b/apps/web/__tests__/anilist-tv-tmdb-enrich.test.ts @@ -169,4 +169,44 @@ describe("mergeTmdbEpisodesIntoSeason", () => { expect(merged.episodes[0]?.still_path).toBe("/ep-1.jpg"); expect(merged.episodes[5]?.name).toBe("Chapter 6"); }); + + it("keeps AniList-only trailing episodes beyond the TMDB season count", () => { + const season: SeasonDetails = { + id: 4, + name: "The Final Season", + overview: "", + season_number: 4, + episodes: Array.from({ length: 30 }, (_, index) => ({ + id: index + 1, + name: `Episode ${index + 1}`, + overview: "", + episode_number: index + 1, + air_date: "", + still_path: null, + runtime: 24, + vote_average: 0, + vote_count: 0, + })), + }; + + const tmdbEpisodes: Episode[] = Array.from({ length: 28 }, (_, index) => ({ + id: 100 + index, + name: `TMDB Episode ${index + 1}`, + overview: "", + episode_number: index + 1, + air_date: "", + still_path: null, + runtime: 24, + vote_average: 0, + vote_count: 0, + })); + + const merged = mergeTmdbEpisodesIntoSeason(season, tmdbEpisodes, { + preserveSplitCourAppendix: true, + }); + expect(merged.episodes).toHaveLength(30); + expect(merged.episodes[28]?.episode_number).toBe(29); + expect(merged.episodes[29]?.episode_number).toBe(30); + expect(merged.episodes[28]?.name).toBe("Episode 29"); + }); }); diff --git a/apps/web/__tests__/anime-episode-mapping-route.test.ts b/apps/web/__tests__/anime-episode-mapping-route.test.ts index b6db9d63..750b313c 100644 --- a/apps/web/__tests__/anime-episode-mapping-route.test.ts +++ b/apps/web/__tests__/anime-episode-mapping-route.test.ts @@ -1,45 +1,25 @@ import { describe, expect, it } from "vitest"; -const resolveCoordsTmdbShowId = ( - routeId: string, - defaultAnilistId: number | null, - playbackTmdbTvId: number | null, -): number | null => { - if (!defaultAnilistId) { - const numeric = Number(routeId); - return Number.isInteger(numeric) && numeric > 0 ? numeric : null; - } - - const tmdbShowId = - typeof playbackTmdbTvId === "number" && playbackTmdbTvId > 0 - ? playbackTmdbTvId - : Number(routeId); - - if (!Number.isInteger(tmdbShowId) || tmdbShowId <= 0) { - return null; - } - - if ( - defaultAnilistId > 0 && - tmdbShowId === defaultAnilistId && - playbackTmdbTvId == null - ) { - return null; - } - - return tmdbShowId; -}; +import { resolveAnimeEpisodeMappingTmdbShowId } from "@/lib/anime/resolve-coords-tmdb-show-id"; describe("anime episode mapping route ids", () => { it("uses mapped TMDB id for /anime/16498 instead of the route id", () => { - expect(resolveCoordsTmdbShowId("16498", 16498, 1429)).toBe(1429); + expect(resolveAnimeEpisodeMappingTmdbShowId("16498", 16498, 1429)).toBe( + 1429, + ); }); - it("stays pending when anime route id would leak into TMDB coords", () => { - expect(resolveCoordsTmdbShowId("16498", 16498, null)).toBeNull(); + it("returns null for bare AniList routes without a TMDB mapping", () => { + expect( + resolveAnimeEpisodeMappingTmdbShowId("16498", 16498, null), + ).toBeNull(); + expect(resolveAnimeEpisodeMappingTmdbShowId("9936", 9936, null)).toBeNull(); + expect( + resolveAnimeEpisodeMappingTmdbShowId("anilist-16498", 16498, null), + ).toBeNull(); }); it("uses the numeric route id on TMDB tv pages", () => { - expect(resolveCoordsTmdbShowId("1429", null, null)).toBe(1429); + expect(resolveAnimeEpisodeMappingTmdbShowId("1429", null, null)).toBe(1429); }); }); diff --git a/apps/web/__tests__/anime-playback-chain.test.ts b/apps/web/__tests__/anime-playback-chain.test.ts index c7c42fd1..00d86786 100644 --- a/apps/web/__tests__/anime-playback-chain.test.ts +++ b/apps/web/__tests__/anime-playback-chain.test.ts @@ -134,7 +134,6 @@ describe("anime-playback-chain", () => { "kickassanime", "anizone", "allmanga", - "animestream", "animegg", "kyren", "justanime", @@ -242,7 +241,6 @@ describe("anime-playback-chain", () => { "anizone", "allmanga", "animeonsen", - "animestream", "kyren", "justanime", ]); diff --git a/apps/web/__tests__/anime-playback-mapping.test.ts b/apps/web/__tests__/anime-playback-mapping.test.ts index 23cf9414..fc7a79e0 100644 --- a/apps/web/__tests__/anime-playback-mapping.test.ts +++ b/apps/web/__tests__/anime-playback-mapping.test.ts @@ -85,6 +85,77 @@ describe("anime playback mapping", () => { relativeEpisode: 1, segmentStart: 13, }); + expect(resolveFribbPlaybackCoords(rows, 61374, 3, 12)).toEqual({ + anilistId: 100240, + relativeEpisode: 12, + segmentStart: 1, + }); + }); + + it("maps Attack on Titan S4E28 to Final Season Part 2, not the Part 3 special", () => { + const aotSeason4 = [ + { + anilist_id: 110277, + themoviedb_id: { tv: 1429 }, + season: { tmdb: 4 }, + }, + { + anilist_id: 131681, + themoviedb_id: { tv: 1429 }, + season: { tmdb: 4 }, + episode_offset: { tmdb: 16 }, + }, + { + anilist_id: 146984, + themoviedb_id: { tv: 1429 }, + season: { tmdb: 4 }, + episode_offset: { tmdb: 28 }, + }, + ]; + + expect(resolveFribbPlaybackCoords(aotSeason4, 1429, 4, 16)).toEqual({ + anilistId: 110277, + relativeEpisode: 16, + segmentStart: 1, + }); + expect(resolveFribbPlaybackCoords(aotSeason4, 1429, 4, 17)).toEqual({ + anilistId: 131681, + relativeEpisode: 1, + segmentStart: 17, + }); + expect(resolveFribbPlaybackCoords(aotSeason4, 1429, 4, 28)).toEqual({ + anilistId: 131681, + relativeEpisode: 12, + segmentStart: 17, + }); + expect(resolveFribbPlaybackCoords(aotSeason4, 1429, 4, 29)).toEqual({ + anilistId: 146984, + relativeEpisode: 1, + segmentStart: 29, + }); + expect(resolveFribbPlaybackCoords(aotSeason4, 1429, 4, 30)).toEqual({ + anilistId: 146984, + relativeEpisode: 2, + segmentStart: 29, + }); + }); + + it("maps AniBridge Attack on Titan S4E28 to Final Season Part 2 episode 12", () => { + const mappings = { + "tmdb_show:1429:s4": { + "anilist:110277": { "1-16": "1-16" }, + "anilist:131681": { "17-28": "1-12" }, + }, + }; + + expect(resolveAniBridgePlaybackCoords(mappings, 1429, 4, 16)).toEqual({ + anilistId: 110277, + relativeEpisode: 16, + }); + expect(resolveAniBridgePlaybackCoords(mappings, 1429, 4, 28)).toEqual({ + anilistId: 131681, + relativeEpisode: 12, + }); }); it("treats missing or zero Fribb season metadata as TMDB season 1", () => { diff --git a/apps/web/__tests__/anime-playback-policy.test.ts b/apps/web/__tests__/anime-playback-policy.test.ts new file mode 100644 index 00000000..2b270654 --- /dev/null +++ b/apps/web/__tests__/anime-playback-policy.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { + isAnimeFirstPlayback, + shouldAllowTmdbOnlyScrape, + shouldHoldAnimePlaybackStart, +} from "@/lib/anime/anime-playback-policy"; + +const holdBase = { + animeFirst: true, + hasSelectedEpisode: true, + animeCoordsReady: false, + animeCoordsStatus: "pending" as const, +}; + +describe("anime-playback-policy", () => { + it("treats anime catalog and AniList-backed pages as anime-first", () => { + expect(isAnimeFirstPlayback("anime", null)).toBe(true); + expect(isAnimeFirstPlayback("tvshows", 21)).toBe(true); + expect(isAnimeFirstPlayback("tvshows", null)).toBe(false); + }); + + it("holds anime playback until coords are ready", () => { + expect(shouldHoldAnimePlaybackStart(holdBase)).toBe(true); + expect( + shouldHoldAnimePlaybackStart({ + ...holdBase, + animeCoordsStatus: "idle", + }), + ).toBe(true); + expect( + shouldHoldAnimePlaybackStart({ + ...holdBase, + animeCoordsReady: true, + }), + ).toBe(false); + expect( + shouldHoldAnimePlaybackStart({ + ...holdBase, + animeCoordsStatus: "miss", + }), + ).toBe(false); + }); + + it("blocks TMDB-only scrape on anime-first pages until a miss", () => { + expect(shouldAllowTmdbOnlyScrape(holdBase)).toBe(false); + expect( + shouldAllowTmdbOnlyScrape({ + ...holdBase, + animeCoordsStatus: "miss", + }), + ).toBe(true); + expect( + shouldAllowTmdbOnlyScrape({ + animeFirst: false, + hasSelectedEpisode: true, + animeCoordsReady: false, + animeCoordsStatus: "pending", + }), + ).toBe(true); + }); +}); diff --git a/apps/web/__tests__/anime-scrape-helpers.test.ts b/apps/web/__tests__/anime-scrape-helpers.test.ts index de5f072e..859ddf58 100644 --- a/apps/web/__tests__/anime-scrape-helpers.test.ts +++ b/apps/web/__tests__/anime-scrape-helpers.test.ts @@ -123,6 +123,13 @@ describe("anime scrape helpers", () => { expect(selectAllmangaShow(shows, ["ONE PIECE"], 21)?._id).toBe("one-piece"); expect(selectAllmangaShow(shows, ["ONE PIECE"])?._id).toBe("one-piece"); + expect( + selectAllmangaShow( + [{ _id: "final-season-p2", name: "AOT FS P2", aniListId: 131681 }], + ["Attack on Titan: The Final Season Part 2"], + 131681, + )?._id, + ).toBe("final-season-p2"); }); it("matches AnimeGG search labels that embed episodes metadata", () => { diff --git a/apps/web/__tests__/anime-segment-sync.test.ts b/apps/web/__tests__/anime-segment-sync.test.ts new file mode 100644 index 00000000..bbca7b14 --- /dev/null +++ b/apps/web/__tests__/anime-segment-sync.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { applySegmentAnimeEpisodeMapping } from "@/lib/anime/apply-segment-episode-mapping"; +import { shouldAwaitPlaybackTmdbTvIdForMapping } from "@/lib/anime/resolve-coords-tmdb-show-id"; +import { useEpisodeStore } from "@/lib/stores/episode-store"; + +const sampleEpisode = { + id: 1, + episode_number: 5, + name: "Episode 5", + overview: "", + still_path: null, + air_date: "", + vote_average: 0, + runtime: null, +}; + +describe("shouldAwaitPlaybackTmdbTvIdForMapping", () => { + it("waits on AniList routes until playbackTmdbTvId hydrates", () => { + expect(shouldAwaitPlaybackTmdbTvIdForMapping("anilist-21", 21, null)).toBe( + true, + ); + }); + + it("does not wait once playbackTmdbTvId is available", () => { + expect(shouldAwaitPlaybackTmdbTvIdForMapping("anilist-21", 21, 37854)).toBe( + false, + ); + }); + + it("does not wait on TMDB tv routes without an AniList default", () => { + expect(shouldAwaitPlaybackTmdbTvIdForMapping("1429", null, null)).toBe( + false, + ); + }); +}); + +describe("applySegmentAnimeEpisodeMapping", () => { + beforeEach(() => { + useEpisodeStore.getState().clearSelectedEpisode(); + useEpisodeStore.getState().setDefaultAnilistId(21); + }); + + it("applies high-confidence coords when the season map arrives", () => { + useEpisodeStore + .getState() + .setSelectedEpisode( + sampleEpisode, + "anilist-21", + 1, + undefined, + true, + undefined, + { + confidence: "low", + isAdult: false, + animeSeasonNumber: 1, + }, + ); + + const applied = applySegmentAnimeEpisodeMapping( + [ + { + anilistMediaId: 21, + startEpisode: 1, + endEpisode: 12, + }, + ], + sampleEpisode.episode_number, + { + tvShowId: "anilist-21", + seasonNumber: 1, + episodeNumber: sampleEpisode.episode_number, + isAdult: false, + }, + ); + + expect(applied).toBe(true); + const state = useEpisodeStore.getState(); + expect(state.animeCoordsStatus).toBe("resolved"); + expect(state.mappingConfidence).toBe("high"); + expect(state.isAnimeEpisode).toBe(true); + expect(state.anilistId).toBe(21); + expect(state.relativeEpisodeNumber).toBe(5); + }); +}); diff --git a/apps/web/__tests__/anime-tv-detail-route.test.ts b/apps/web/__tests__/anime-tv-detail-route.test.ts index 8c712642..36945624 100644 --- a/apps/web/__tests__/anime-tv-detail-route.test.ts +++ b/apps/web/__tests__/anime-tv-detail-route.test.ts @@ -69,15 +69,15 @@ describe("resolveAnilistTvDetailRedirects", () => { ); }); - it("normalizes legacy anilist-* slugs to bare /anime/{id}", async () => { - await expect( - resolveAnilistTvDetailRedirects("anilist-154587"), - ).rejects.toThrow("REDIRECT:/anime/154587?"); + it("redirects legacy bare /anime/{id} URLs to prefixed slugs", async () => { + await expect(resolveAnilistTvDetailRedirects("154587")).rejects.toThrow( + "REDIRECT:/anime/anilist-154587?", + ); }); - it("does not redirect bare anime ids when already canonical", async () => { + it("keeps prefixed anime ids when already canonical", async () => { await expect( - resolveAnilistTvDetailRedirects("154587"), + resolveAnilistTvDetailRedirects("anilist-154587"), ).resolves.toBeUndefined(); }); @@ -88,7 +88,7 @@ describe("resolveAnilistTvDetailRedirects", () => { }); await expect(resolveAnilistTvDetailRedirects("200")).rejects.toThrow( - "REDIRECT:/anime/100?", + "REDIRECT:/anime/anilist-100?", ); }); @@ -97,7 +97,7 @@ describe("resolveAnilistTvDetailRedirects", () => { mockResolveTmdbToAnilistFromMappings.mockResolvedValue(153567); await expect(resolveAnilistTvDetailRedirects("207840")).rejects.toThrow( - "REDIRECT:/anime/153567?", + "REDIRECT:/anime/anilist-153567?", ); }); }); diff --git a/apps/web/__tests__/animeonsen-match.test.ts b/apps/web/__tests__/animeonsen-match.test.ts index 4bfd0d53..74108b5f 100644 --- a/apps/web/__tests__/animeonsen-match.test.ts +++ b/apps/web/__tests__/animeonsen-match.test.ts @@ -101,9 +101,9 @@ describe("buildAnimeSearchContext", () => { ]); expect(context.searchQueries).toEqual([ - "Attack on Titan", "Attack on Titan Season 3 Part 2", "Shingeki no Kyojin Season 3 Part 2", + "Attack on Titan", ]); expect(context.matchTitles).toEqual([ "Attack on Titan Season 3 Part 2", diff --git a/apps/web/__tests__/app-settings-sync.test.tsx b/apps/web/__tests__/app-settings-sync.test.tsx index 59db7682..d6d2630a 100644 --- a/apps/web/__tests__/app-settings-sync.test.tsx +++ b/apps/web/__tests__/app-settings-sync.test.tsx @@ -29,7 +29,10 @@ describe("AppSettingsSync", () => { await useAppSettingsStore.persist.rehydrate(); await usePlaybackModeStore.persist.rehydrate(); useAppSettingsStore.setState({ noAdsMode: false }); - usePlaybackModeStore.setState({ selectedServer: embedServer }); + usePlaybackModeStore.setState({ + selectedServer: embedServer, + hasUserSelectedPlaybackServer: false, + }); }); it("seeds scrape when no-ads default flag is enabled after flags load", async () => { @@ -76,7 +79,7 @@ describe("AppSettingsSync", () => { }); }); - it("does not override a persisted embed server for default proxy playback alone", async () => { + it("seeds scrape when persisted embed is only the store default", async () => { localStorage.setItem( "playback-mode-storage", JSON.stringify({ @@ -91,6 +94,31 @@ describe("AppSettingsSync", () => { defaultProxyPlayback: true, }); + await waitFor(() => { + expect(usePlaybackModeStore.getState().selectedServer.id).toBe( + scrapeServer.id, + ); + }); + }); + + it("does not override a user-chosen embed server for default proxy playback", async () => { + localStorage.setItem( + "playback-mode-storage", + JSON.stringify({ + state: { + selectedServerId: embedServer.id, + hasUserSelectedPlaybackServer: true, + }, + version: 0, + }), + ); + await usePlaybackModeStore.persist.rehydrate(); + + renderSync({ + ...getDefaultSiteFlags(), + defaultProxyPlayback: true, + }); + await waitFor(() => { expect(usePlaybackModeStore.getState().selectedServer.id).toBe( embedServer.id, diff --git a/apps/web/__tests__/bingr-provider.test.ts b/apps/web/__tests__/bingr-provider.test.ts index 8d0d169f..08b93ce7 100644 --- a/apps/web/__tests__/bingr-provider.test.ts +++ b/apps/web/__tests__/bingr-provider.test.ts @@ -101,15 +101,22 @@ describe("parseBingrAbrQualities", () => { describe("raceWithTimeout", () => { it("returns the fallback and timedOut when work never settles", async () => { - const hung = new Promise(() => undefined); const started = Date.now(); - const result = await raceWithTimeout(hung, 40, "timeout"); + const result = await raceWithTimeout( + () => new Promise(() => undefined), + 40, + "timeout", + ); expect(result).toEqual({ value: "timeout", timedOut: true }); expect(Date.now() - started).toBeLessThan(200); }); it("returns the value when work finishes first", async () => { - const result = await raceWithTimeout(Promise.resolve("ok"), 200, "timeout"); + const result = await raceWithTimeout( + () => Promise.resolve("ok"), + 200, + "timeout", + ); expect(result).toEqual({ value: "ok", timedOut: false }); }); diff --git a/apps/web/__tests__/calluspirates-fetch.test.ts b/apps/web/__tests__/calluspirates-fetch.test.ts index 898fd5df..1b074373 100644 --- a/apps/web/__tests__/calluspirates-fetch.test.ts +++ b/apps/web/__tests__/calluspirates-fetch.test.ts @@ -35,5 +35,6 @@ describe("fetchCalluspirates", () => { expect(fetchMock).toHaveBeenCalledOnce(); const init = fetchMock.mock.calls[0]?.[1]; expect(init?.signal?.aborted).toBe(true); + expect(init?.cache).toBe("no-store"); }); }); diff --git a/apps/web/__tests__/cast-person-href.test.ts b/apps/web/__tests__/cast-person-href.test.ts new file mode 100644 index 00000000..f95b0946 --- /dev/null +++ b/apps/web/__tests__/cast-person-href.test.ts @@ -0,0 +1,33 @@ +import { resolveCastPersonHref } from "@/lib/cast-person-href"; +import { describe, expect, it } from "vitest"; + +describe("resolveCastPersonHref", () => { + it("keeps TMDB people on /person", () => { + expect( + resolveCastPersonHref({ + id: 31, + profile_path: "/abc.jpg", + }), + ).toBe("/person/31"); + }); + + it("does not send AniList character ids to TMDB person pages", () => { + expect( + resolveCastPersonHref({ + id: 281763, + profile_path: + "https://s4.anilist.co/file/anilistcdn/character/large/b281763.jpg", + }), + ).toBeNull(); + }); + + it("honors an explicit null href from AniList cast mapping", () => { + expect( + resolveCastPersonHref({ + id: 281763, + href: null, + profile_path: "/abc.jpg", + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/__tests__/ensure-env.test.ts b/apps/web/__tests__/ensure-env.test.ts new file mode 100644 index 00000000..09d00ff3 --- /dev/null +++ b/apps/web/__tests__/ensure-env.test.ts @@ -0,0 +1,100 @@ +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; + +const repoRoot = join(import.meta.dirname, "..", "..", ".."); +const scriptPath = join(repoRoot, "apps/web/scripts/ensure-env.mjs"); + +type Fixture = { + root: string; + webDir: string; + cleanup: () => void; +}; + +const createFixture = (): Fixture => { + const root = mkdtempSync(join(tmpdir(), "ensure-env-")); + const webDir = join(root, "apps", "web"); + mkdirSync(webDir, { recursive: true }); + return { + root, + webDir, + cleanup: () => { + rmSync(root, { recursive: true, force: true }); + }, + }; +}; + +const runEnsureEnv = ( + webDir: string, + env: Record = {}, +) => + spawnSync("node", [scriptPath], { + cwd: webDir, + env: { ...process.env, ...env }, + encoding: "utf8", + }); + +describe("ensure-env", () => { + const fixtures: Fixture[] = []; + + afterEach(() => { + while (fixtures.length > 0) { + fixtures.pop()?.cleanup(); + } + }); + + it("links repo-root env files into apps/web for local dev", () => { + const fixture = createFixture(); + fixtures.push(fixture); + + writeFileSync(join(fixture.root, ".env"), "TMDB_API_KEY=test\n"); + writeFileSync(join(fixture.root, ".env.local"), "AUTH_SECRET=test\n"); + + const result = runEnsureEnv(fixture.webDir); + expect(result.status).toBe(0); + + const envLink = join(fixture.webDir, ".env"); + const envLocalLink = join(fixture.webDir, ".env.local"); + expect(lstatSync(envLink).isSymbolicLink()).toBe(true); + expect(lstatSync(envLocalLink).isSymbolicLink()).toBe(true); + expect(readlinkSync(envLink)).toBe("../../.env"); + expect(readlinkSync(envLocalLink)).toBe("../../.env.local"); + expect(existsSync(envLink)).toBe(true); + expect(existsSync(envLocalLink)).toBe(true); + }); + + it("removes broken env symlinks instead of leaving next build to fail", () => { + const fixture = createFixture(); + fixtures.push(fixture); + + symlinkSync("../../.env", join(fixture.webDir, ".env")); + + const result = runEnsureEnv(fixture.webDir); + expect(result.status).toBe(0); + expect(existsSync(join(fixture.webDir, ".env"))).toBe(false); + }); + + it("skips linking when SKIP_ENSURE_ENV is set (container builds)", () => { + const fixture = createFixture(); + fixtures.push(fixture); + + writeFileSync(join(fixture.root, ".env"), "TMDB_API_KEY=test\n"); + symlinkSync("../../missing.env", join(fixture.webDir, ".env")); + + const result = runEnsureEnv(fixture.webDir, { SKIP_ENSURE_ENV: "1" }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("skipped (SKIP_ENSURE_ENV)"); + expect(existsSync(join(fixture.webDir, ".env"))).toBe(false); + }); +}); diff --git a/apps/web/__tests__/episode-playback-source.test.ts b/apps/web/__tests__/episode-playback-source.test.ts new file mode 100644 index 00000000..a35360cc --- /dev/null +++ b/apps/web/__tests__/episode-playback-source.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { + readEpisodeAnilistPlayback, + resolveEpisodeAnimeSelection, +} from "@/lib/anime/episode-playback-source"; +import type { Episode } from "@/lib/domain/typings"; + +const buildEpisode = ( + overrides: Partial & Pick, +): Episode => ({ + id: overrides.id, + episode_number: overrides.episode_number, + name: overrides.name ?? `Episode ${overrides.episode_number}`, + overview: "", + air_date: "", + still_path: null, + runtime: 24, + sourceAnilistId: overrides.sourceAnilistId, + sourceEpisodeNumber: overrides.sourceEpisodeNumber, +}); + +describe("episode playback source", () => { + it("reads embedded AniList playback coords from merged episodes", () => { + expect( + readEpisodeAnilistPlayback( + buildEpisode({ + id: 1469840029, + episode_number: 29, + sourceAnilistId: 146984, + sourceEpisodeNumber: 1, + }), + ), + ).toEqual({ + anilistId: 146984, + episodeNumber: 1, + }); + }); + + it("resolves high-confidence anime selection for Final Chapter specials", () => { + expect( + resolveEpisodeAnimeSelection( + buildEpisode({ + id: 1623140030, + episode_number: 30, + sourceAnilistId: 162314, + sourceEpisodeNumber: 1, + }), + { animeSeasonNumber: 4 }, + ), + ).toEqual({ + animeInfo: { + anilistId: 162314, + startEpisode: 30, + endEpisode: 30, + }, + mapping: { + confidence: "high", + isAdult: false, + relativeEpisodeNumber: 1, + animeSeasonNumber: 4, + }, + }); + }); + + it("keeps split-cour relative episode numbers inside a merged TMDB season", () => { + expect( + resolveEpisodeAnimeSelection( + buildEpisode({ + id: 1316810017, + episode_number: 17, + sourceAnilistId: 131681, + sourceEpisodeNumber: 1, + }), + { animeSeasonNumber: 4 }, + ), + ).toMatchObject({ + animeInfo: { + anilistId: 131681, + startEpisode: 17, + endEpisode: 17, + }, + mapping: { + confidence: "high", + relativeEpisodeNumber: 1, + }, + }); + }); +}); diff --git a/apps/web/__tests__/feature-flags-fetch-cache.test.ts b/apps/web/__tests__/feature-flags-fetch-cache.test.ts index 6427a922..5a196b5e 100644 --- a/apps/web/__tests__/feature-flags-fetch-cache.test.ts +++ b/apps/web/__tests__/feature-flags-fetch-cache.test.ts @@ -3,13 +3,13 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; describe("feature flags client fetch", () => { - it("does not force no-store on the flags request", () => { + it("bypasses the browser cache on the flags request", () => { const source = readFileSync( resolve("components/providers/feature-flags-provider.tsx"), "utf8", ); expect(source).toContain('fetch("/api/site/flags"'); - expect(source).not.toContain('cache: "no-store"'); + expect(source).toContain('cache: "no-store"'); }); }); diff --git a/apps/web/__tests__/flags-sync.test.ts b/apps/web/__tests__/flags-sync.test.ts index 99724e16..5311051d 100644 --- a/apps/web/__tests__/flags-sync.test.ts +++ b/apps/web/__tests__/flags-sync.test.ts @@ -3,14 +3,9 @@ import { describe, expect, it, vi } from "vitest"; import { broadcastSiteFlagsUpdated, FLAGS_UPDATED_BROADCAST_CHANNEL, - SITE_FLAGS_REVALIDATE_TAG, } from "@/lib/flags/flags-sync"; describe("flags-sync", () => { - it("exports the revalidate tag used by site flags cache", () => { - expect(SITE_FLAGS_REVALIDATE_TAG).toBe("site-flags-public"); - }); - it("broadcasts a save event to other tabs", () => { const postMessage = vi.fn(); const close = vi.fn(); diff --git a/apps/web/__tests__/flipt-client.test.ts b/apps/web/__tests__/flipt-client.test.ts index 1d533333..b4be8232 100644 --- a/apps/web/__tests__/flipt-client.test.ts +++ b/apps/web/__tests__/flipt-client.test.ts @@ -7,6 +7,8 @@ import { import { ensureFlagsSeeded, invalidateFlagCache, + readAnnouncementBannerConfig, + readFliptMetadataValue, toFliptStorageKey, writeAdminFlagState, } from "@/lib/flags/flipt-client"; @@ -14,6 +16,27 @@ import { DEFAULT_ANNOUNCEMENT_BANNER_CONFIG } from "@/lib/flags/announcement-ban const originalFetch = globalThis.fetch; +const notFoundResponse = () => new Response("not found", { status: 404 }); + +const mockFlagWrites = ( + listBody: Response, + mutateBody: Response, +): ReturnType> => + vi.fn().mockImplementation(async (input, init) => { + const method = init?.method ?? "GET"; + if (method === "PUT" || method === "POST") { + return mutateBody; + } + const url = String(input); + if ( + url.includes("/resources/flipt.core.Flag/") && + !url.endsWith("/resources/flipt.core.Flag") + ) { + return notFoundResponse(); + } + return listBody; + }); + const resource = (def: (typeof ALL_FLAG_DEFINITIONS)[number]) => ({ key: toFliptStorageKey(def.key), payload: { @@ -85,14 +108,10 @@ describe("Flipt v2 client", () => { } : current; }); - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ resources, revision: "revision-1" }), - ) - .mockResolvedValueOnce( - Response.json({ revision: "revision-2" }, { status: 200 }), - ); + const fetchMock = mockFlagWrites( + Response.json({ resources, revision: "revision-1" }), + Response.json({ revision: "revision-2" }, { status: 200 }), + ); globalThis.fetch = fetchMock; await writeAdminFlagState({ @@ -100,8 +119,11 @@ describe("Flipt v2 client", () => { [changed.key]: !changed.defaultValue, }); - expect(fetchMock).toHaveBeenCalledTimes(2); - const [url, init] = fetchMock.mock.calls[1] ?? []; + const putCall = fetchMock.mock.calls.find( + ([, init]) => init?.method === "PUT", + ); + expect(putCall).toBeDefined(); + const [url, init] = putCall ?? []; expect(url).toContain( "/api/v2/environments/default/namespaces/default/resources", ); @@ -126,14 +148,10 @@ describe("Flipt v2 client", () => { it("stores announcement presentation in flag metadata", async () => { const resources = ALL_FLAG_DEFINITIONS.map(resource); - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ resources, revision: "revision-1" }), - ) - .mockResolvedValueOnce( - Response.json({ revision: "revision-2" }, { status: 200 }), - ); + const fetchMock = mockFlagWrites( + Response.json({ resources, revision: "revision-1" }), + Response.json({ revision: "revision-2" }, { status: 200 }), + ); globalThis.fetch = fetchMock; const announcementBanner = { @@ -142,17 +160,65 @@ describe("Flipt v2 client", () => { }; await writeAdminFlagState(buildDefaultAdminFlagState(), announcementBanner); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect( - JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)), - ).toMatchObject({ + const putCall = fetchMock.mock.calls.find( + ([, init]) => init?.method === "PUT", + ); + expect(JSON.parse(String(putCall?.[1]?.body))).toMatchObject({ key: toFliptStorageKey("global.announcement_banner"), payload: { - metadata: { announcementBanner }, + metadata: { announcementBanner: JSON.stringify(announcementBanner) }, }, }); }); + it("reads announcement presentation from GetResource metadata", async () => { + const announcementBanner = { + ...DEFAULT_ANNOUNCEMENT_BANNER_CONFIG, + title: "Service update", + }; + const fetchMock = vi.fn().mockResolvedValueOnce( + Response.json({ + resource: { + key: toFliptStorageKey("global.announcement_banner"), + payload: { + "@type": "flipt.core.Flag", + key: toFliptStorageKey("global.announcement_banner"), + enabled: true, + type: "BOOLEAN_FLAG_TYPE", + name: "Announcement", + description: "", + metadata: { + announcementBanner: JSON.stringify(announcementBanner), + }, + }, + }, + }), + ); + globalThis.fetch = fetchMock; + + await expect(readAnnouncementBannerConfig()).resolves.toEqual( + announcementBanner, + ); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain( + `/resources/flipt.core.Flag/${toFliptStorageKey("global.announcement_banner")}`, + ); + }); + + it("parses string or object flag metadata values", () => { + expect( + readFliptMetadataValue( + { announcementBanner: '{"title":"Hi"}' }, + "announcementBanner", + ), + ).toEqual({ title: "Hi" }); + expect( + readFliptMetadataValue( + { announcementBanner: { title: "Hi" } }, + "announcementBanner", + ), + ).toEqual({ title: "Hi" }); + }); + it("does not attempt writes when the initial read fails", async () => { const fetchMock = vi .fn() diff --git a/apps/web/__tests__/idb-persister.test.ts b/apps/web/__tests__/idb-persister.test.ts index faf9d1b4..c70da491 100644 --- a/apps/web/__tests__/idb-persister.test.ts +++ b/apps/web/__tests__/idb-persister.test.ts @@ -1,4 +1,5 @@ import { createIDBPersister } from "@/lib/idb-persister"; +import { suppressConsole } from "@/test/suppress-console"; import type { PersistedClient } from "@tanstack/react-query-persist-client"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -102,9 +103,11 @@ describe("createIDBPersister", () => { }, }; + const restoreConsole = suppressConsole("warn"); await expect(persister.persistClient(mockClient)).rejects.toThrow( "Storage quota exceeded", ); + restoreConsole(); }); test("handles get errors gracefully", async () => { @@ -112,9 +115,11 @@ describe("createIDBPersister", () => { const persister = createIDBPersister(); + const restoreConsole = suppressConsole("warn"); await expect(persister.restoreClient()).rejects.toThrow( "IndexedDB unavailable", ); + restoreConsole(); }); test("handles del errors gracefully", async () => { @@ -122,6 +127,8 @@ describe("createIDBPersister", () => { const persister = createIDBPersister(); + const restoreConsole = suppressConsole("warn"); await expect(persister.removeClient()).rejects.toThrow("Delete failed"); + restoreConsole(); }); }); diff --git a/apps/web/__tests__/mal-anime-detail-route.test.ts b/apps/web/__tests__/mal-anime-detail-route.test.ts index e2899408..8978bc96 100644 --- a/apps/web/__tests__/mal-anime-detail-route.test.ts +++ b/apps/web/__tests__/mal-anime-detail-route.test.ts @@ -48,7 +48,7 @@ describe("resolveMalAnimeDetailRedirects", () => { }); await expect(resolveMalAnimeDetailRedirects("mal-16498")).rejects.toThrow( - "REDIRECT:/anime/16498?", + "REDIRECT:/anime/anilist-16498?", ); }); @@ -69,7 +69,7 @@ describe("resolveMalAnimeDetailRedirects", () => { mockResolveMalToAnilist.mockResolvedValue(188); await expect(resolveMalAnimeDetailRedirects("mal-999")).rejects.toThrow( - "REDIRECT:/anime/188", + "REDIRECT:/anime/anilist-188", ); }); diff --git a/apps/web/__tests__/media-peek-target.test.ts b/apps/web/__tests__/media-peek-target.test.ts index 1ed86e3b..faf120a0 100644 --- a/apps/web/__tests__/media-peek-target.test.ts +++ b/apps/web/__tests__/media-peek-target.test.ts @@ -19,11 +19,11 @@ describe("resolveMediaPeekTarget", () => { it("marks anime catalog routes", () => { const target = resolveMediaPeekTarget( { id: 21, media_type: "tv", name: "One Piece" }, - "/anime/21", + "/anime/anilist-21", ); expect(target).toMatchObject({ mediaType: "tv", - id: "21", + id: "anilist-21", catalog: "anime", }); }); diff --git a/apps/web/__tests__/merge-search-media.test.ts b/apps/web/__tests__/merge-search-media.test.ts index e0e733a2..a6d1df4c 100644 --- a/apps/web/__tests__/merge-search-media.test.ts +++ b/apps/web/__tests__/merge-search-media.test.ts @@ -56,7 +56,7 @@ describe("mergeSearchMediaResults", () => { ); expect(merged).toHaveLength(1); - expect(merged[0]?.href).toBe("/anime/21222"); + expect(merged[0]?.href).toBe("/anime/anilist-21222"); expect(merged[0]?.title).toBe("Mankitsu Happening"); }); @@ -84,13 +84,15 @@ describe("mergeSearchMediaResults", () => { [ anilistMediaItem(21222, { id: 12345, - href: "/anime/21222", + href: "/anime/anilist-21222", }), ], ); expect(merged).toHaveLength(2); - expect(merged.some((card) => card.href === "/anime/21222")).toBe(true); + expect(merged.some((card) => card.href === "/anime/anilist-21222")).toBe( + true, + ); }); it("ranks mainstream TMDB matches above adult anime for ambiguous titles", () => { diff --git a/apps/web/__tests__/middleware.test.ts b/apps/web/__tests__/middleware.test.ts index 6c25e595..8483649c 100644 --- a/apps/web/__tests__/middleware.test.ts +++ b/apps/web/__tests__/middleware.test.ts @@ -13,7 +13,12 @@ vi.mock("@/lib/flags/flipt-client", () => ({ })); const getRedirectLocation = async (url: string) => { - const response = await middleware(new NextRequest(url)); + const parsed = new URL(url); + const response = await middleware( + new NextRequest(url, { + headers: { host: parsed.host }, + }), + ); return response.headers.get("location"); }; @@ -87,4 +92,28 @@ describe("middleware", () => { expect(raced).toEqual({ kind: "done", status: 200 }); }); + + test("redirects ffs host root to /ffs", async () => { + expect(await getRedirectLocation("http://ffs.localhost:3000/")).toBe( + "http://ffs.localhost:3000/ffs", + ); + }); + + test("allows shared auth api routes on ffs host", async () => { + expect( + await getRedirectLocation("http://ffs.localhost:3000/api/auth/session"), + ).toBeNull(); + }); + + test("allows shared site flag api routes on ffs host", async () => { + expect( + await getRedirectLocation("http://ffs.localhost:3000/api/site/flags"), + ).toBeNull(); + }); + + test("redirects non-ffs app routes on ffs host", async () => { + expect( + await getRedirectLocation("http://ffs.localhost:3000/movies/123"), + ).toBe("http://ffs.localhost:3000/ffs"); + }); }); diff --git a/apps/web/__tests__/movi-subtitle-preference.test.ts b/apps/web/__tests__/movi-subtitle-preference.test.ts new file mode 100644 index 00000000..df9e937d --- /dev/null +++ b/apps/web/__tests__/movi-subtitle-preference.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { + applyMoviSubtitlePreference, + resolvePreferredSubtitleLang, +} from "@/lib/playback/movi-subtitle-preference"; +import { TRACK_PREFERENCES_STORAGE_KEY } from "@/lib/playback/track-preferences-storage"; +import type { MoviPlayerElement } from "@/lib/player/player-element"; + +const scopeKey = "movie:42"; + +describe("resolvePreferredSubtitleLang", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("uses saved subtitle preference when present", () => { + window.localStorage.setItem( + TRACK_PREFERENCES_STORAGE_KEY, + JSON.stringify({ + [scopeKey]: { + subtitleLang: "spanish", + audioLang: null, + updatedAt: 1, + }, + }), + ); + + expect( + resolvePreferredSubtitleLang(scopeKey, { + preferEnglishSubtitles: true, + }), + ).toBe("spanish"); + }); + + it("falls back to english when the setting is enabled", () => { + expect( + resolvePreferredSubtitleLang(scopeKey, { + preferEnglishSubtitles: true, + }), + ).toBe("english"); + }); + + it("prefers english subs for japanese audio preference", () => { + expect( + resolvePreferredSubtitleLang(scopeKey, { + preferredAudioLang: "jpn", + }), + ).toBe("english"); + }); +}); + +describe("applyMoviSubtitlePreference", () => { + it("selects external scrape subtitles by stable id", async () => { + const selectExternalSubtitle = vi.fn(async () => true); + const selectSubtitleTrack = vi.fn(async () => true); + + const player = { + getSubtitleLangs: () => [ + { + id: "scrape-english-abc", + lang: "English", + label: "English · Provider", + active: false, + }, + ], + getSubtitleTracks: () => [], + selectExternalSubtitle, + selectSubtitleTrack, + } as unknown as MoviPlayerElement; + + await applyMoviSubtitlePreference(player, "english"); + + expect(selectExternalSubtitle).toHaveBeenCalledWith("scrape-english-abc"); + expect(selectSubtitleTrack).not.toHaveBeenCalled(); + }); + + it("selects muxed hls subtitles when no sidecars exist", async () => { + const selectExternalSubtitle = vi.fn(async () => true); + const selectSubtitleTrack = vi.fn(async () => true); + + const player = { + getSubtitleLangs: () => [], + getSubtitleTracks: () => [ + { id: 0, language: "eng", label: "English" }, + { id: 1, language: "jpn", label: "Japanese" }, + ], + selectExternalSubtitle, + selectSubtitleTrack, + } as unknown as MoviPlayerElement; + + await applyMoviSubtitlePreference(player, "english"); + + expect(selectExternalSubtitle).not.toHaveBeenCalled(); + expect(selectSubtitleTrack).toHaveBeenCalledWith(0); + }); + + it("turns subtitles off when preference is off", async () => { + const selectExternalSubtitle = vi.fn(async () => true); + const selectSubtitleTrack = vi.fn(async () => true); + + const player = { + getSubtitleLangs: () => [ + { + id: "scrape-english-abc", + lang: "English", + label: "English", + active: true, + }, + ], + getSubtitleTracks: () => [{ id: 0, language: "eng", label: "English" }], + selectExternalSubtitle, + selectSubtitleTrack, + } as unknown as MoviPlayerElement; + + await applyMoviSubtitlePreference(player, "off"); + + expect(selectExternalSubtitle).toHaveBeenCalledWith(null); + expect(selectSubtitleTrack).toHaveBeenCalledWith(null); + }); +}); diff --git a/apps/web/__tests__/new-anime-providers.live.test.ts b/apps/web/__tests__/new-anime-providers.live.test.ts index ea37bf4e..e87904e7 100644 --- a/apps/web/__tests__/new-anime-providers.live.test.ts +++ b/apps/web/__tests__/new-anime-providers.live.test.ts @@ -28,7 +28,6 @@ describe.skipIf(!runLive)("new anime provider scrapers (live)", () => { }); if (!result.ok) { - console.warn("Kyren live scrape soft-fail:", result.error); return; } expect(result.streamKind).toBe("hls"); diff --git a/apps/web/__tests__/new-tmdb-providers.live.test.ts b/apps/web/__tests__/new-tmdb-providers.live.test.ts index b4a15889..7d622689 100644 --- a/apps/web/__tests__/new-tmdb-providers.live.test.ts +++ b/apps/web/__tests__/new-tmdb-providers.live.test.ts @@ -47,7 +47,6 @@ describe.skipIf(!runLive)("new tmdb providers (live)", () => { it("VidRock scrapes Fight Club", async () => { const result = await scrapeVidrock({ mediaType: "movie", tmdbId: 550 }); if (!result.ok) { - console.warn("VidRock live scrape soft-fail:", result.error); return; } expect(result.streamUrl).toMatch(/\.m3u8(?:[?#]|$)/i); @@ -59,7 +58,6 @@ describe.skipIf(!runLive)("new tmdb providers (live)", () => { tmdbId: 550, }); if (!result.ok) { - console.warn("VidSrc scrapeProvider live soft-fail:", result.error); return; } expect(result.streamUrl).toMatch(/[?&]token=/); @@ -71,7 +69,6 @@ describe.skipIf(!runLive)("new tmdb providers (live)", () => { tmdbId: 550, }); if (!result.ok) { - console.warn("2Embed scrapeProvider live soft-fail:", result.error); return; } expect(result.ok).toBe(true); @@ -83,7 +80,6 @@ describe.skipIf(!runLive)("new tmdb providers (live)", () => { tmdbId: 550, }); if (!result.ok) { - console.warn("VidRock scrapeProvider live soft-fail:", result.error); return; } expect(result.ok).toBe(true); @@ -92,7 +88,6 @@ describe.skipIf(!runLive)("new tmdb providers (live)", () => { it("Bingr scrapes Fight Club", async () => { const result = await scrapeBingr({ mediaType: "movie", tmdbId: 550 }); if (!result.ok) { - console.warn("Bingr live scrape soft-fail:", result.error); return; } expect(result.streamUrl.length).toBeGreaterThan(20); @@ -104,7 +99,6 @@ describe.skipIf(!runLive)("new tmdb providers (live)", () => { tmdbId: 550, }); if (!result.ok) { - console.warn("Bingr scrapeProvider live soft-fail:", result.error); return; } expect(result.ok).toBe(true); diff --git a/apps/web/__tests__/personalization-features.test.ts b/apps/web/__tests__/personalization-features.test.ts index 525be9fc..84216ed1 100644 --- a/apps/web/__tests__/personalization-features.test.ts +++ b/apps/web/__tests__/personalization-features.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import type { EpisodeInfo } from "@/lib/domain/episodes"; import type { WatchlistItem } from "@/lib/domain/watchlist"; import { + buildAnimeUpNextHref, buildUpNextHref, collectUpNextCandidates, isUpNextInboxCandidate, @@ -56,6 +57,12 @@ describe("up next inbox helpers", () => { ); }); + it("builds anime autoplay hrefs under /anime", () => { + expect( + buildAnimeUpNextHref(11757, { seasonNumber: 2, episodeNumber: 1 }, 1, 5), + ).toBe("/anime/anilist-11757?season=2&episode=1&autoplay=true"); + }); + it("only keeps watching tv titles with unwatched aired episodes", () => { const watchlist = [ watchingTvItem(1), diff --git a/apps/web/__tests__/playback-preferences.test.ts b/apps/web/__tests__/playback-preferences.test.ts index cbafa0ab..211394ed 100644 --- a/apps/web/__tests__/playback-preferences.test.ts +++ b/apps/web/__tests__/playback-preferences.test.ts @@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest"; import { hasEnglishSubtitles, matchesAudioPreference, + payloadHasDubAudio, + payloadMatchesAnimeEarlyWin, payloadMatchesPlaybackPreferences, pickScrapeQualityIndexForPreference, + scoreAnimePlaybackRacePayload, scoreAnimeScrapePayload, DEFAULT_PLAYBACK_PREFERENCES, } from "@/lib/playback/playback-preferences"; @@ -18,12 +21,13 @@ describe("playback preferences", () => { expect(matchesAudioPreference("jpn", "sub")).toBe(true); }); - it("requires explicit resolution height for hard quality prefs", () => { + it("does not require target resolution for early playback prefs", () => { expect( payloadMatchesPlaybackPreferences( { qualities: [{ label: "1080p", url: "https://cdn.example/1080.m3u8" }], preferredAudioLang: "jpn", + subtitles: [{ lang: "English", url: "https://cdn.example/en.vtt" }], }, DEFAULT_PLAYBACK_PREFERENCES, ), @@ -34,13 +38,52 @@ describe("playback preferences", () => { { qualities: [{ label: "720p", url: "https://cdn.example/720.m3u8" }], preferredAudioLang: "jpn", + subtitles: [{ lang: "English", url: "https://cdn.example/en.vtt" }], + }, + DEFAULT_PLAYBACK_PREFERENCES, + ), + ).toBe(true); + }); + + it("requires english subs for early playback when that pref is enabled", () => { + expect( + payloadMatchesPlaybackPreferences( + { + qualities: [{ label: "1080p", url: "https://cdn.example/1080.m3u8" }], + preferredAudioLang: "jpn", }, DEFAULT_PLAYBACK_PREFERENCES, ), ).toBe(false); }); - it("favors higher max quality when target is 1080p", () => { + it("favors english subtitles over a higher-resolution stream without subs", () => { + const preferences = { + ...DEFAULT_PLAYBACK_PREFERENCES, + playbackQuality: "1080p" as const, + }; + + const withSubs720 = scoreAnimeScrapePayload( + { + qualities: [{ label: "720p", url: "https://cdn.example/720.m3u8" }], + subtitles: [{ lang: "English", url: "https://cdn.example/en.vtt" }], + preferredAudioLang: "jpn", + }, + preferences, + ); + + const withoutSubs1080 = scoreAnimeScrapePayload( + { + qualities: [{ label: "1080p", url: "https://cdn.example/1080.m3u8" }], + preferredAudioLang: "jpn", + }, + preferences, + ); + + expect(withSubs720).toBeGreaterThan(withoutSubs1080); + }); + + it("still favors higher max quality when subtitle coverage is equal", () => { const preferences = { ...DEFAULT_PLAYBACK_PREFERENCES, playbackQuality: "1080p" as const, @@ -212,4 +255,111 @@ describe("playback preferences", () => { expect(withNativeSubs).toBeGreaterThan(withCatalogOnly); }); + + it("does not early-win justanime english megaplay over switchable dub siblings", () => { + expect( + payloadMatchesAnimeEarlyWin({ + providerId: "justanime", + preferredAudioLang: "eng", + nativeAudioTrackCount: 1, + }), + ).toBe(false); + expect( + payloadMatchesAnimeEarlyWin({ + providerId: "kickassanime", + preferredAudioLang: "jpn", + nativeAudioTrackCount: 2, + }), + ).toBe(true); + expect( + payloadMatchesAnimeEarlyWin({ + providerId: "kyren", + preferredAudioLang: "eng", + nativeAudioTrackCount: 1, + }), + ).toBe(false); + }); + + it("scores kickass multi-audio above justanime megaplay dub", () => { + const preferences = { + ...DEFAULT_PLAYBACK_PREFERENCES, + playbackAudio: "dub" as const, + }; + const justanime = scoreAnimePlaybackRacePayload( + { + providerId: "justanime", + streamKind: "hls", + preferredAudioLang: "eng", + nativeAudioTrackCount: 1, + qualities: [{ label: "1080p", url: "https://cdn.example/1080.m3u8" }], + }, + preferences, + ); + const kickass = scoreAnimePlaybackRacePayload( + { + providerId: "kickassanime", + streamKind: "hls", + preferredAudioLang: "jpn", + nativeAudioTrackCount: 2, + qualities: [{ label: "1080p", url: "https://cdn.example/1080.m3u8" }], + }, + preferences, + ); + + expect(kickass).toBeGreaterThan(justanime); + }); + + it("treats english audio and multi-audio HLS as having dub", () => { + expect( + payloadHasDubAudio({ + preferredAudioLang: "eng", + nativeAudioTrackCount: 1, + }), + ).toBe(true); + expect( + payloadHasDubAudio({ + nativeAudioTrackCount: 2, + streamKind: "hls", + }), + ).toBe(true); + expect( + payloadHasDubAudio({ + audioVersions: [ + { lang: "ja", label: "Japanese", url: "https://cdn.example/ja.mp4" }, + { lang: "en", label: "English", url: "https://cdn.example/en.mp4" }, + ], + }), + ).toBe(true); + expect( + payloadHasDubAudio({ + preferredAudioLang: "jpn", + nativeAudioTrackCount: 1, + }), + ).toBe(false); + }); + + it("scores a dub-capable source above a japanese-only sibling during best-match", () => { + const japaneseOnly = scoreAnimeScrapePayload( + { + streamKind: "hls", + preferredAudioLang: "jpn", + nativeAudioTrackCount: 1, + qualities: [{ label: "1080p", url: "https://cdn.example/1080.m3u8" }], + subtitles: [{ lang: "English", url: "https://cdn.example/en.vtt" }], + }, + DEFAULT_PLAYBACK_PREFERENCES, + ); + + const withDub = scoreAnimeScrapePayload( + { + streamKind: "hls", + preferredAudioLang: "jpn", + nativeAudioTrackCount: 2, + qualities: [{ label: "720p", url: "https://cdn.example/720.m3u8" }], + }, + DEFAULT_PLAYBACK_PREFERENCES, + ); + + expect(withDub).toBeGreaterThan(japaneseOnly); + }); }); diff --git a/apps/web/__tests__/playback-progress.test.ts b/apps/web/__tests__/playback-progress.test.ts index ed6a53bc..22d7d3f3 100644 --- a/apps/web/__tests__/playback-progress.test.ts +++ b/apps/web/__tests__/playback-progress.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { movieWatchButtonLabel } from "@/lib/playback/movie-watch-label"; import { clampPlaybackProgress, + getLatestTvPlaybackCoords, getPlaybackProgress, progressStorageKey, resolveResumeTime, @@ -111,3 +112,26 @@ describe("movieWatchButtonLabel", () => { expect(movieWatchButtonLabel(424_243)).toBe("Play"); }); }); + +describe("last tv episode memory", () => { + it("remembers the latest tv episode when progress is saved", () => { + window.localStorage.removeItem(PLAYBACK_PROGRESS_STORAGE_KEY); + + setPlaybackProgress( + { + mediaType: "tv", + contentId: 1399, + seasonNumber: 2, + episodeNumber: 4, + }, + { watched: 400, duration: 3600 }, + ); + + expect(getLatestTvPlaybackCoords(1399)).toEqual( + expect.objectContaining({ + seasonNumber: 2, + episodeNumber: 4, + }), + ); + }); +}); diff --git a/apps/web/__tests__/playback-start.test.ts b/apps/web/__tests__/playback-start.test.ts index f8d3040f..1bbf8dca 100644 --- a/apps/web/__tests__/playback-start.test.ts +++ b/apps/web/__tests__/playback-start.test.ts @@ -4,7 +4,9 @@ import { decidePlaybackAutoStart, hlsStartPosition, PLAYBACK_START_TIMEOUT_MS, + VIDSTACK_START_TIMEOUT_MS, shouldEnforceVidstackStartAtZero, + vidstackScrapeStartTimeoutMs, } from "@/lib/playback/playbackStart"; describe("PLAYBACK_START_TIMEOUT_MS", () => { @@ -13,6 +15,21 @@ describe("PLAYBACK_START_TIMEOUT_MS", () => { }); }); +describe("vidstackScrapeStartTimeoutMs", () => { + it("fails scrape HLS after 30s if metadata never arrives", () => { + expect(VIDSTACK_START_TIMEOUT_MS).toBe(30_000); + expect( + vidstackScrapeStartTimeoutMs("https://cdn.example/master.m3u8"), + ).toBe(30_000); + }); + + it("keeps a longer window for transcode playlists", () => { + expect( + vidstackScrapeStartTimeoutMs("/api/direct/transcode/playlist?u=x"), + ).toBe(PLAYBACK_START_TIMEOUT_MS); + }); +}); + describe("shouldEnforceVidstackStartAtZero", () => { it("enforces start-at-zero for HLS when there is no resume time", () => { expect( diff --git a/apps/web/__tests__/playback-startup-score.test.ts b/apps/web/__tests__/playback-startup-score.test.ts new file mode 100644 index 00000000..f31fbce1 --- /dev/null +++ b/apps/web/__tests__/playback-startup-score.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { + scorePayloadWithStartupProbe, + scorePlaybackStartupMs, + pickBestCachedFallback, +} from "@/lib/scrape/playback-startup-score"; + +describe("playback startup score", () => { + it("rewards lower startup latency", () => { + expect(scorePlaybackStartupMs(300)).toBeGreaterThan( + scorePlaybackStartupMs(3_000), + ); + }); + + it("prefers a fast startup over a slow one when metadata scores tie", () => { + const base = 1_500; + const fast = scorePayloadWithStartupProbe(base, { + startupProbeOk: true, + startupProbeMs: 400, + }); + const slow = scorePayloadWithStartupProbe(base, { + startupProbeOk: true, + startupProbeMs: 4_000, + }); + + expect(fast).toBeGreaterThan(slow); + }); + + it("penalizes failed and pending startup probes", () => { + const base = 2_000; + const ok = scorePayloadWithStartupProbe(base, { + startupProbeOk: true, + startupProbeMs: 500, + }); + const failed = scorePayloadWithStartupProbe(base, { + startupProbeOk: false, + startupProbeMs: 500, + }); + const pending = scorePayloadWithStartupProbe(base, {}); + + expect(ok).toBeGreaterThan(failed); + expect(failed).toBeGreaterThan(pending); + }); + + it("picks the highest-scored warmed fallback", () => { + const cache = new Map([ + ["justanime", { startupProbeOk: true, startupProbeMs: 4_000 }], + ["kickassanime", { startupProbeOk: true, startupProbeMs: 900 }], + ] as const); + + const picked = pickBestCachedFallback({ + order: ["justanime", "kickassanime"], + cache, + failed: new Set(), + scorePayload: (payload) => scorePayloadWithStartupProbe(1_000, payload), + requireStartupProbePass: true, + }); + + expect(picked?.providerId).toBe("kickassanime"); + }); + + it("skips failed startup probes when a passing sibling exists", () => { + const cache = new Map([ + ["justanime", { startupProbeOk: false, startupProbeMs: 500 }], + ["anizone", { startupProbeOk: true, startupProbeMs: 1_200 }], + ] as const); + + const picked = pickBestCachedFallback({ + order: ["justanime", "anizone"], + cache, + failed: new Set(), + scorePayload: (payload) => scorePayloadWithStartupProbe(1_000, payload), + requireStartupProbePass: true, + }); + + expect(picked?.providerId).toBe("anizone"); + }); +}); diff --git a/apps/web/__tests__/player-engine-preference.test.ts b/apps/web/__tests__/player-engine-preference.test.ts new file mode 100644 index 00000000..8dcebe5f --- /dev/null +++ b/apps/web/__tests__/player-engine-preference.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { + DEFAULT_PLAYER_ENGINE, + PLAYER_ENGINE_STORAGE_KEY, + readStoredPlayerEngine, + resolvePlayerEngine, + writePlayerEnginePreference, +} from "@/hooks/use-movi-preview"; + +describe("player engine preference", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("defaults to vidstack when no preference is stored", () => { + expect(DEFAULT_PLAYER_ENGINE).toBe("vidstack"); + expect(resolvePlayerEngine()).toBe("vidstack"); + expect(readStoredPlayerEngine()).toBeNull(); + }); + + it("persists and reads client player engine preference", () => { + writePlayerEnginePreference("movi"); + expect(window.localStorage.getItem(PLAYER_ENGINE_STORAGE_KEY)).toBe("movi"); + expect(readStoredPlayerEngine()).toBe("movi"); + expect(resolvePlayerEngine()).toBe("movi"); + }); + + it("ignores unknown stored values", () => { + window.localStorage.setItem(PLAYER_ENGINE_STORAGE_KEY, "legacy"); + expect(readStoredPlayerEngine()).toBeNull(); + expect(resolvePlayerEngine()).toBe("vidstack"); + }); +}); diff --git a/apps/web/__tests__/preferred-provider.test.ts b/apps/web/__tests__/preferred-provider.test.ts index f783c231..5ab2f95b 100644 --- a/apps/web/__tests__/preferred-provider.test.ts +++ b/apps/web/__tests__/preferred-provider.test.ts @@ -31,4 +31,9 @@ describe("preferred-provider", () => { setPreferredScrapeProvider("198511:1:sub", ""); expect(getPreferredScrapeProvider("198511:1:sub")).toBeNull(); }); + + it("stores vidrock as a preferred provider", () => { + setPreferredScrapeProvider("808:movie", "vidrock"); + expect(getPreferredScrapeProvider("808:movie")).toBe("vidrock"); + }); }); diff --git a/apps/web/__tests__/progress-change-events.test.ts b/apps/web/__tests__/progress-change-events.test.ts new file mode 100644 index 00000000..c442ff79 --- /dev/null +++ b/apps/web/__tests__/progress-change-events.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { shouldApplyMalPatchResponse } from "@/lib/mal/list-index-shared"; +import { + notifyPlaybackProgressChanged, + PLAYBACK_PROGRESS_CHANGED_EVENT, + subscribePlaybackProgressChanged, +} from "@/lib/playback/progress-change-events"; + +describe("shouldApplyMalPatchResponse", () => { + it("accepts only the latest patch generation", () => { + expect(shouldApplyMalPatchResponse(2, 2)).toBe(true); + expect(shouldApplyMalPatchResponse(1, 2)).toBe(false); + }); +}); + +describe("playback progress change events", () => { + it("dispatches a custom event when progress changes", () => { + let count = 0; + const unsubscribe = subscribePlaybackProgressChanged(() => { + count += 1; + }); + + notifyPlaybackProgressChanged(); + unsubscribe(); + + expect(count).toBe(1); + }); + + it("exposes a stable event name", () => { + expect(PLAYBACK_PROGRESS_CHANGED_EVENT).toBe( + "nyumatflix:playback-progress-changed", + ); + }); +}); diff --git a/apps/web/__tests__/progress-storage.test.ts b/apps/web/__tests__/progress-storage.test.ts new file mode 100644 index 00000000..2cb599d2 --- /dev/null +++ b/apps/web/__tests__/progress-storage.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { + mergePlaybackProgressEntry, + PLAYBACK_PROGRESS_STORAGE_KEY, + setPlaybackProgress, + getPlaybackProgress, +} from "@/lib/playback/progress-storage"; + +describe("mergePlaybackProgressEntry", () => { + it("accepts a newer write outright", () => { + const merged = mergePlaybackProgressEntry( + { watched: 100, duration: 1000, updatedAt: 100 }, + { watched: 250, duration: 1000 }, + 200, + ); + + expect(merged).toEqual({ + watched: 250, + duration: 1000, + updatedAt: 200, + }); + }); + + it("keeps the newer timestamp when an older write arrives late", () => { + const merged = mergePlaybackProgressEntry( + { watched: 500, duration: 1000, updatedAt: 500 }, + { watched: 100, duration: 1000 }, + 100, + ); + + expect(merged).toEqual({ + watched: 500, + duration: 1000, + updatedAt: 500, + }); + }); + + it("still accepts higher watched from a slower tab when timestamps are stale", () => { + const merged = mergePlaybackProgressEntry( + { watched: 100, duration: 1000, updatedAt: 500 }, + { watched: 450, duration: 1000 }, + 100, + ); + + expect(merged).toEqual({ + watched: 450, + duration: 1000, + updatedAt: 500, + }); + }); +}); + +describe("setPlaybackProgress", () => { + it("merges per-key without dropping unrelated titles", () => { + window.localStorage.removeItem(PLAYBACK_PROGRESS_STORAGE_KEY); + + setPlaybackProgress( + { mediaType: "movie", contentId: 550 }, + { watched: 100, duration: 1000 }, + ); + setPlaybackProgress( + { mediaType: "tv", contentId: 1399, seasonNumber: 1, episodeNumber: 1 }, + { watched: 200, duration: 2000 }, + ); + + expect(getPlaybackProgress({ mediaType: "movie", contentId: 550 })).toEqual( + expect.objectContaining({ watched: 100 }), + ); + expect( + getPlaybackProgress({ + mediaType: "tv", + contentId: 1399, + seasonNumber: 1, + episodeNumber: 1, + }), + ).toEqual(expect.objectContaining({ watched: 200 })); + }); +}); diff --git a/apps/web/__tests__/provider-race.test.ts b/apps/web/__tests__/provider-race.test.ts index a0c403f2..ce613a02 100644 --- a/apps/web/__tests__/provider-race.test.ts +++ b/apps/web/__tests__/provider-race.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { ANIME_PLAYBACK_SOLO_FIRST_PROVIDERS, + TMDB_SCRAPE_SOLO_FIRST_PROVIDERS, deprioritizeProviders, getScrapeAttemptTimeoutMs, nextRaceBatch, @@ -18,7 +19,7 @@ describe("nextRaceBatch", () => { "anipm", "anizone", "kickassanime", - "animestream", + "animegg", ] as const; it("takes concurrency providers from the start index", () => { @@ -50,6 +51,10 @@ describe("nextRaceBatch", () => { expect(nextIndex).toBe(order.length); }); + it("solos Direct on the TMDB scrape overlay", () => { + expect([...TMDB_SCRAPE_SOLO_FIRST_PROVIDERS]).toEqual(["direct"]); + }); + it("runs direct solo when solo-first is enabled", () => { const order = ["direct", "bingr", "videasy", "vidking"] as const; const first = nextRaceBatch(order, 0, new Set()); @@ -78,13 +83,13 @@ describe("nextRaceBatch", () => { it("skips failed providers and advances past them", () => { const failed = new Set(["hentaigasm", "anipm"]); const { batch, nextIndex } = nextRaceBatch(order, 0, failed); - expect(batch).toEqual(["anizone", "kickassanime", "animestream"]); + expect(batch).toEqual(["anizone", "kickassanime", "animegg"]); expect(nextIndex).toBe(5); }); it("continues forward from a preferred start index without wrapping", () => { const { batch, nextIndex } = nextRaceBatch(order, 2, new Set()); - expect(batch).toEqual(["anizone", "kickassanime", "animestream"]); + expect(batch).toEqual(["anizone", "kickassanime", "animegg"]); expect(nextIndex).toBe(5); }); diff --git a/apps/web/__tests__/provider-registry.test.ts b/apps/web/__tests__/provider-registry.test.ts index a125b26e..b0df15eb 100644 --- a/apps/web/__tests__/provider-registry.test.ts +++ b/apps/web/__tests__/provider-registry.test.ts @@ -32,8 +32,9 @@ describe("provider registry", () => { ]); }); - it("keeps unreliable TMDB scrape providers last in the chain", () => { + it("keeps Direct first and unreliable TMDB scrape providers last", () => { const order = TMDB_SCRAPE_PROVIDER_ORDER; + expect(order[0]).toBe("direct"); expect(order.at(-1)).toBe("2embed"); expect(order).not.toContain("vixsrc"); }); @@ -52,6 +53,7 @@ describe("provider registry", () => { expect(ANIME_SCRAPE_PROVIDER_ORDER).not.toContain("hentaini"); expect(ANIME_SCRAPE_PROVIDER_ORDER).not.toContain("animepahe"); expect(ANIME_SCRAPE_PROVIDER_ORDER).not.toContain("anikuro"); + expect(ANIME_SCRAPE_PROVIDER_ORDER).not.toContain("animestream"); expect(embedOnlyProviderIds()).toEqual( expect.arrayContaining(["vixsrc", "hentaini"]), ); diff --git a/apps/web/__tests__/provider-scrape-loop-pinned.test.ts b/apps/web/__tests__/provider-scrape-loop-pinned.test.ts index 05f68689..ea395ef6 100644 --- a/apps/web/__tests__/provider-scrape-loop-pinned.test.ts +++ b/apps/web/__tests__/provider-scrape-loop-pinned.test.ts @@ -257,6 +257,10 @@ describe("provider scrape loop pinned selection", () => { expect(result.current.result?.providerId).not.toBe("flixhq"); }); + expect( + result.current.items.find((item) => item.providerId === "flixhq")?.status, + ).toBe("skipped"); + expect(onAllProvidersFailed).not.toHaveBeenCalled(); }); @@ -288,6 +292,10 @@ describe("provider scrape loop pinned selection", () => { expect(result.current.result?.providerId).toBe("vixsrc"); }); + expect( + result.current.items.find((item) => item.providerId === "vidsrc")?.status, + ).toBe("skipped"); + const fetchCountAfterResume = vi.mocked(globalThis.fetch).mock.calls.length; expect(fetchCountAfterResume).toBe(fetchCountBeforeResume); }); diff --git a/apps/web/__tests__/race-first.test.ts b/apps/web/__tests__/race-first.test.ts index af31968a..b8ddf111 100644 --- a/apps/web/__tests__/race-first.test.ts +++ b/apps/web/__tests__/race-first.test.ts @@ -21,6 +21,33 @@ describe("raceFirstOk", () => { const winner = await raceFirstOk(["a", "b"], async () => null); expect(winner).toBeNull(); }); + + it("aborts sibling probes after a winner is found", async () => { + let siblingAborted = false; + const winner = await raceFirstOk(["fast", "slow"], async (id, signal) => { + if (id === "fast") { + return "ok"; + } + + await new Promise((resolve, reject) => { + const onAbort = () => { + siblingAborted = true; + reject(new DOMException("aborted", "AbortError")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + window.setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, 200); + }).catch(() => null); + + return null; + }); + + expect(winner).toEqual({ item: "fast", value: "ok" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(siblingAborted).toBe(true); + }); }); describe("firstOkInBatches", () => { diff --git a/apps/web/__tests__/recently-watched.test.ts b/apps/web/__tests__/recently-watched.test.ts index cdcb2fd3..07521988 100644 --- a/apps/web/__tests__/recently-watched.test.ts +++ b/apps/web/__tests__/recently-watched.test.ts @@ -158,6 +158,23 @@ describe("collectRecentlyWatchedStubs", () => { expect(item.isAnime).toBe(false); }); + it("links anime continue-watching items to /anime", () => { + const item = toRecentlyWatchedItem( + { + mediaType: "tv", + contentId: 11757, + seasonNumber: 2, + episodeNumber: 1, + progressRatio: 0.4, + updatedAt: 1, + }, + { title: "Sword Art Online", isAnime: true }, + ); + + expect(item.href).toBe("/anime/anilist-11757?season=2"); + expect(item.isAnime).toBe(true); + }); + it("omits non-watching watchlist titles", () => { const watchlist: WatchlistItem[] = [ { diff --git a/apps/web/__tests__/scrape-abort.test.ts b/apps/web/__tests__/scrape-abort.test.ts new file mode 100644 index 00000000..2eaac150 --- /dev/null +++ b/apps/web/__tests__/scrape-abort.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { isScrapeAborted } from "@/lib/scrape/abort"; + +describe("scrape abort helpers", () => { + it("detects aborted signals", () => { + const controller = new AbortController(); + expect(isScrapeAborted(controller.signal)).toBe(false); + controller.abort(); + expect(isScrapeAborted(controller.signal)).toBe(true); + expect(isScrapeAborted(undefined)).toBe(false); + }); +}); diff --git a/apps/web/__tests__/scrape-hls-playback.test.ts b/apps/web/__tests__/scrape-hls-playback.test.ts index 7fc44829..39a75bfe 100644 --- a/apps/web/__tests__/scrape-hls-playback.test.ts +++ b/apps/web/__tests__/scrape-hls-playback.test.ts @@ -244,7 +244,7 @@ describe("scrape hls playback helpers", () => { ]); }); - it("serves disguised AnimeStream pict segments as MPEG-TS", () => { + it("serves disguised pict segments as MPEG-TS", () => { expect( contentTypeForProxiedAsset( "https://cdn.example/show/img-00000.pict", @@ -278,10 +278,17 @@ describe("scrape hls playback helpers", () => { playUrl: "/api/scrape/play/token/master.m3u8", qualities: [{ label: "1080p", url: "https://cdn/1080.m3u8" }], subtitles: [{ lang: "en", url: "https://cdn/en.vtt" }], + progressKey: { + mediaType: "tv", + contentId: 1399, + seasonNumber: 1, + episodeNumber: 3, + }, }); expect(key).toContain("/api/scrape/play/token/master.m3u8"); expect(key).toContain("1080p"); + expect(key).toContain("tv:1399:1:3"); expect(key).not.toContain("en:https://cdn/en.vtt"); }); diff --git a/apps/web/__tests__/scrape-hls-recovery.test.ts b/apps/web/__tests__/scrape-hls-recovery.test.ts new file mode 100644 index 00000000..b94e7494 --- /dev/null +++ b/apps/web/__tests__/scrape-hls-recovery.test.ts @@ -0,0 +1,401 @@ +import { describe, expect, it, vi } from "vitest"; +import Hls, { type ErrorData, type Level } from "hls.js"; + +import { + bufferedAheadSeconds, + configureScrapeHlsInstance, + decideScrapeHlsFatalRecovery, + isScrapeHlsMidstream, + isUnrecoverableScrapeHlsError, + nextScrapeHlsLevelAfterError, + pickNextLowerHlsLevelIndex, + PLAYING_FRAG_LOADING_MAX_RETRY, + PLAYING_FRAG_LOADING_TIMEOUT_MS, + scrapeHlsRecoveryBudget, + shouldFailoverScrapeHlsFatal, +} from "@/lib/scrape/hls-quality"; + +const fakeBuffered = (ranges: Array<[number, number]>) => ({ + length: ranges.length, + start: (index: number) => ranges[index]?.[0] ?? 0, + end: (index: number) => ranges[index]?.[1] ?? 0, +}); + +const fatal = ( + overrides: Partial & Pick, +): ErrorData => + ({ + fatal: true, + details: "fragLoadError", + ...overrides, + }) as ErrorData; + +describe("scrape hls midstream recovery", () => { + it("measures buffer ahead only inside the active range", () => { + expect( + bufferedAheadSeconds({ + currentTime: 12, + buffered: fakeBuffered([ + [0, 10], + [11, 40], + ]), + }), + ).toBe(28); + + expect( + bufferedAheadSeconds({ + currentTime: 10.5, + buffered: fakeBuffered([ + [0, 10], + [11, 40], + ]), + }), + ).toBe(0); + }); + + it("treats playhead past 1s or >1s of buffer as midstream", () => { + expect( + isScrapeHlsMidstream({ + currentTime: 0, + buffered: fakeBuffered([]), + }), + ).toBe(false); + + expect( + isScrapeHlsMidstream({ + currentTime: 0.2, + buffered: fakeBuffered([[0, 0.4]]), + }), + ).toBe(false); + + expect( + isScrapeHlsMidstream({ + currentTime: 0.2, + buffered: fakeBuffered([[0, 12]]), + }), + ).toBe(true); + + expect( + isScrapeHlsMidstream({ + currentTime: 90, + buffered: fakeBuffered([]), + }), + ).toBe(true); + }); + + it("treats 403/404 and mux errors as unrecoverable", () => { + expect( + isUnrecoverableScrapeHlsError( + fatal({ + type: Hls.ErrorTypes.NETWORK_ERROR, + response: { code: 403, text: "Forbidden", url: "https://cdn/x" }, + }), + ), + ).toBe(true); + + expect( + isUnrecoverableScrapeHlsError( + fatal({ + type: Hls.ErrorTypes.NETWORK_ERROR, + response: { code: 404, text: "Not Found", url: "https://cdn/x" }, + }), + ), + ).toBe(true); + + expect( + isUnrecoverableScrapeHlsError(fatal({ type: Hls.ErrorTypes.MUX_ERROR })), + ).toBe(true); + + expect( + isUnrecoverableScrapeHlsError( + fatal({ + type: Hls.ErrorTypes.NETWORK_ERROR, + response: { code: 503, text: "Unavailable", url: "https://cdn/x" }, + }), + ), + ).toBe(false); + }); + + it("gives a larger retry budget when the buffer can cover a blip", () => { + expect(scrapeHlsRecoveryBudget(12)).toEqual({ network: 3, media: 2 }); + expect(scrapeHlsRecoveryBudget(4)).toEqual({ network: 2, media: 1 }); + expect(scrapeHlsRecoveryBudget(0.5)).toEqual({ network: 1, media: 1 }); + }); + + it("fails fast before the first frame and recovers in place midstream", () => { + const budget = scrapeHlsRecoveryBudget(20); + + expect( + decideScrapeHlsFatalRecovery({ + midstream: false, + unrecoverable: false, + errorType: Hls.ErrorTypes.NETWORK_ERROR, + networkRetries: 0, + mediaRetries: 0, + budget, + }), + ).toBe("fail-fast"); + + expect( + decideScrapeHlsFatalRecovery({ + midstream: true, + unrecoverable: false, + errorType: Hls.ErrorTypes.NETWORK_ERROR, + networkRetries: 0, + mediaRetries: 0, + budget, + }), + ).toBe("recover-network"); + + expect( + decideScrapeHlsFatalRecovery({ + midstream: true, + unrecoverable: false, + errorType: Hls.ErrorTypes.MEDIA_ERROR, + networkRetries: 0, + mediaRetries: 0, + budget, + }), + ).toBe("recover-media"); + + expect( + decideScrapeHlsFatalRecovery({ + midstream: true, + unrecoverable: true, + errorType: Hls.ErrorTypes.NETWORK_ERROR, + networkRetries: 0, + mediaRetries: 0, + budget, + }), + ).toBe("unrecoverable"); + + expect( + decideScrapeHlsFatalRecovery({ + midstream: true, + unrecoverable: false, + errorType: Hls.ErrorTypes.NETWORK_ERROR, + networkRetries: 3, + mediaRetries: 0, + budget, + }), + ).toBe("exhausted"); + }); + + it("does not hop vidstack on the first recoverable midstream fatal", () => { + expect( + shouldFailoverScrapeHlsFatal( + fatal({ type: Hls.ErrorTypes.MEDIA_ERROR }), + true, + ), + ).toBe(false); + + expect( + shouldFailoverScrapeHlsFatal( + fatal({ type: Hls.ErrorTypes.MEDIA_ERROR }), + false, + ), + ).toBe(true); + + expect( + shouldFailoverScrapeHlsFatal( + fatal({ + type: Hls.ErrorTypes.NETWORK_ERROR, + response: { code: 404, text: "Not Found", url: "https://cdn/x" }, + }), + true, + ), + ).toBe(true); + + expect( + shouldFailoverScrapeHlsFatal( + { fatal: false, type: Hls.ErrorTypes.MEDIA_ERROR } as ErrorData, + false, + ), + ).toBe(false); + }); + + it("drops to the next lower HLS rung before enabling ABR", () => { + const levels = [ + { height: 480, url: "https://cdn/480p/index.m3u8" }, + { height: 720, url: "https://cdn/720p/index.m3u8" }, + { height: 1080, url: "https://cdn/1080p/index.m3u8" }, + ] as unknown as Level[]; + + expect(pickNextLowerHlsLevelIndex(levels, 2)).toBe(1); + expect(nextScrapeHlsLevelAfterError(levels, 0)).toBe(-1); + expect(nextScrapeHlsLevelAfterError(levels.slice(0, 1), 0)).toBeNull(); + }); + + it("recovers a midstream network fatal without notifying failover", () => { + const listeners = new Map< + string, + Array<(event: string, detail?: ErrorData) => void> + >(); + const startLoad = vi.fn(); + const recoverMediaError = vi.fn(); + const onRecoveryExhausted = vi.fn(); + const media = { + currentTime: 42, + buffered: fakeBuffered([[30, 90]]), + }; + + const hls = { + media, + levels: [], + currentLevel: -1, + config: { + fragLoadingTimeOut: 60_000, + fragLoadingMaxRetry: 6, + }, + on: ( + event: string, + callback: (event: string, detail?: ErrorData) => void, + ) => { + const list = listeners.get(event) ?? []; + list.push(callback); + listeners.set(event, list); + }, + startLoad, + recoverMediaError, + }; + + configureScrapeHlsInstance(hls as unknown as Hls, { onRecoveryExhausted }); + + const emitError = (detail: ErrorData) => { + for (const callback of listeners.get(Hls.Events.ERROR) ?? []) { + callback(Hls.Events.ERROR, detail); + } + }; + + const first = fatal({ type: Hls.ErrorTypes.NETWORK_ERROR }); + emitError(first); + + expect(first.fatal).toBe(false); + expect(startLoad).toHaveBeenCalledWith(42); + expect(recoverMediaError).not.toHaveBeenCalled(); + expect(onRecoveryExhausted).not.toHaveBeenCalled(); + }); + + it("does not recover a startup fatal so vidstack can fail fast", () => { + const listeners = new Map< + string, + Array<(event: string, detail?: ErrorData) => void> + >(); + const startLoad = vi.fn(); + const onRecoveryExhausted = vi.fn(); + + const hls = { + media: { + currentTime: 0, + buffered: fakeBuffered([]), + }, + levels: [], + currentLevel: -1, + config: { + fragLoadingTimeOut: 60_000, + fragLoadingMaxRetry: 6, + }, + on: ( + event: string, + callback: (event: string, detail?: ErrorData) => void, + ) => { + const list = listeners.get(event) ?? []; + list.push(callback); + listeners.set(event, list); + }, + startLoad, + recoverMediaError: vi.fn(), + }; + + configureScrapeHlsInstance(hls as unknown as Hls, { onRecoveryExhausted }); + + const detail = fatal({ type: Hls.ErrorTypes.NETWORK_ERROR }); + for (const callback of listeners.get(Hls.Events.ERROR) ?? []) { + callback(Hls.Events.ERROR, detail); + } + + expect(detail.fatal).toBe(true); + expect(startLoad).not.toHaveBeenCalled(); + expect(onRecoveryExhausted).not.toHaveBeenCalled(); + }); + + it("hops after the midstream retry budget is spent", () => { + const listeners = new Map< + string, + Array<(event: string, detail?: ErrorData) => void> + >(); + const startLoad = vi.fn(); + const onRecoveryExhausted = vi.fn(); + const media = { + currentTime: 8, + buffered: fakeBuffered([[8, 9]]), + }; + + const hls = { + media, + levels: [], + currentLevel: -1, + config: { + fragLoadingTimeOut: 60_000, + fragLoadingMaxRetry: 6, + }, + on: ( + event: string, + callback: (event: string, detail?: ErrorData) => void, + ) => { + const list = listeners.get(event) ?? []; + list.push(callback); + listeners.set(event, list); + }, + startLoad, + recoverMediaError: vi.fn(), + }; + + configureScrapeHlsInstance(hls as unknown as Hls, { onRecoveryExhausted }); + + const emitError = () => { + const detail = fatal({ type: Hls.ErrorTypes.NETWORK_ERROR }); + for (const callback of listeners.get(Hls.Events.ERROR) ?? []) { + callback(Hls.Events.ERROR, detail); + } + return detail; + }; + + const first = emitError(); + expect(first.fatal).toBe(false); + expect(onRecoveryExhausted).not.toHaveBeenCalled(); + + const second = emitError(); + expect(second.fatal).toBe(true); + expect(onRecoveryExhausted).toHaveBeenCalledTimes(1); + }); + + it("tightens fragment retries after the first successful load", () => { + const listeners = new Map void>>(); + const hls = { + media: null, + levels: [], + currentLevel: -1, + config: { + fragLoadingTimeOut: 60_000, + fragLoadingMaxRetry: 6, + }, + on: (event: string, callback: () => void) => { + const list = listeners.get(event) ?? []; + list.push(callback); + listeners.set(event, list); + }, + startLoad: vi.fn(), + recoverMediaError: vi.fn(), + }; + + configureScrapeHlsInstance(hls as unknown as Hls); + + for (const callback of listeners.get(Hls.Events.FRAG_LOADED) ?? []) { + callback(); + } + + expect(hls.config.fragLoadingTimeOut).toBe(PLAYING_FRAG_LOADING_TIMEOUT_MS); + expect(hls.config.fragLoadingMaxRetry).toBe(PLAYING_FRAG_LOADING_MAX_RETRY); + }); +}); diff --git a/apps/web/__tests__/scrape-start-gate.test.ts b/apps/web/__tests__/scrape-start-gate.test.ts new file mode 100644 index 00000000..cc980ff3 --- /dev/null +++ b/apps/web/__tests__/scrape-start-gate.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { + scrapeSessionKeyFor, + shouldHoldScrapeForAnimeCoords, +} from "@/lib/anime/scrape-start-gate"; + +const base = { + defaultAnilistId: 21, + hasSelectedEpisode: true, + animeCoordsStatus: "pending" as const, + mappingConfidence: null, + isDirectMode: false, + isAnimeScrapeActive: false, +}; + +describe("shouldHoldScrapeForAnimeCoords", () => { + it("holds while coords are pending for an anime show", () => { + expect(shouldHoldScrapeForAnimeCoords(base)).toBe(true); + }); + + it("holds while coords are still idle before mapping starts", () => { + expect( + shouldHoldScrapeForAnimeCoords({ + ...base, + animeCoordsStatus: "idle", + mappingConfidence: null, + }), + ).toBe(true); + }); + + it("does not hold after coords miss so tmdb proxies can start", () => { + expect( + shouldHoldScrapeForAnimeCoords({ + ...base, + animeCoordsStatus: "miss", + mappingConfidence: "low", + }), + ).toBe(false); + }); + + it("does not hold when mapping is already high", () => { + expect( + shouldHoldScrapeForAnimeCoords({ + ...base, + mappingConfidence: "high", + animeCoordsStatus: "resolved", + }), + ).toBe(false); + }); + + it("does not hold once anime scrape is active even at low confidence", () => { + expect( + shouldHoldScrapeForAnimeCoords({ + ...base, + mappingConfidence: "low", + isAnimeScrapeActive: true, + }), + ).toBe(false); + }); +}); + +describe("scrapeSessionKeyFor", () => { + it("namespaces direct vs tmdb so a direct start cannot skip the proxy race", () => { + expect(scrapeSessionKeyFor("direct", "tv:37854:1:1")).not.toBe( + scrapeSessionKeyFor("tmdb", "tv:37854:1:1"), + ); + }); +}); diff --git a/apps/web/__tests__/scrape-stream-validation.test.ts b/apps/web/__tests__/scrape-stream-validation.test.ts index 8a8d4637..d9fa88c8 100644 --- a/apps/web/__tests__/scrape-stream-validation.test.ts +++ b/apps/web/__tests__/scrape-stream-validation.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { - extractHlsProbeTargets, isValidHlsAssetResponse, resolveStreamReferers, resolveValidateStreamDepths, } from "@/lib/scrape/validate-stream"; +import { extractHlsProbeTargets } from "@/lib/scrape/hls-probe-targets"; import { countHlsMediaRenditions } from "@/lib/scrape/hls-media-renditions"; import { isTokenizedHlsMaster } from "@/lib/scrape/stream-url-patterns"; diff --git a/apps/web/__tests__/search-autocomplete.test.ts b/apps/web/__tests__/search-autocomplete.test.ts index 744ea09b..1b00678f 100644 --- a/apps/web/__tests__/search-autocomplete.test.ts +++ b/apps/web/__tests__/search-autocomplete.test.ts @@ -53,9 +53,9 @@ describe("search autocomplete helpers", () => { id: 21222, title: "Mankitsu Happening", media_type: "tv", - href: "/anime/21222", + href: "/anime/anilist-21222", } as SearchPreviewResult; - expect(getSearchResultHref(animeResult)).toBe("/anime/21222"); + expect(getSearchResultHref(animeResult)).toBe("/anime/anilist-21222"); }); }); diff --git a/apps/web/__tests__/site-flags.test.ts b/apps/web/__tests__/site-flags.test.ts index 8f7389ca..26d72979 100644 --- a/apps/web/__tests__/site-flags.test.ts +++ b/apps/web/__tests__/site-flags.test.ts @@ -8,6 +8,7 @@ import { getDefaultSiteFlags, getPlaybackModePolicy, resolveSiteFlags, + shouldSeedDefaultProxyPlayback, } from "@/lib/flags/site-flags"; describe("site-flags", () => { @@ -113,4 +114,31 @@ describe("site-flags", () => { expect(flags.locks.playbackMode).toBe(false); expect(getPlaybackModePolicy(flags)).toBe("choice"); }); + + it("seeds default proxy playback until the user picks iframe", () => { + expect( + shouldSeedDefaultProxyPlayback({ + policy: "choice", + defaultProxyPlayback: true, + hasUserSelectedPlaybackServer: false, + selectedServerIsScrape: false, + }), + ).toBe(true); + expect( + shouldSeedDefaultProxyPlayback({ + policy: "choice", + defaultProxyPlayback: true, + hasUserSelectedPlaybackServer: true, + selectedServerIsScrape: false, + }), + ).toBe(false); + expect( + shouldSeedDefaultProxyPlayback({ + policy: "proxy", + defaultProxyPlayback: true, + hasUserSelectedPlaybackServer: false, + selectedServerIsScrape: false, + }), + ).toBe(false); + }); }); diff --git a/apps/web/__tests__/source-resolve.test.ts b/apps/web/__tests__/source-resolve.test.ts index 61b4f282..31ef98ca 100644 --- a/apps/web/__tests__/source-resolve.test.ts +++ b/apps/web/__tests__/source-resolve.test.ts @@ -10,6 +10,7 @@ import { classifySiblingCancel, shouldFinalizeBatchWinner, shouldFinalizeFirstPreferenceMatch, + shouldFinalizeFirstPreferenceMatchWithStartup, shouldFinalizeRaceWinner, } from "@/lib/scrape/scrape-race-batch"; @@ -144,6 +145,56 @@ describe("shouldFinalizeFirstPreferenceMatch", () => { }); }); +describe("shouldFinalizeFirstPreferenceMatchWithStartup", () => { + it("requires a passing startup probe", () => { + const winner = shouldFinalizeFirstPreferenceMatchWithStartup( + [ + { + providerId: "justanime", + attempt: { + outcome: "success", + payload: { ok: true, startupProbeOk: false, startupProbeMs: 200 }, + }, + }, + { + providerId: "anizone", + attempt: { + outcome: "success", + payload: { ok: true, startupProbeOk: true, startupProbeMs: 900 }, + }, + }, + ], + (payload: { ok: boolean }) => payload.ok, + ); + + expect(winner?.providerId).toBe("anizone"); + }); + + it("takes the first dub that already passed startup even if an earlier source is still probing", () => { + const winner = shouldFinalizeFirstPreferenceMatchWithStartup( + [ + { + providerId: "vidking", + attempt: { + outcome: "success", + payload: { dub: false, startupProbeOk: true, startupProbeMs: 200 }, + }, + }, + { + providerId: "kickassanime", + attempt: { + outcome: "success", + payload: { dub: true, startupProbeOk: true, startupProbeMs: 800 }, + }, + }, + ], + (payload: { dub: boolean }) => payload.dub, + ); + + expect(winner?.providerId).toBe("kickassanime"); + }); +}); + describe("shouldFinalizeRaceWinner", () => { it("finalizes the first success without waiting for siblings", () => { const winner = shouldFinalizeRaceWinner([ diff --git a/apps/web/__tests__/split-cour-appendix.test.ts b/apps/web/__tests__/split-cour-appendix.test.ts new file mode 100644 index 00000000..a52706db --- /dev/null +++ b/apps/web/__tests__/split-cour-appendix.test.ts @@ -0,0 +1,91 @@ +import { + advanceFribbPlaybackAcrossSpecialSequels, + appendMappingSegmentsBeyond, + buildFribbSeasonSegments, +} from "@/lib/anime/split-cour-appendix"; +import { + collectFribbAnilistIdsForTmdbSeason, + mergeAnilistIdsByFribbOffset, +} from "@/lib/anilist-franchise-display"; +import type { FribbAnimeRow } from "@/lib/fribb-mapping"; +import { describe, expect, it } from "vitest"; + +const aotSeason4Rows: FribbAnimeRow[] = [ + { + anilist_id: 110277, + themoviedb_id: { tv: 1429 }, + season: { tmdb: 4 }, + }, + { + anilist_id: 131681, + themoviedb_id: { tv: 1429 }, + season: { tmdb: 4 }, + episode_offset: { tmdb: 16 }, + }, + { + anilist_id: 146984, + themoviedb_id: { tv: 1429 }, + season: { tmdb: 4 }, + episode_offset: { tmdb: 28 }, + }, +]; + +describe("split-cour appendix helpers", () => { + it("collects all Fribb ids mapped onto the same TMDB season", () => { + expect( + collectFribbAnilistIdsForTmdbSeason(aotSeason4Rows, 1429, 4), + ).toEqual([110277, 131681, 146984]); + }); + + it("merges franchise and Fribb ids in offset order", () => { + expect( + mergeAnilistIdsByFribbOffset( + [110277, 131681], + collectFribbAnilistIdsForTmdbSeason(aotSeason4Rows, 1429, 4), + aotSeason4Rows, + ), + ).toEqual([110277, 131681, 146984]); + }); + + it("advances Fribb playback into a chained special sequel", () => { + expect( + advanceFribbPlaybackAcrossSpecialSequels({ + anilistId: 146984, + relativeEpisode: 2, + segmentStart: 29, + episodeCount: 1, + sequelSpecialId: 162314, + }), + ).toEqual({ + anilistId: 162314, + relativeEpisode: 1, + segmentStart: 30, + }); + }); + + it("builds Fribb map segments for Attack on Titan season 4", () => { + expect(buildFribbSeasonSegments(aotSeason4Rows, 1429, 4)).toEqual([ + { startEpisode: 1, endEpisode: 16, anilistMediaId: 110277 }, + { startEpisode: 17, endEpisode: 28, anilistMediaId: 131681 }, + { startEpisode: 29, endEpisode: 29, anilistMediaId: 146984 }, + ]); + }); + + it("appends Fribb segments beyond AniBridge coverage", () => { + const anibridge = [ + { startEpisode: 1, endEpisode: 16, anilistMediaId: 110277 }, + { startEpisode: 17, endEpisode: 28, anilistMediaId: 131681 }, + ]; + + expect( + appendMappingSegmentsBeyond( + anibridge, + buildFribbSeasonSegments(aotSeason4Rows, 1429, 4), + ), + ).toEqual([ + { startEpisode: 1, endEpisode: 16, anilistMediaId: 110277 }, + { startEpisode: 17, endEpisode: 28, anilistMediaId: 131681 }, + { startEpisode: 29, endEpisode: 29, anilistMediaId: 146984 }, + ]); + }); +}); diff --git a/apps/web/__tests__/tmdb-anilist-map.test.ts b/apps/web/__tests__/tmdb-anilist-map.test.ts index e60ff032..76c1502a 100644 --- a/apps/web/__tests__/tmdb-anilist-map.test.ts +++ b/apps/web/__tests__/tmdb-anilist-map.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest"; import { animeSeasonNumberForEpisode, buildUnknownEpisodeCountSegment, + episodeListPresentation, findSegmentForEpisode, + formatCourSelectLabel, inferMappingConfidence, relativeEpisodeInSegment, resolveAnimeEpisodeCoords, @@ -87,4 +89,35 @@ describe("tmdb-anilist-map", () => { anilistMediaId: 21, }); }); + + it("keeps the TMDB season picker when a split-cour season sits among multiple seasons", () => { + expect( + episodeListPresentation({ tmdbSeasonCount: 4, segmentCount: 2 }), + ).toEqual({ + splitCour: true, + showTmdbSeasonSelect: true, + showCourSelect: true, + courSelectReplacesSeasons: false, + }); + }); + + it("replaces the season picker with cours when TMDB only has one season", () => { + expect( + episodeListPresentation({ tmdbSeasonCount: 1, segmentCount: 2 }), + ).toEqual({ + splitCour: true, + showTmdbSeasonSelect: false, + showCourSelect: true, + courSelectReplacesSeasons: true, + }); + }); + + it("labels cours as parts when they sit beside TMDB seasons", () => { + expect( + formatCourSelectLabel(segments[1], 1, { besideTmdbSeasons: true }), + ).toBe("Part 2 · 12 eps"); + expect( + formatCourSelectLabel(segments[1], 1, { besideTmdbSeasons: false }), + ).toBe("Season 2"); + }); }); diff --git a/apps/web/__tests__/tmdb-anime-detail-route.test.ts b/apps/web/__tests__/tmdb-anime-detail-route.test.ts index 23d47468..d8f8675b 100644 --- a/apps/web/__tests__/tmdb-anime-detail-route.test.ts +++ b/apps/web/__tests__/tmdb-anime-detail-route.test.ts @@ -68,7 +68,7 @@ describe("resolveTmdbAnimeDetailRedirects", () => { await expect( resolveTmdbAnimeDetailRedirects("tmdb-207840"), - ).rejects.toThrow("REDIRECT:/anime/153567?"); + ).rejects.toThrow("REDIRECT:/anime/anilist-153567?"); }); it("redirects tmdb-movie-1234 to canonical AniList anime URL when mapped", async () => { @@ -76,7 +76,7 @@ describe("resolveTmdbAnimeDetailRedirects", () => { await expect( resolveTmdbAnimeDetailRedirects("tmdb-movie-1234"), - ).rejects.toThrow("REDIRECT:/anime/4321?"); + ).rejects.toThrow("REDIRECT:/anime/anilist-4321?"); }); it("falls back to standard tvshow route when anime cannot be mapped to AniList", async () => { diff --git a/apps/web/__tests__/tmdb-cache-policy.test.ts b/apps/web/__tests__/tmdb-cache-policy.test.ts index a2bc0ab3..2c5c28c3 100644 --- a/apps/web/__tests__/tmdb-cache-policy.test.ts +++ b/apps/web/__tests__/tmdb-cache-policy.test.ts @@ -7,8 +7,18 @@ import { import { describe, expect, it } from "vitest"; describe("TMDB cache policy", () => { - it("bypasses Next data cache for raw season details", () => { - expect(shouldBypassTmdbDataCache("/tv/95479/season/1")).toBe(true); + it("keeps raw season details cacheable", () => { + expect(shouldBypassTmdbDataCache("/tv/95479/season/1")).toBe(false); + }); + + it("uses revalidate for raw season details", () => { + const init = tmdbFetchInit({ + endpoint: "/tv/95479/season/1", + revalidate: 3600, + }); + + expect(init.cache).toBeUndefined(); + expect(init.next?.revalidate).toBe(3600); }); it("bypasses Next data cache for large appended detail payloads", () => { diff --git a/apps/web/__tests__/tmdb-lookup-id.test.ts b/apps/web/__tests__/tmdb-lookup-id.test.ts new file mode 100644 index 00000000..101b84fa --- /dev/null +++ b/apps/web/__tests__/tmdb-lookup-id.test.ts @@ -0,0 +1,17 @@ +import { + buildTmdbAnimeDetailHref, + unwrapTmdbLookupId, +} from "@/lib/tmdb-anime-route-id"; +import { describe, expect, it } from "vitest"; + +describe("unwrapTmdbLookupId", () => { + it("strips tmdb- anime route slugs for TMDB API lookups", () => { + expect(unwrapTmdbLookupId("tmdb-46153")).toBe("46153"); + expect(unwrapTmdbLookupId("tmdb-movie-372058")).toBe("372058"); + expect(unwrapTmdbLookupId("196187")).toBe("196187"); + }); + + it("builds anime catalog hrefs for TMDB ids", () => { + expect(buildTmdbAnimeDetailHref(46153)).toBe("/anime/tmdb-46153"); + }); +}); diff --git a/apps/web/__tests__/tv-card-href.test.ts b/apps/web/__tests__/tv-card-href.test.ts new file mode 100644 index 00000000..b2efd5bd --- /dev/null +++ b/apps/web/__tests__/tv-card-href.test.ts @@ -0,0 +1,45 @@ +import { resolveTvCardHref } from "@/lib/tv-card-href"; +import { describe, expect, it } from "vitest"; + +describe("resolveTvCardHref", () => { + it("prefers an existing internal href", () => { + expect( + resolveTvCardHref({ + id: 188, + href: "/anime/188", + poster_path: "/tmdb.jpg", + }), + ).toBe("/anime/anilist-188"); + }); + + it("routes AniList posters to /anime/{id}", () => { + expect( + resolveTvCardHref({ + id: 21087, + poster_path: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx21087.jpg", + }), + ).toBe("/anime/anilist-21087"); + }); + + it("keeps TMDB cards on /tvshows outside the anime catalog", () => { + expect( + resolveTvCardHref({ + id: 1396, + poster_path: "/abc.jpg", + }), + ).toBe("/tvshows/1396"); + }); + + it("uses the TMDB anime slug on anime catalog when the id is not AniList", () => { + expect( + resolveTvCardHref( + { + id: 1396, + poster_path: "/abc.jpg", + }, + "anime", + ), + ).toBe("/anime/tmdb-1396"); + }); +}); diff --git a/apps/web/__tests__/tv-detail-catalog.test.ts b/apps/web/__tests__/tv-detail-catalog.test.ts index b956e06e..dc41f070 100644 --- a/apps/web/__tests__/tv-detail-catalog.test.ts +++ b/apps/web/__tests__/tv-detail-catalog.test.ts @@ -1,6 +1,12 @@ import { isAnilistBackedTvRouteId, readTvDetailCatalogFromRequestUrl, + resolveAnimePrefetchRouteId, + resolveTvDetailCatalogFromHref, + toAnimeApiId, + toTvApiRouteId, + withPageTvApiPath, + withTvApiRouteId, withTvDetailCatalogQuery, } from "@/lib/tv-detail-catalog"; import { describe, expect, it } from "vitest"; @@ -25,4 +31,56 @@ describe("tv detail catalog", () => { withTvDetailCatalogQuery("/api/media/tv/154587/all-seasons", "anime"), ).toBe("/api/media/tv/154587/all-seasons?catalog=anime"); }); + + it("prefixes bare anime catalog ids so they cannot collide with TMDB", () => { + expect(toTvApiRouteId("11757", "anime")).toBe("anilist-11757"); + expect(toTvApiRouteId("anilist-11757", "tvshows")).toBe("anilist-11757"); + expect(toTvApiRouteId("11757", "tvshows")).toBe("11757"); + expect(toTvApiRouteId("tmdb-45790", "anime")).toBe("tmdb-45790"); + }); + + it("maps AniList route ids onto /api/anime ids", () => { + expect(toAnimeApiId("11757")).toBe("11757"); + expect(toAnimeApiId("anilist-11757")).toBe("11757"); + expect(toAnimeApiId("tmdb-45790")).toBeNull(); + }); + + it("rewrites anime catalog tv API paths onto /api/anime", () => { + expect(withTvApiRouteId("/api/tv/11757/season/2", "anime")).toBe( + "/api/anime/11757/season/2", + ); + expect(withTvApiRouteId("/api/tv/anilist-11757/season/2", "anime")).toBe( + "/api/anime/11757/season/2", + ); + expect(withTvApiRouteId("/api/tv/tmdb-45790/season/1", "anime")).toBe( + "/api/tv/45790/season/1", + ); + expect(withTvApiRouteId("/api/media/tv/11757/above-fold", "anime")).toBe( + "/api/media/tv/anilist-11757/above-fold?catalog=anime", + ); + }); + + it("resolves prefetch ids from /anime hrefs", () => { + expect(resolveAnimePrefetchRouteId("/anime/20021")).toBe("anilist-20021"); + expect(resolveAnimePrefetchRouteId("/anime/tmdb-45790")).toBe("tmdb-45790"); + expect(resolveAnimePrefetchRouteId("/tvshows/11757")).toBeNull(); + }); + + it("resolves catalog from detail hrefs", () => { + expect(resolveTvDetailCatalogFromHref("/anime/20021")).toBe("anime"); + expect(resolveTvDetailCatalogFromHref("/tvshows/11757")).toBe("tvshows"); + expect(resolveTvDetailCatalogFromHref("/movies/550")).toBeNull(); + }); + + it("picks /api/anime vs /api/tv from the page URL namespace", () => { + expect(withPageTvApiPath("/api/tv/11757/season/2", "/anime/11757")).toBe( + "/api/anime/11757/season/2", + ); + expect(withPageTvApiPath("/api/tv/11757/season/2", "/tvshows/11757")).toBe( + "/api/tv/11757/season/2", + ); + expect( + withPageTvApiPath("/api/tv/45790/season/1", "/anime/tmdb-45790"), + ).toBe("/api/tv/45790/season/1"); + }); }); diff --git a/apps/web/__tests__/tv-show-detail-client.test.ts b/apps/web/__tests__/tv-show-detail-client.test.ts new file mode 100644 index 00000000..e6344608 --- /dev/null +++ b/apps/web/__tests__/tv-show-detail-client.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + fetchTvShowDetailClient, + tvDetailTitleMatches, +} from "@/lib/tv-show-detail-client"; + +describe("tvDetailTitleMatches", () => { + it("treats a missing expected title as a match", () => { + expect(tvDetailTitleMatches("The Shop", undefined)).toBe(true); + }); + + it("matches SAO against a TMDB collision title as a miss", () => { + expect(tvDetailTitleMatches("The Shop", "Sword Art Online")).toBe(false); + expect(tvDetailTitleMatches("Sword Art Online", "Sword Art Online")).toBe( + true, + ); + }); +}); + +describe("fetchTvShowDetailClient", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + global.fetch = fetchMock; + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("returns the TMDB show when no expected title is provided", async () => { + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const url = String(input); + if (url === "/api/tv/11757") { + return { + ok: true, + json: async () => ({ id: 11757, name: "The Shop" }), + }; + } + return { ok: false, json: async () => ({}) }; + }); + + const result = await fetchTvShowDetailClient(11757); + expect(result?.detail.name).toBe("The Shop"); + expect(result?.catalog).toBeNull(); + }); + + it("returns the AniList show when the TMDB title does not match", async () => { + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const url = String(input); + if (url === "/api/tv/11757") { + return { + ok: true, + json: async () => ({ id: 11757, name: "The Shop" }), + }; + } + if (url === "/api/anime/11757") { + return { + ok: true, + json: async () => ({ id: 11757, name: "Sword Art Online" }), + }; + } + return { ok: false, json: async () => ({}) }; + }); + + const result = await fetchTvShowDetailClient(11757, { + expectedTitle: "Sword Art Online", + }); + expect(result?.detail.name).toBe("Sword Art Online"); + expect(result?.catalog).toBe("anime"); + }); + + it("skips TMDB when catalog is anime", async () => { + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const url = String(input); + if (url === "/api/tv/11757") { + return { + ok: true, + json: async () => ({ id: 11757, name: "The Shop" }), + }; + } + if (url === "/api/anime/11757") { + return { + ok: true, + json: async () => ({ id: 11757, name: "Sword Art Online" }), + }; + } + return { ok: false, json: async () => ({}) }; + }); + + const result = await fetchTvShowDetailClient(11757, { catalog: "anime" }); + expect(result?.detail.name).toBe("Sword Art Online"); + expect(result?.catalog).toBe("anime"); + expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([ + "/api/anime/11757", + ]); + }); + + it("falls back to AniList when TMDB 404s", async () => { + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const url = String(input); + if (url === "/api/tv/154587") { + return { ok: false, json: async () => ({}) }; + } + if (url === "/api/anime/154587") { + return { + ok: true, + json: async () => ({ id: 154587, name: "Frieren" }), + }; + } + return { ok: false, json: async () => ({}) }; + }); + + const result = await fetchTvShowDetailClient(154587); + expect(result?.detail.name).toBe("Frieren"); + expect(result?.catalog).toBe("anime"); + }); +}); diff --git a/apps/web/__tests__/tv-watch-target.test.ts b/apps/web/__tests__/tv-watch-target.test.ts index 98e1172f..d954f14d 100644 --- a/apps/web/__tests__/tv-watch-target.test.ts +++ b/apps/web/__tests__/tv-watch-target.test.ts @@ -1,12 +1,18 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, beforeEach } from "vitest"; import type { Episode } from "@/lib/domain/typings"; import type { WatchlistItem } from "@/lib/domain/watchlist"; -import { setPlaybackProgress } from "@/lib/playback/progress-storage"; +import { + LAST_TV_EPISODE_STORAGE_KEY, + PLAYBACK_PROGRESS_STORAGE_KEY, + rememberLastTvEpisode, + setPlaybackProgress, +} from "@/lib/playback/progress-storage"; import { formatTvWatchLabel, isTvPlaybackResume, isTvWatchlistResume, + resolveLocalTvWatchCoords, resolveTvWatchTarget, type TvWatchTarget, } from "@/lib/tv-watch-target"; @@ -87,4 +93,69 @@ describe("tv watch resume", () => { expect(isTvPlaybackResume(selectionTarget, 140_391)).toBe(true); }); + it("does not override an explicit season browse with saved progress", () => { + const target = resolveTvWatchTarget( + 21, + { selectedEpisode: null, tvShowId: "anilist-21", seasonNumber: 21 }, + null, + null, + null, + { seasonNumber: 1, episodeNumber: 4 }, + ); + + expect(target).toBeNull(); + }); +}); + +describe("local last-episode memory", () => { + beforeEach(() => { + window.localStorage.removeItem(LAST_TV_EPISODE_STORAGE_KEY); + window.localStorage.removeItem(PLAYBACK_PROGRESS_STORAGE_KEY); + }); + + it("resolves remembered season and episode without a watchlist row", () => { + rememberLastTvEpisode(140_391, 2, 5); + + expect(resolveLocalTvWatchCoords(140_391)).toEqual({ + seasonNumber: 2, + episodeNumber: 5, + }); + + const target = resolveTvWatchTarget( + 140_391, + { selectedEpisode: null, tvShowId: null, seasonNumber: null }, + null, + null, + null, + resolveLocalTvWatchCoords(140_391), + ); + + expect(target).toEqual({ + source: "progress", + seasonNumber: 2, + episodeNumber: 5, + }); + expect(formatTvWatchLabel(target!, undefined, { resume: true })).toBe( + "Resume S2E5", + ); + }); + + it("prefers this-device progress over a stale watchlist episode", () => { + rememberLastTvEpisode(140_391, 3, 1); + + const target = resolveTvWatchTarget( + 140_391, + { selectedEpisode: null, tvShowId: null, seasonNumber: null }, + watchlistItem(1, 2), + null, + null, + resolveLocalTvWatchCoords(140_391), + ); + + expect(target).toEqual({ + source: "progress", + seasonNumber: 3, + episodeNumber: 1, + }); + }); }); diff --git a/apps/web/__tests__/use-local-tv-watch-coords.test.ts b/apps/web/__tests__/use-local-tv-watch-coords.test.ts new file mode 100644 index 00000000..c543b790 --- /dev/null +++ b/apps/web/__tests__/use-local-tv-watch-coords.test.ts @@ -0,0 +1,42 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useLocalTvWatchCoords } from "@/hooks/use-local-tv-watch-coords"; +import { notifyPlaybackProgressChanged } from "@/lib/playback/progress-change-events"; +import * as tvWatchTarget from "@/lib/tv-watch-target"; + +vi.mock("@/lib/tv-watch-target", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveLocalTvWatchCoords: vi.fn(), + }; +}); + +describe("useLocalTvWatchCoords", () => { + beforeEach(() => { + vi.mocked(tvWatchTarget.resolveLocalTvWatchCoords).mockReset(); + }); + + it("re-reads coords when playback progress changes", async () => { + vi.mocked(tvWatchTarget.resolveLocalTvWatchCoords) + .mockReturnValueOnce(null) + .mockReturnValueOnce({ seasonNumber: 1, episodeNumber: 3 }); + + const { result } = renderHook(() => useLocalTvWatchCoords(123)); + + await waitFor(() => { + expect(tvWatchTarget.resolveLocalTvWatchCoords).toHaveBeenCalledWith(123); + }); + expect(result.current).toBeNull(); + + act(() => { + notifyPlaybackProgressChanged(); + }); + + await waitFor(() => { + expect(result.current).toEqual({ seasonNumber: 1, episodeNumber: 3 }); + }); + expect(tvWatchTarget.resolveLocalTvWatchCoords).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/__tests__/use-movie-watch-button-label.test.ts b/apps/web/__tests__/use-movie-watch-button-label.test.ts new file mode 100644 index 00000000..53b034d6 --- /dev/null +++ b/apps/web/__tests__/use-movie-watch-button-label.test.ts @@ -0,0 +1,56 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useMovieWatchButtonLabel } from "@/hooks/use-movie-watch-button-label"; +import { notifyPlaybackProgressChanged } from "@/lib/playback/progress-change-events"; +import * as movieWatchLabel from "@/lib/playback/movie-watch-label"; + +vi.mock("@/lib/playback/movie-watch-label", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + movieWatchButtonLabel: vi.fn(), + }; +}); + +describe("useMovieWatchButtonLabel", () => { + beforeEach(() => { + vi.mocked(movieWatchLabel.movieWatchButtonLabel).mockReset(); + }); + + it("re-reads the label when playback progress changes", async () => { + vi.mocked(movieWatchLabel.movieWatchButtonLabel) + .mockReturnValueOnce("Play") + .mockReturnValueOnce("Resume"); + + const { result } = renderHook(() => + useMovieWatchButtonLabel(424_242, "movie"), + ); + + await waitFor(() => { + expect(movieWatchLabel.movieWatchButtonLabel).toHaveBeenCalledWith( + 424_242, + ); + }); + expect(result.current).toBe("Play"); + + act(() => { + notifyPlaybackProgressChanged(); + }); + + await waitFor(() => { + expect(result.current).toBe("Resume"); + }); + expect(movieWatchLabel.movieWatchButtonLabel).toHaveBeenCalledTimes(2); + }); + + it("returns Play for non-movie media types", () => { + const { result } = renderHook(() => + useMovieWatchButtonLabel(424_242, "tv"), + ); + + expect(result.current).toBe("Play"); + expect(movieWatchLabel.movieWatchButtonLabel).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/use-validated-hero-backdrop.test.ts b/apps/web/__tests__/use-validated-hero-backdrop.test.ts index 32794fea..c601cfe3 100644 --- a/apps/web/__tests__/use-validated-hero-backdrop.test.ts +++ b/apps/web/__tests__/use-validated-hero-backdrop.test.ts @@ -27,7 +27,7 @@ describe("useValidatedHeroBackdrop", () => { vi.restoreAllMocks(); }); - it("shows backdrop optimistically on first paint while validating", () => { + it("shows backdrop optimistically on first paint while validating", async () => { vi.stubGlobal("Image", MockImage); const { result } = renderHook(() => @@ -40,6 +40,10 @@ describe("useValidatedHeroBackdrop", () => { expect(result.current.usePosterHero).toBe(false); expect(result.current.showBackdropImage).toBe(true); + + await waitFor(() => { + expect(result.current.isValidating).toBe(false); + }); }); it("accepts widescreen backdrops for anime routes", async () => { diff --git a/apps/web/__tests__/vidsrc-progress-storage.test.ts b/apps/web/__tests__/vidsrc-progress-storage.test.ts new file mode 100644 index 00000000..0ed420ea --- /dev/null +++ b/apps/web/__tests__/vidsrc-progress-storage.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + persistVidsrcProgressPayload, + readVidsrcProgressEntries, + readVidsrcProgressMap, + VIDSRC_PROGRESS_STORAGE_KEY, + type VidsrcProgressEntry, +} from "@/lib/playback/vidsrc-progress-storage"; + +const movieEntry = (id: string, lastUpdated: number): VidsrcProgressEntry => ({ + id, + type: "movie", + last_updated: lastUpdated, +}); + +describe("persistVidsrcProgressPayload", () => { + beforeEach(() => { + window.localStorage.removeItem(VIDSRC_PROGRESS_STORAGE_KEY); + }); + + it("merges a single entry into an existing map", () => { + persistVidsrcProgressPayload({ + "550": movieEntry("550", 100), + "1399": movieEntry("1399", 200), + }); + + persistVidsrcProgressPayload(movieEntry("551", 300)); + + expect(readVidsrcProgressEntries()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "550" }), + expect.objectContaining({ id: "1399" }), + expect.objectContaining({ id: "551" }), + ]), + ); + expect(readVidsrcProgressEntries()).toHaveLength(3); + }); + + it("keeps the newer entry when a single update is older", () => { + persistVidsrcProgressPayload(movieEntry("550", 500)); + persistVidsrcProgressPayload(movieEntry("550", 100)); + + const entries = readVidsrcProgressEntries(); + expect(entries).toHaveLength(1); + expect(entries[0]?.last_updated).toBe(500); + }); + + it("replaces storage when the embed sends a full map", () => { + persistVidsrcProgressPayload({ + "550": movieEntry("550", 100), + "1399": movieEntry("1399", 200), + }); + + persistVidsrcProgressPayload({ + "551": movieEntry("551", 300), + }); + + const map = readVidsrcProgressMap(); + expect(Object.keys(map)).toEqual(["551"]); + }); + + it("normalizes a legacy single-entry payload when reading", () => { + window.localStorage.setItem( + VIDSRC_PROGRESS_STORAGE_KEY, + JSON.stringify(movieEntry("550", 100)), + ); + + expect(readVidsrcProgressEntries()).toEqual([ + expect.objectContaining({ id: "550" }), + ]); + }); +}); diff --git a/apps/web/__tests__/watchlist-api.test.ts b/apps/web/__tests__/watchlist-api.test.ts index fc7843e8..8dfd722c 100644 --- a/apps/web/__tests__/watchlist-api.test.ts +++ b/apps/web/__tests__/watchlist-api.test.ts @@ -1,5 +1,6 @@ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { suppressConsole } from "@/test/suppress-console"; vi.mock("@/auth", () => { return { @@ -119,9 +120,11 @@ describe("Watchlist API Routes", () => { mockDb.select = mockSelect; + const restoreConsole = suppressConsole("error"); const request = new NextRequest("http://localhost/api/watchlist"); const response = await GET(request); const data = await response.json(); + restoreConsole(); expect(response.status).toBe(500); expect(data.error).toBe("Internal server error"); @@ -340,8 +343,10 @@ describe("Watchlist API Routes", () => { }), }); + const restoreConsole = suppressConsole("error"); const response = await POST(request); const data = await response.json(); + restoreConsole(); expect(response.status).toBe(500); expect(data.error).toBe("Internal server error"); diff --git a/apps/web/__tests__/watchlist-button.test.tsx b/apps/web/__tests__/watchlist-button.test.tsx index b35395a1..239981dd 100644 --- a/apps/web/__tests__/watchlist-button.test.tsx +++ b/apps/web/__tests__/watchlist-button.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { useSession } from "next-auth/react"; import { toast } from "sonner"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { suppressConsole } from "@/test/suppress-console"; vi.mock("next-auth/react", () => ({ useSession: vi.fn(), @@ -393,6 +394,7 @@ describe("WatchlistButton", () => { }); const button = screen.getByTestId("watchlist-button-add"); + const restoreConsole = suppressConsole("error"); await user.click(button); await waitFor(() => { @@ -400,6 +402,7 @@ describe("WatchlistButton", () => { "Failed to add to watchlist", ); }); + restoreConsole(); }); test("does not fetch when mediaType is missing", async () => { diff --git a/apps/web/app/anime/[id]/opengraph-image.tsx b/apps/web/app/anime/[id]/opengraph-image.tsx index 1a7bbdf3..be3cb262 100644 --- a/apps/web/app/anime/[id]/opengraph-image.tsx +++ b/apps/web/app/anime/[id]/opengraph-image.tsx @@ -1,3 +1,4 @@ +import { getCachedAnilistTvShowDetail } from "@/lib/anilist-tv-detail"; import { fetchOgTvDetail } from "@/lib/seo/og-tmdb-fetch"; import { resolveMediaOgImageProps } from "@/lib/seo/og-remote-image"; import { @@ -7,6 +8,7 @@ import { ogImageContentType, } from "@/lib/seo/og-image"; import { renderCachedOgImage } from "@/lib/seo/og-render"; +import { unwrapTmdbLookupId } from "@/lib/tmdb-anime-route-id"; export const alt = "Anime on NyumatFlix"; export const size = OG_IMAGE_SIZE; @@ -21,7 +23,11 @@ export default async function Image({ params }: Props) { const { id } = await params; return renderCachedOgImage(`anime:${id}`, async () => { - const tvShow = await fetchOgTvDetail(id); + const anilistShow = await getCachedAnilistTvShowDetail(id, { + acceptBareNumeric: true, + }); + const tvShow = + anilistShow ?? (await fetchOgTvDetail(unwrapTmdbLookupId(id))); if (!tvShow) { return ; diff --git a/apps/web/app/anime/[id]/page.tsx b/apps/web/app/anime/[id]/page.tsx index 7a5ba237..7723f421 100644 --- a/apps/web/app/anime/[id]/page.tsx +++ b/apps/web/app/anime/[id]/page.tsx @@ -20,7 +20,9 @@ export default async function AnimeDetailPage(props: Props) { return ( <> {tvShow ? ( - + ) : null} @@ -36,6 +38,7 @@ export async function generateMetadata(props: Props): Promise { media: tvShow, mediaType: "tv", mediaId: id, + path: `/anime/${id}`, }); if (!tvShow || !("name" in tvShow)) return base; const year = tvShow.first_air_date diff --git a/apps/web/app/api/anime/[id]/route.ts b/apps/web/app/api/anime/[id]/route.ts new file mode 100644 index 00000000..dfbe3ef4 --- /dev/null +++ b/apps/web/app/api/anime/[id]/route.ts @@ -0,0 +1,44 @@ +import { rejectUnlessCapAllowed } from "@/lib/api/cap-route-guard"; +import { catalogCacheHeaders } from "@/lib/http-cache"; +import { getCachedAnilistTvShowDetail } from "@/lib/anilist-tv-detail"; +import { isAnimeAnilistRouteId } from "@/lib/anilist-route-id"; +import { NextResponse } from "next/server"; + +export async function GET( + request: Request, + props: { params: Promise<{ id: string }> }, +) { + const capDenied = await rejectUnlessCapAllowed(request); + if (capDenied) return capDenied; + + const { id } = await props.params; + + if (!id) { + return NextResponse.json( + { error: "Anime ID is required" }, + { status: 400 }, + ); + } + + if (!isAnimeAnilistRouteId(id)) { + return NextResponse.json({ error: "Anime not found" }, { status: 404 }); + } + + try { + const details = await getCachedAnilistTvShowDetail(id, { + acceptBareNumeric: true, + }); + + if (!details) { + return NextResponse.json({ error: "Anime not found" }, { status: 404 }); + } + + return NextResponse.json(details, { headers: catalogCacheHeaders() }); + } catch (error) { + console.error(`Error fetching anime details for ID ${id}:`, error); + return NextResponse.json( + { error: "Failed to fetch anime details" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/anime/[id]/season/[seasonNumber]/route.ts b/apps/web/app/api/anime/[id]/season/[seasonNumber]/route.ts new file mode 100644 index 00000000..9a5dee64 --- /dev/null +++ b/apps/web/app/api/anime/[id]/season/[seasonNumber]/route.ts @@ -0,0 +1,44 @@ +import { rejectUnlessCapAllowed } from "@/lib/api/cap-route-guard"; +import { seasonCacheHeaders } from "@/lib/http-cache"; +import { getCachedAnilistTvSeasonDetails } from "@/lib/anilist-tv-detail"; +import { isAnimeAnilistRouteId } from "@/lib/anilist-route-id"; +import { NextResponse } from "next/server"; + +export async function GET( + request: Request, + props: { params: Promise<{ id: string; seasonNumber: string }> }, +) { + const capDenied = await rejectUnlessCapAllowed(request); + if (capDenied) return capDenied; + + const { id, seasonNumber } = await props.params; + + if (!id || !isAnimeAnilistRouteId(id)) { + return NextResponse.json({ error: "Anime not found" }, { status: 404 }); + } + + const parsedSeasonNumber = Number.parseInt(seasonNumber, 10); + if (!Number.isInteger(parsedSeasonNumber) || parsedSeasonNumber < 0) { + return NextResponse.json( + { error: "Invalid season number" }, + { status: 400 }, + ); + } + + try { + const data = await getCachedAnilistTvSeasonDetails(id, parsedSeasonNumber, { + acceptBareNumeric: true, + }); + if (!data) { + return NextResponse.json({ error: "Season not found" }, { status: 404 }); + } + + return NextResponse.json(data, { headers: seasonCacheHeaders() }); + } catch (error) { + console.error("Error fetching anime season details:", error); + return NextResponse.json( + { error: "Failed to fetch season details" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/ffs/flags/route.ts b/apps/web/app/api/ffs/flags/route.ts index 43354255..514c1ae9 100644 --- a/apps/web/app/api/ffs/flags/route.ts +++ b/apps/web/app/api/ffs/flags/route.ts @@ -22,7 +22,6 @@ import { type AdminFlagState, } from "@/lib/flags/flag-catalog"; import { assertFfsHost } from "@/lib/ffs/require-ffs-host"; -import { revalidateSiteFlagsCache } from "@/lib/flags/revalidate-site-flags"; export async function GET(request: NextRequest) { if (!assertFfsHost(request)) { @@ -95,7 +94,6 @@ export async function PATCH(request: NextRequest) { try { await writeAdminFlagState(flags, announcementBanner, providerMenuOrder); - revalidateSiteFlagsCache(); return NextResponse.json({ flags, announcementBanner, diff --git a/apps/web/app/api/map/route.ts b/apps/web/app/api/map/route.ts index 41ffc721..dd5e3c46 100644 --- a/apps/web/app/api/map/route.ts +++ b/apps/web/app/api/map/route.ts @@ -9,6 +9,7 @@ import { buildAniBridgeSeasonSegments, getAniBridgeMappings, } from "@/lib/anime/anibridge-mappings"; +import { extendSeasonMapSegments } from "@/lib/anime/map-segment-appendix"; import { buildUnknownEpisodeCountSegment, inferMappingConfidence, @@ -119,6 +120,23 @@ const cache = new Map< >(); const CACHE_TTL = process.env.NODE_ENV === "development" ? 0 : 60 * 60 * 1000; const MAX_CACHE_ENTRIES = 250; +const inflightRequests = new Map>(); + +async function coalesceCachedRequest( + key: string, + load: () => Promise, +): Promise { + const pending = inflightRequests.get(key); + if (pending) { + return pending as Promise; + } + + const promise = load().finally(() => { + inflightRequests.delete(key); + }); + inflightRequests.set(key, promise); + return promise; +} function getCached(key: string) { const cached = cache.get(key); @@ -193,25 +211,39 @@ async function fetchTmdbShow(showId: number): Promise { "id" in cached && "name" in cached && "first_air_date" in cached - ) + ) { return cached as TmdbShow; + } - try { - const response = await fetch( - `https://api.themoviedb.org/3/tv/${showId}?api_key=${process.env.TMDB_API_KEY}&language=en-US`, - ); - - if (!response.ok) { - throw new Error(`TMDB API error: ${response.status}`); + return coalesceCachedRequest(cacheKey, async () => { + const cachedAgain = getCached(cacheKey); + if ( + cachedAgain && + typeof cachedAgain === "object" && + "id" in cachedAgain && + "name" in cachedAgain && + "first_air_date" in cachedAgain + ) { + return cachedAgain as TmdbShow; } - const data = await response.json(); - setCached(cacheKey, data); - return data; - } catch (error) { - console.error(`Error fetching TMDB show ${showId}:`, error); - return null; - } + try { + const response = await fetch( + `https://api.themoviedb.org/3/tv/${showId}?api_key=${process.env.TMDB_API_KEY}&language=en-US`, + ); + + if (!response.ok) { + throw new Error(`TMDB API error: ${response.status}`); + } + + const data = await response.json(); + setCached(cacheKey, data); + return data as TmdbShow; + } catch (error) { + console.error(`Error fetching TMDB show ${showId}:`, error); + return null; + } + }); } async function fetchTmdbSeason( @@ -226,32 +258,46 @@ async function fetchTmdbSeason( "season_number" in cached && "episode_count" in cached && "episodes" in cached - ) + ) { return cached as TmdbSeason; + } - try { - const response = await fetch( - `https://api.themoviedb.org/3/tv/${showId}/season/${seasonNumber}?api_key=${process.env.TMDB_API_KEY}&language=en-US`, - { cache: "no-store" }, - ); - - if (!response.ok) { - throw new Error(`TMDB API error: ${response.status}`); + return coalesceCachedRequest(cacheKey, async () => { + const cachedAgain = getCached(cacheKey); + if ( + cachedAgain && + typeof cachedAgain === "object" && + "season_number" in cachedAgain && + "episode_count" in cachedAgain && + "episodes" in cachedAgain + ) { + return cachedAgain as TmdbSeason; } - const data = toSlimTmdbSeason(await response.json()); - if (!data) { - throw new Error("Invalid TMDB season response"); + try { + const response = await fetch( + `https://api.themoviedb.org/3/tv/${showId}/season/${seasonNumber}?api_key=${process.env.TMDB_API_KEY}&language=en-US`, + { cache: "no-store" }, + ); + + if (!response.ok) { + throw new Error(`TMDB API error: ${response.status}`); + } + + const data = toSlimTmdbSeason(await response.json()); + if (!data) { + throw new Error("Invalid TMDB season response"); + } + setCached(cacheKey, data); + return data; + } catch (error) { + console.error( + `Error fetching TMDB season ${showId}/${seasonNumber}:`, + error, + ); + return null; } - setCached(cacheKey, data); - return data; - } catch (error) { - console.error( - `Error fetching TMDB season ${showId}/${seasonNumber}:`, - error, - ); - return null; - } + }); } async function fetchAniListMediaById( @@ -337,12 +383,18 @@ async function fetchAniListCandidates( return []; } - try { - const candidates: AnilistMediaExtended[] = []; - const primaryId = await fetchAnilistId(title); + return coalesceCachedRequest(cacheKey, async () => { + const cachedAgain = getCached(cacheKey); + if (cachedAgain && Array.isArray(cachedAgain)) { + return cachedAgain as AnilistMediaExtended[]; + } - if (primaryId) { - const detailQuery = ` + try { + const candidates: AnilistMediaExtended[] = []; + const primaryId = await fetchAnilistId(title); + + if (primaryId) { + const detailQuery = ` query ($id: Int) { Media(id: $id, type: ANIME) { id @@ -368,82 +420,83 @@ async function fetchAniListCandidates( } `; - const response = await fetch("https://graphql.anilist.co", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: detailQuery, - variables: { id: primaryId }, - }), - }); - - if (response.ok) { - const data: AnilistResponseExtended = await response.json(); - if (data.data.Media) { - candidates.push(data.data.Media); + const response = await fetch("https://graphql.anilist.co", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: detailQuery, + variables: { id: primaryId }, + }), + }); + + if (response.ok) { + const data: AnilistResponseExtended = await response.json(); + if (data.data.Media) { + candidates.push(data.data.Media); + } } - } - const franchise = await resolveAniListFranchise(primaryId); - const related = await Promise.all( - franchise.seasons - .filter(({ anilistId }) => anilistId !== primaryId) - .map(({ anilistId }) => fetchAniListMediaById(anilistId)), - ); - for (const candidate of related) { - if (candidate && candidate.format !== "MOVIE") { - candidates.push(candidate); + const franchise = await resolveAniListFranchise(primaryId); + const related = await Promise.all( + franchise.seasons + .filter(({ anilistId }) => anilistId !== primaryId) + .map(({ anilistId }) => fetchAniListMediaById(anilistId)), + ); + for (const candidate of related) { + if (candidate && candidate.format !== "MOVIE") { + candidates.push(candidate); + } } - } - if (seasonNumber) { - const seasonVariations = [ - `${title} Season ${seasonNumber}`, - `${title} Final Season`, - `${title} The Final Season`, - ]; - - for (const variation of seasonVariations) { - const variationId = await fetchAnilistId(variation); - if ( - variationId && - variationId !== primaryId && - !candidates.find((c) => c.id === variationId) - ) { - const variationResponse = await fetch( - "https://graphql.anilist.co", - { - method: "POST", - headers: { - "Content-Type": "application/json", + if (seasonNumber) { + const seasonVariations = [ + `${title} Season ${seasonNumber}`, + `${title} Final Season`, + `${title} The Final Season`, + ]; + + for (const variation of seasonVariations) { + const variationId = await fetchAnilistId(variation); + if ( + variationId && + variationId !== primaryId && + !candidates.find((c) => c.id === variationId) + ) { + const variationResponse = await fetch( + "https://graphql.anilist.co", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: detailQuery, + variables: { id: variationId }, + }), }, - body: JSON.stringify({ - query: detailQuery, - variables: { id: variationId }, - }), - }, - ); - - if (variationResponse.ok) { - const variationData: AnilistResponseExtended = - await variationResponse.json(); - if (variationData.data.Media) { - candidates.push(variationData.data.Media); + ); + + if (variationResponse.ok) { + const variationData: AnilistResponseExtended = + await variationResponse.json(); + if (variationData.data.Media) { + candidates.push(variationData.data.Media); + } } } } } } - } - setCached(cacheKey, candidates); - return candidates; - } catch (error) { - console.error(`Error fetching AniList candidates for "${title}":`, error); - return []; - } + setCached(cacheKey, candidates); + return candidates; + } catch (error) { + console.error(`Error fetching AniList candidates for "${title}":`, error); + return []; + } + }); } function resolveMappings( @@ -589,6 +642,32 @@ export async function GET(request: NextRequest) { ); } + const resolvedSeasonNumber = + seasonNumber ?? tmdbSeasonData?.season_number ?? 1; + const anibridgeMappings = await getAniBridgeMappings(); + const anibridgeSegments = buildAniBridgeSeasonSegments( + anibridgeMappings, + showId, + resolvedSeasonNumber, + ); + + if (anibridgeSegments.length > 0) { + const segments = await extendSeasonMapSegments( + anibridgeSegments, + showId, + resolvedSeasonNumber, + ); + const response: MapResponse = { + tmdbShowId: showId, + tmdbSeason: seasonNumber, + segments, + confidence: "high", + isAdult: false, + source: "anibridge", + }; + return NextResponse.json(response, { headers: catalogCacheHeaders() }); + } + const mediaItemAdapter: MediaItem = { id: tmdbShow.id, name: tmdbShow.name, @@ -642,21 +721,9 @@ export async function GET(request: NextRequest) { let segments: MappingSegment[] = []; let debugInfo: DebugInfo | undefined = undefined; - let source = "date+title heuristic"; + const source = "date+title heuristic"; - const anibridgeMappings = await getAniBridgeMappings(); - const resolvedSeasonNumber = - seasonNumber ?? tmdbSeasonData?.season_number ?? 1; - const anibridgeSegments = buildAniBridgeSeasonSegments( - anibridgeMappings, - showId, - resolvedSeasonNumber, - ); - - if (anibridgeSegments.length > 0) { - segments = anibridgeSegments; - source = "anibridge"; - } else if (tmdbSeasonData) { + if (tmdbSeasonData) { const result = resolveMappings( tmdbShow, tmdbSeasonData, @@ -679,22 +746,24 @@ export async function GET(request: NextRequest) { } } - const confidence = - source === "anibridge" - ? "high" - : inferMappingConfidence( - segments, - orderedCandidates.length, - sourceAnilistId, - ); + const confidence = inferMappingConfidence( + segments, + orderedCandidates.length, + sourceAnilistId, + ); const isAdult = orderedCandidates.some( (candidate) => candidate.isAdult === true, ); + const extendedSegments = + tmdbSeasonData && segments.length > 0 + ? await extendSeasonMapSegments(segments, showId, resolvedSeasonNumber) + : segments; + const response: MapResponse = { tmdbShowId: showId, tmdbSeason: seasonNumber, - segments, + segments: extendedSegments, confidence, isAdult, source, diff --git a/apps/web/app/api/media/[mediaType]/[id]/[resource]/route.ts b/apps/web/app/api/media/[mediaType]/[id]/[resource]/route.ts index 659a97c6..0592cce4 100644 --- a/apps/web/app/api/media/[mediaType]/[id]/[resource]/route.ts +++ b/apps/web/app/api/media/[mediaType]/[id]/[resource]/route.ts @@ -5,6 +5,7 @@ import { getCachedAnilistTvRecommendations, getCachedAnilistTvShowDetail, } from "@/lib/anilist-tv-detail"; +import { unwrapTmdbLookupId } from "@/lib/tmdb-anime-route-id"; import { isAnilistBackedTvRouteId, readTvDetailCatalogFromRequestUrl, @@ -91,8 +92,11 @@ export async function GET( try { const typedMediaType = mediaType as MediaType; const catalog = readTvDetailCatalogFromRequestUrl(url); + const tmdbLookupId = unwrapTmdbLookupId(id); const isAnilistTv = - typedMediaType === "tv" && isAnilistBackedTvRouteId(id, catalog); + typedMediaType === "tv" && + tmdbLookupId === id && + isAnilistBackedTvRouteId(id, catalog); const anilistResolveOptions = catalog === "anime" ? { acceptBareNumeric: true as const } : undefined; const mediaApi = typedMediaType === "movie" ? tmdb.movie : tmdb.tv; @@ -101,7 +105,7 @@ export async function GET( case "above-fold": { const detail = isAnilistTv ? await getCachedAnilistTvAboveFoldDetail(id, anilistResolveOptions) - : await getCachedMediaAboveFoldDetail(typedMediaType, id); + : await getCachedMediaAboveFoldDetail(typedMediaType, tmdbLookupId); if (!detail) { return NextResponse.json( { error: "Media not found" }, @@ -113,10 +117,10 @@ export async function GET( case "details": { const details = typedMediaType === "movie" - ? await getCachedMovieDetail(id) + ? await getCachedMovieDetail(tmdbLookupId) : isAnilistTv ? await getCachedAnilistTvShowDetail(id, anilistResolveOptions) - : await getCachedTvShowDetail(id); + : await getCachedTvShowDetail(tmdbLookupId); if (!details) { return NextResponse.json( { error: "Media not found" }, @@ -140,13 +144,15 @@ export async function GET( ); } - const details = await getCachedTvShowDetail(id); + const details = await getCachedTvShowDetail(tmdbLookupId); if (!details) { return jsonCached({}, "all-seasons"); } return jsonCached( - await fetchAllSeasonDetails(id, details.seasons, { catalog }), + await fetchAllSeasonDetails(tmdbLookupId, details.seasons, { + catalog, + }), "all-seasons", ); } @@ -154,7 +160,7 @@ export async function GET( return jsonCached( isAnilistTv ? await getCachedAnilistTvCredits(id, anilistResolveOptions) - : await mediaApi.credits({ id }), + : await mediaApi.credits({ id: tmdbLookupId }), "credits", ); case "images": @@ -165,7 +171,7 @@ export async function GET( ); } return jsonCached( - await mediaApi.images({ id, langs: "en,null" }), + await mediaApi.images({ id: tmdbLookupId, langs: "en,null" }), "images", ); case "videos": @@ -179,7 +185,10 @@ export async function GET( "videos", ); } - return jsonCached(await mediaApi.videos({ id }), "videos"); + return jsonCached( + await mediaApi.videos({ id: tmdbLookupId }), + "videos", + ); case "reviews": if (isAnilistTv) { return jsonCached( @@ -187,12 +196,15 @@ export async function GET( "reviews", ); } - return jsonCached(await mediaApi.reviews({ id, page }), "reviews"); + return jsonCached( + await mediaApi.reviews({ id: tmdbLookupId, page }), + "reviews", + ); case "recommendations": return jsonCached( isAnilistTv ? await getCachedAnilistTvRecommendations(id, anilistResolveOptions) - : await mediaApi.recommendations({ id, page }), + : await mediaApi.recommendations({ id: tmdbLookupId, page }), "recommendations", ); case "similar": @@ -202,7 +214,10 @@ export async function GET( "similar", ); } - return jsonCached(await mediaApi.similar({ id, page }), "similar"); + return jsonCached( + await mediaApi.similar({ id: tmdbLookupId, page }), + "similar", + ); } } catch (error) { console.error( diff --git a/apps/web/app/api/site/flags/route.ts b/apps/web/app/api/site/flags/route.ts index 6b5de293..a24da11b 100644 --- a/apps/web/app/api/site/flags/route.ts +++ b/apps/web/app/api/site/flags/route.ts @@ -1,24 +1,15 @@ -import { DEV_CACHE_CONTROL, IS_DEV } from "@/lib/cache-policy"; -import { SITE_FLAGS_REVALIDATE_TAG } from "@/lib/flags/flags-sync"; +import { DEV_CACHE_CONTROL } from "@/lib/cache-policy"; import { getSiteFlags } from "@/lib/flags/site-flags"; -import { unstable_cache } from "next/cache"; import { NextResponse } from "next/server"; -const getCachedSiteFlags = IS_DEV - ? async () => getSiteFlags() - : unstable_cache(async () => getSiteFlags(), [SITE_FLAGS_REVALIDATE_TAG], { - revalidate: 30, - tags: [SITE_FLAGS_REVALIDATE_TAG], - }); +export const dynamic = "force-dynamic"; export async function GET() { try { - const flags = await getCachedSiteFlags(); + const flags = await getSiteFlags(); return NextResponse.json(flags, { headers: { - "Cache-Control": IS_DEV - ? DEV_CACHE_CONTROL - : "public, s-maxage=30, stale-while-revalidate=300", + "Cache-Control": DEV_CACHE_CONTROL, }, }); } catch (error) { diff --git a/apps/web/app/api/tv/[id]/route.ts b/apps/web/app/api/tv/[id]/route.ts index 517ad9c2..2122f208 100644 --- a/apps/web/app/api/tv/[id]/route.ts +++ b/apps/web/app/api/tv/[id]/route.ts @@ -1,8 +1,8 @@ import { rejectUnlessCapAllowed } from "@/lib/api/cap-route-guard"; import { catalogCacheHeaders } from "@/lib/http-cache"; +import { resolveTvShowDetailForApiRoute } from "@/lib/server/tvshow-api"; import { isTmdbNotFoundError } from "@/lib/tmdb-errors"; import { NextResponse } from "next/server"; -import { movieDb } from "@/lib/constants"; export async function GET( request: Request, @@ -22,7 +22,7 @@ export async function GET( } try { - const tvDetails = await movieDb.tvInfo({ id }); + const tvDetails = await resolveTvShowDetailForApiRoute(id); if (!tvDetails) { return NextResponse.json({ error: "TV show not found" }, { status: 404 }); diff --git a/apps/web/app/api/tv/[id]/season/[seasonNumber]/route.ts b/apps/web/app/api/tv/[id]/season/[seasonNumber]/route.ts index c0f161ff..e7d8d542 100644 --- a/apps/web/app/api/tv/[id]/season/[seasonNumber]/route.ts +++ b/apps/web/app/api/tv/[id]/season/[seasonNumber]/route.ts @@ -1,7 +1,6 @@ import { rejectUnlessCapAllowed } from "@/lib/api/cap-route-guard"; import { seasonCacheHeaders } from "@/lib/http-cache"; import { fetchSeasonDetailsServer } from "@/lib/server/tvshow-api"; -import { readTvDetailCatalogFromRequestUrl } from "@/lib/tv-detail-catalog"; import { NextResponse } from "next/server"; export async function GET( @@ -14,7 +13,6 @@ export async function GET( const params = await props.params; try { const { id, seasonNumber } = params; - const catalog = readTvDetailCatalogFromRequestUrl(new URL(request.url)); const parsedSeasonNumber = Number.parseInt(seasonNumber, 10); if (!Number.isInteger(parsedSeasonNumber) || parsedSeasonNumber < 0) { @@ -25,7 +23,7 @@ export async function GET( } const data = await fetchSeasonDetailsServer(id, parsedSeasonNumber, { - catalog, + source: "tmdb", }); if (!data) { return NextResponse.json({ error: "Season not found" }, { status: 404 }); diff --git a/apps/web/app/api/watchlist/check-episodes/route.ts b/apps/web/app/api/watchlist/check-episodes/route.ts index dd2d3e77..dacc2da5 100644 --- a/apps/web/app/api/watchlist/check-episodes/route.ts +++ b/apps/web/app/api/watchlist/check-episodes/route.ts @@ -9,6 +9,7 @@ import type { EpisodeInfo } from "@/lib/domain/episodes"; const cache = new Map(); const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours const MAX_CACHE_ENTRIES = 500; +const inflightRequests = new Map>(); type WatchlistRow = typeof watchlist.$inferSelect; @@ -29,6 +30,40 @@ function setCached(key: string, data: EpisodeInfo) { if (oldestKey) cache.delete(oldestKey); } +async function resolveEpisodeInfo( + userId: string, + item: WatchlistRow, +): Promise { + const cacheKey = makeCacheKey(userId, item); + const cached = getCached(cacheKey); + if (cached) { + return cached; + } + + const pending = inflightRequests.get(cacheKey); + if (pending) { + return pending; + } + + const promise = checkEpisodesForShow( + item.contentId, + item.lastWatchedSeason, + item.lastWatchedEpisode, + ) + .then((episodeInfo) => { + if (episodeInfo) { + setCached(cacheKey, episodeInfo); + } + return episodeInfo; + }) + .finally(() => { + inflightRequests.delete(cacheKey); + }); + + inflightRequests.set(cacheKey, promise); + return promise; +} + /** * Cache entries are scoped per user + show + progress so we refresh as soon as the * viewer logs a new episode or flips status (watching/waiting/finished). @@ -71,21 +106,7 @@ export async function GET(request: NextRequest) { const episodeData: Record = {}; const resolved = await runInChunks(scopedShows, async (item) => { - const cacheKey = makeCacheKey(userId, item); - let episodeInfo = getCached(cacheKey); - - if (!episodeInfo) { - episodeInfo = await checkEpisodesForShow( - item.contentId, - item.lastWatchedSeason, - item.lastWatchedEpisode, - ); - - if (episodeInfo) { - setCached(cacheKey, episodeInfo); - } - } - + const episodeInfo = await resolveEpisodeInfo(userId, item); return episodeInfo ? { contentId: item.contentId, episodeInfo } : null; }); diff --git a/apps/web/app/settings/settings-client.tsx b/apps/web/app/settings/settings-client.tsx index 8e9d0fa4..b43711fe 100644 --- a/apps/web/app/settings/settings-client.tsx +++ b/apps/web/app/settings/settings-client.tsx @@ -87,7 +87,7 @@ export function SettingsClient({ session }: SettingsClientProps) { const handleNoAdsModeChange = (enabled: boolean) => { setNoAdsMode(enabled); if (enabled) { - setSelectedServer(scrapeServer); + setSelectedServer(scrapeServer, { userInitiated: true }); } }; diff --git a/apps/web/components/anilist/anime-infinite-grid.tsx b/apps/web/components/anilist/anime-infinite-grid.tsx index 008672a2..10585aa4 100644 --- a/apps/web/components/anilist/anime-infinite-grid.tsx +++ b/apps/web/components/anilist/anime-infinite-grid.tsx @@ -5,6 +5,7 @@ import { MediaContentGrid } from "@/components/content/media-content-grid"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { AniListSearchParams } from "@/lib/anilist"; import { filterWithPosterPath } from "@/lib/media-poster-path"; +import { useScrollFetchLock } from "@/hooks/use-scroll-fetch-lock"; import type { MediaItem } from "@/lib/domain/typings"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -60,6 +61,7 @@ const AnimeInfiniteGridBody = ({ const [currentPage, setCurrentPage] = useState(initialPage); const [hasNextPage, setHasNextPage] = useState(initialHasNextPage); const [isLoading, setIsLoading] = useState(false); + const { beginFetch, endFetch, isCurrent } = useScrollFetchLock(); const sentinelRef = useRef(null); const items = useMemo( @@ -71,11 +73,23 @@ const AnimeInfiniteGridBody = ({ ); const fetchNextPage = useCallback(async () => { - if (isLoading || !hasNextPage) return; + if (!hasNextPage) { + return; + } + + const generation = beginFetch(); + if (generation === null) { + return; + } + + setIsLoading(true); try { - setIsLoading(true); const data = await fetchAnimeNextPage(params, currentPage + 1); + if (!isCurrent(generation)) { + return; + } + const raw = filterWithPosterPath(data.results ?? []); setStore((prev) => { @@ -102,9 +116,12 @@ const AnimeInfiniteGridBody = ({ } catch (error) { console.error("Anime infinite scroll error:", error); } finally { - setIsLoading(false); + endFetch(generation); + if (isCurrent(generation)) { + setIsLoading(false); + } } - }, [currentPage, hasNextPage, isLoading, params]); + }, [beginFetch, currentPage, endFetch, hasNextPage, isCurrent, params]); useEffect(() => { const observer = new IntersectionObserver( diff --git a/apps/web/components/catalog/catalog-infinite-grid.tsx b/apps/web/components/catalog/catalog-infinite-grid.tsx index 88f882fa..4bd2d90e 100644 --- a/apps/web/components/catalog/catalog-infinite-grid.tsx +++ b/apps/web/components/catalog/catalog-infinite-grid.tsx @@ -5,6 +5,7 @@ import { MediaContentGrid } from "@/components/content/media-content-grid"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { makeEntityKey } from "@/lib/catalog-page-dedupe"; import { filterWithPosterPath } from "@/lib/media-poster-path"; +import { useScrollFetchLock } from "@/hooks/use-scroll-fetch-lock"; import type { MediaItem } from "@/lib/domain/typings"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -55,6 +56,7 @@ const CatalogInfiniteGridBody = ({ ); const [currentPage, setCurrentPage] = useState(initialPage); const [isLoading, setIsLoading] = useState(false); + const { beginFetch, endFetch, isCurrent } = useScrollFetchLock(); const sentinelRef = useRef(null); const items = useMemo( @@ -66,12 +68,24 @@ const CatalogInfiniteGridBody = ({ ); const fetchNextPage = useCallback(async () => { - if (isLoading || currentPage >= totalPages) return; + if (currentPage >= totalPages) { + return; + } + + const generation = beginFetch(); + if (generation === null) { + return; + } + + setIsLoading(true); try { - setIsLoading(true); const nextPage = currentPage + 1; const data = await fetchCatalogNextPage(mediaType, queryParams, nextPage); + if (!isCurrent(generation)) { + return; + } + const raw = filterWithPosterPath( (data.results ?? []) as unknown as MediaItem[], ); @@ -97,9 +111,20 @@ const CatalogInfiniteGridBody = ({ } catch (error) { console.error("Catalog infinite scroll error:", error); } finally { - setIsLoading(false); + endFetch(generation); + if (isCurrent(generation)) { + setIsLoading(false); + } } - }, [currentPage, isLoading, mediaType, queryParams, totalPages]); + }, [ + beginFetch, + currentPage, + endFetch, + isCurrent, + mediaType, + queryParams, + totalPages, + ]); useEffect(() => { const observer = new IntersectionObserver( diff --git a/apps/web/components/discover/discover.tsx b/apps/web/components/discover/discover.tsx index af589020..fcd1b948 100644 --- a/apps/web/components/discover/discover.tsx +++ b/apps/web/components/discover/discover.tsx @@ -242,9 +242,16 @@ export const DiscoverFilterGenre: React.FC = ({ genres, onChange, }) => { - const [matchMode, setMatchMode] = React.useState<"or" | "and">(() => - value.includes(",") && !value.includes("|") ? "and" : "or", + const derivedMatchMode = + value.includes(",") && !value.includes("|") ? "and" : "or"; + const [matchMode, setMatchMode] = React.useState<"or" | "and">( + derivedMatchMode, ); + const [prevValue, setPrevValue] = React.useState(value); + if (value !== prevValue) { + setPrevValue(value); + setMatchMode(derivedMatchMode); + } const selectedIds = React.useMemo( () => value @@ -259,10 +266,6 @@ export const DiscoverFilterGenre: React.FC = ({ onChange, }); - React.useEffect(() => { - setMatchMode(value.includes(",") && !value.includes("|") ? "and" : "or"); - }, [value]); - const handleMatchModeChange = (nextMode: string) => { if (nextMode !== "or" && nextMode !== "and") return; setMatchMode(nextMode); diff --git a/apps/web/components/hero/hero-background.tsx b/apps/web/components/hero/hero-background.tsx index 95572235..e07b9b07 100644 --- a/apps/web/components/hero/hero-background.tsx +++ b/apps/web/components/hero/hero-background.tsx @@ -116,6 +116,7 @@ export function HeroBackground({ handleDirectPlaybackExhausted, handleRetryAllScraping, handleScrapePlaybackEnded, + awaitingAnimeCoords, } = scrapePlayback; const [mediaReady, setMediaReady] = useState(false); @@ -178,6 +179,21 @@ export function HeroBackground({ const canSwitchTrailers = trailerVideos.length > 1; const selectedEpisode = useEpisodeStore((state) => state.selectedEpisode); + const embedPlaybackRevision = useEpisodeStore((state) => { + if (!state.selectedEpisode) { + return ""; + } + + return [ + state.tvShowId, + state.seasonNumber, + state.selectedEpisode.id, + state.isAnimeEpisode, + state.anilistId, + state.relativeEpisodeNumber, + state.providerEpisodeNumber, + ].join("|"); + }); const preferPosterHero = typeof anilistId === "number" && @@ -305,6 +321,7 @@ export function HeroBackground({ [ animePreference, anilistId, + embedPlaybackRevision, getEmbedUrl, media.id, resolvedMediaType, @@ -570,8 +587,10 @@ export function HeroBackground({ onSelectEmbedServer={handleSelectEmbedServer} onRetryAllScraping={handleRetryAllScraping} onFatalError={handleScrapedPlaybackError} + onPlaybackStallFailover={handleScrapedPlaybackError} onDirectPlaybackExhausted={handleDirectPlaybackExhausted} onEnded={handleScrapePlaybackEnded} + isResolvingEpisode={awaitingAnimeCoords} isDirectMode={isDirectMode} directPlayback={directPlayback} onMediaReadyChange={setMediaReady} diff --git a/apps/web/components/hero/hero-buttons.tsx b/apps/web/components/hero/hero-buttons.tsx index 78aab7bc..40bdbbd5 100644 --- a/apps/web/components/hero/hero-buttons.tsx +++ b/apps/web/components/hero/hero-buttons.tsx @@ -4,8 +4,13 @@ import { fetchSeasonDetails } from "@/components/tvshow/tvshow-api"; import { MalListPopoverButton } from "@/components/media/mal-list-popover-button"; import { WatchlistButton } from "@/components/watchlist/watchlist"; import { pages } from "@/config/pages"; +import { useIsHydrated } from "@/hooks/use-is-hydrated"; +import { useLocalTvWatchCoords } from "@/hooks/use-local-tv-watch-coords"; +import { useMovieWatchButtonLabel } from "@/hooks/use-movie-watch-button-label"; import { useMalSyncStatus } from "@/hooks/use-mal-sync-status"; import { resolveEpisodeAnimeMapping } from "@/lib/anime/resolve-episode-mapping"; +import { resolveEpisodeAnimeSelection } from "@/lib/anime/episode-playback-source"; +import { resolveAnimeEpisodeMappingTmdbShowId } from "@/lib/anime/resolve-coords-tmdb-show-id"; import { isAnimeAnilistRouteId, resolveTvDetailRouteId, @@ -17,6 +22,7 @@ import { useMediaDetailTabStore } from "@/lib/stores/media-detail-tab-store"; import { formatTvWatchLabel, isSameTvWatchTarget, + isTvCoordsWatchTarget, isTvWatchlistResume, isTvPlaybackResume, resolveTvWatchTarget, @@ -32,9 +38,7 @@ import { } from "@/components/ui/tooltip"; import { Youtube } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { movieWatchButtonLabel } from "@/lib/playback/movie-watch-label"; - +import { useRef } from "react"; interface HeroButtonsProps { handleWatch(): void; handlePlayTrailer(): void; @@ -74,6 +78,7 @@ export function HeroButtons({ } = useEpisodeStore(); const router = useRouter(); const pathname = usePathname(); + const watchClickGenerationRef = useRef(0); const catalog = useTvDetailCatalog(); const malStatusQuery = useMalSyncStatus(); const malConnected = malStatusQuery.data?.connected === true; @@ -83,10 +88,9 @@ export function HeroButtons({ malConnected && mediaType === "tv" && (hasAnilistId || isAnimeCatalog || Boolean(defaultAnilistId)); - const [movieButtonLabel, setMovieButtonLabel] = useState<"Play" | "Resume">( - "Play", - ); - const [tvPlaybackResume, setTvPlaybackResume] = useState(false); + const isHydrated = useIsHydrated(); + const localCoords = useLocalTvWatchCoords(contentId); + const movieButtonLabel = useMovieWatchButtonLabel(contentId, mediaType); const watchTarget = mediaType === "tv" @@ -96,33 +100,18 @@ export function HeroButtons({ watchlistItem, initialEpisode, initialSeasonNumber, + localCoords, ) : null; + const tvPlaybackResume = + isHydrated && mediaType === "tv" && watchTarget != null + ? isTvPlaybackResume(watchTarget, contentId) + : false; const tvResume = watchTarget != null && - (isTvWatchlistResume(watchTarget, watchlistItem) || tvPlaybackResume); - - useEffect(() => { - if (mediaType !== "movie") { - return; - } - setMovieButtonLabel(movieWatchButtonLabel(contentId)); - }, [contentId, mediaType]); - - useEffect(() => { - if (mediaType !== "tv" || !watchTarget) { - setTvPlaybackResume(false); - return; - } - setTvPlaybackResume(isTvPlaybackResume(watchTarget, contentId)); - }, [ - contentId, - mediaType, - watchTarget, - watchlistItem?.lastWatchedEpisode, - watchlistItem?.lastWatchedSeason, - ]); - + (isTvWatchlistResume(watchTarget, watchlistItem) || + tvPlaybackResume || + watchTarget.source === "progress"); const showEpisodeList = () => { const id = resolveTvDetailRouteId(pathname, contentId); const catalogRoot = @@ -173,15 +162,22 @@ export function HeroButtons({ return; } + const generation = ++watchClickGenerationRef.current; + const isStale = () => generation !== watchClickGenerationRef.current; + let episode: Episode; let targetSeasonNumber: number; let seasonEpisodes: Episode[] | undefined; - if (watchTarget.source === "watchlist") { + if (isTvCoordsWatchTarget(watchTarget)) { const seasonData = await fetchSeasonDetails( String(contentId), watchTarget.seasonNumber, ); + if (isStale()) { + return; + } + const resolvedEpisode = seasonData?.episodes?.find( (item) => item.episode_number === watchTarget.episodeNumber, ); @@ -226,9 +222,18 @@ export function HeroButtons({ } | undefined; + const embeddedSelection = resolveEpisodeAnimeSelection(episode, { + animeSeasonNumber: targetSeasonNumber, + isAdult: defaultIsAdultAnime, + }); + if (embeddedSelection) { + animeInfo = embeddedSelection.animeInfo; + mapping = embeddedSelection.mapping; + } + const tvRouteId = resolveTvDetailRouteId(pathname, contentId); - if (defaultAnilistId && isAnimeAnilistRouteId(tvRouteId)) { + if (!animeInfo && defaultAnilistId && isAnimeAnilistRouteId(tvRouteId)) { const playbackTmdbTvId = storeState.playbackTmdbTvId; if (playbackTmdbTvId) { const coords = await resolveEpisodeAnimeMapping({ @@ -237,6 +242,9 @@ export function HeroButtons({ episodeNumber: episode.episode_number, isAdult: defaultIsAdultAnime, }); + if (isStale()) { + return; + } if (coords) { animeInfo = coords.animeInfo; mapping = { @@ -250,31 +258,66 @@ export function HeroButtons({ } if (!animeInfo) { - mapping = { - confidence: "low", - isAdult: defaultIsAdultAnime, + const fallbackSelection = resolveEpisodeAnimeSelection(episode, { animeSeasonNumber: targetSeasonNumber, - }; + isAdult: defaultIsAdultAnime, + }); + if (fallbackSelection) { + animeInfo = fallbackSelection.animeInfo; + mapping = fallbackSelection.mapping; + } else { + mapping = { + confidence: "low", + isAdult: defaultIsAdultAnime, + animeSeasonNumber: targetSeasonNumber, + }; + } + } + } else if (!animeInfo && defaultAnilistId) { + const mappingTmdbShowId = resolveAnimeEpisodeMappingTmdbShowId( + tvRouteId, + defaultAnilistId, + storeState.playbackTmdbTvId, + ); + + if (mappingTmdbShowId) { + const coords = await resolveEpisodeAnimeMapping({ + tmdbShowId: mappingTmdbShowId, + seasonNumber: targetSeasonNumber, + episodeNumber: episode.episode_number, + isAdult: defaultIsAdultAnime, + }); + if (isStale()) { + return; + } + if (coords) { + animeInfo = coords.animeInfo; + mapping = { + confidence: coords.confidence, + isAdult: coords.isAdult, + genres: coords.genres, + animeSeasonNumber: coords.animeSeasonNumber, + relativeEpisodeNumber: coords.relativeEpisodeNumber, + }; + } } - } else if (defaultAnilistId) { - const coords = await resolveEpisodeAnimeMapping({ - tmdbShowId: contentId, - seasonNumber: targetSeasonNumber, - episodeNumber: episode.episode_number, - isAdult: defaultIsAdultAnime, - }); - if (coords) { - animeInfo = coords.animeInfo; - mapping = { - confidence: coords.confidence, - isAdult: coords.isAdult, - genres: coords.genres, - animeSeasonNumber: coords.animeSeasonNumber, - relativeEpisodeNumber: coords.relativeEpisodeNumber, - }; + + if (!animeInfo) { + const fallbackSelection = resolveEpisodeAnimeSelection(episode, { + animeSeasonNumber: targetSeasonNumber, + isAdult: defaultIsAdultAnime, + }); + if (fallbackSelection) { + animeInfo = fallbackSelection.animeInfo; + mapping = fallbackSelection.mapping; + } } } + if (isStale()) { + return; + } + setSelectedEpisode( episode, tvRouteId, diff --git a/apps/web/components/hero/hero-content.tsx b/apps/web/components/hero/hero-content.tsx index 7fc768e2..08681745 100644 --- a/apps/web/components/hero/hero-content.tsx +++ b/apps/web/components/hero/hero-content.tsx @@ -16,12 +16,15 @@ import { import { AnimatePresence, motion } from "framer-motion"; import { Star, Volume2, VolumeX, X } from "lucide-react"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import React, { useLayoutEffect, useMemo, useRef } from "react"; import { useHeroAnimePrefs } from "@/hooks/use-hero-anime-prefs"; import { useAnilistAdultHint } from "@/hooks/use-anilist-adult-hint"; -import { useAnimeEpisodeMapping } from "@/hooks/use-anime-episode-mapping"; +import { useAnimeEpisodeCoords } from "@/hooks/use-anime-episode-coords"; +import { useLocalTvWatchCoords } from "@/hooks/use-local-tv-watch-coords"; import { useTvEpisodeHydrate } from "@/hooks/use-tv-episode-hydrate"; import { useWatchlistItem } from "@/hooks/useWatchlistItem"; +import { resolveTvDetailRouteId } from "@/lib/anilist-route-id"; import { tvCompleteMediaFromRecord } from "@/lib/playback/continue-watching-complete"; import { readMappedTmdbTvIdFromMedia } from "@/lib/tv-playback-tmdb-id"; import { HeroButtons } from "./hero-buttons"; @@ -86,12 +89,19 @@ export function HeroContent({ setWatchCallback, } = useEpisodeStore(); const scrapeChrome = useScrapeChrome(); + const pathname = usePathname(); + const mappedTmdbTvId = readMappedTmdbTvIdFromMedia(media); + const tvRouteId = useMemo( + () => resolveTvDetailRouteId(pathname, media.id), + [media.id, pathname], + ); const title = media.title || media.name; const isAnime = typeof anilistId === "number"; const resolvedMediaType = mediaType ?? "tv"; const { watchlistItem: watchlistItemLive, refetch: refetchWatchlist } = useWatchlistItem(media.id, resolvedMediaType); const effectiveWatchlistItem = watchlistItem ?? watchlistItemLive; + const localCoords = useLocalTvWatchCoords(media.id); const completeMedia = useMemo(() => { if (resolvedMediaType !== "tv") { return null; @@ -104,10 +114,14 @@ export function HeroContent({ mediaType, media.adult === true, media.original_name ?? media.name, - readMappedTmdbTvIdFromMedia(media), + mappedTmdbTvId, ); useAnilistAdultHint(anilistId, media.adult === true, mediaType); - useAnimeEpisodeMapping(); + useAnimeEpisodeCoords({ + tvRouteId, + mappedTmdbTvId, + enabled: resolvedMediaType === "tv", + }); useTvEpisodeHydrate({ mediaId: media.id, mediaType, @@ -255,10 +269,13 @@ export function HeroContent({ const isLastWatchedEpisode = displayEpisode != null && displaySeasonNumber != null && - watchlistItem?.lastWatchedSeason != null && - watchlistItem?.lastWatchedEpisode != null && - displaySeasonNumber === watchlistItem.lastWatchedSeason && - displayEpisode.episode_number === watchlistItem.lastWatchedEpisode; + ((watchlistItem?.lastWatchedSeason != null && + watchlistItem?.lastWatchedEpisode != null && + displaySeasonNumber === watchlistItem.lastWatchedSeason && + displayEpisode.episode_number === watchlistItem.lastWatchedEpisode) || + (localCoords != null && + displaySeasonNumber === localCoords.seasonNumber && + displayEpisode.episode_number === localCoords.episodeNumber)); return (
diff --git a/apps/web/components/hero/hero-scrape-player-panel.tsx b/apps/web/components/hero/hero-scrape-player-panel.tsx index f722e75d..486123b4 100644 --- a/apps/web/components/hero/hero-scrape-player-panel.tsx +++ b/apps/web/components/hero/hero-scrape-player-panel.tsx @@ -5,11 +5,13 @@ import { useCallback, useEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; import { useFeatureFlags } from "@/components/providers/feature-flags-provider"; +import { useMoviPreview } from "@/hooks/use-movi-preview"; import { PlaybackStartingOverlay } from "@/components/media/controls/playback-starting-overlay"; import { ScrapingOverlay } from "@/components/media/controls/scraping-overlay"; import { PlaybackErrorBoundary } from "@/components/media/playback-error-boundary"; @@ -17,11 +19,15 @@ import { ScrapePlayerShell } from "@/components/media/scrape-player-shell"; import type { UseDirectPlaybackReturn } from "@/hooks/use-direct-movie-playback"; import type { UseAnimeScrapeReturn } from "@/hooks/use-anime-scrape"; import { useMoviPlayerLoaded } from "@/hooks/use-movi-player-loaded"; +import { ANIME_PLAYBACK_RESOLVING_MESSAGE } from "@/lib/anime/anime-playback-policy"; import type { UseScrapeReturn } from "@/hooks/use-scrape"; import { selectInitialEngine } from "@nyumatflix/playback"; import type { PlaybackProgressKey } from "@/lib/playback/progress-storage"; import { USE_SHAKA_DASH } from "@/lib/constants"; -import { PLAYBACK_START_TIMEOUT_MS } from "@/lib/playback/playbackStart"; +import { + PLAYBACK_STALL_FAILOVER_MS, + PLAYBACK_START_TIMEOUT_MS, +} from "@/lib/playback/playbackStart"; import { buildScrapePlayerKey } from "@/lib/scrape/player-sources"; import type { SourceOverlayItem } from "@/lib/scrape/source-overlay"; import type { StreamKind } from "@/lib/scrape/stream-url-patterns"; @@ -48,6 +54,22 @@ const CalluspiratesStreamPlayer = dynamic( { ssr: false, loading: () => null }, ); +const MoviScrapePlayer = dynamic( + () => + import("@/components/media/movi-scrape-player").then( + (module) => module.MoviScrapePlayer, + ), + { ssr: false, loading: () => null }, +); + +const MoviDirectPlayer = dynamic( + () => + import("@/components/media/movi-direct-player").then( + (module) => module.MoviDirectPlayer, + ), + { ssr: false, loading: () => null }, +); + const ScrapeHlsPlayer = dynamic( () => import("@/components/media/scrape-hls-player").then( @@ -94,11 +116,13 @@ type HeroScrapePlayerPanelProps = { onSelectEmbedServer: (serverId: string) => void; onRetryAllScraping?: () => void; onFatalError: () => void; + onPlaybackStallFailover?: () => void; onDirectPlaybackExhausted?: () => void; onEnded?: () => Promise; isDirectMode?: boolean; directPlayback?: UseDirectPlaybackReturn; onMediaReadyChange?: (ready: boolean) => void; + isResolvingEpisode?: boolean; }; export function HeroScrapePlayerPanel({ @@ -117,13 +141,16 @@ export function HeroScrapePlayerPanel({ onSelectEmbedServer, onRetryAllScraping, onFatalError, + onPlaybackStallFailover, onDirectPlaybackExhausted, onEnded, isDirectMode = false, directPlayback, onMediaReadyChange, + isResolvingEpisode = false, }: HeroScrapePlayerPanelProps) { const { maintenanceMode } = useFeatureFlags(); + const moviPreview = useMoviPreview(); const [mediaReady, setMediaReady] = useState(false); const [playbackStartError, setPlaybackStartError] = useState( null, @@ -146,11 +173,18 @@ export function HeroScrapePlayerPanel({ scrapeResult?.providerId, ]); + const onMediaReadyChangeRef = useRef(onMediaReadyChange); + const onPlaybackStallFailoverRef = useRef(onPlaybackStallFailover); + const onFatalErrorRef = useRef(onFatalError); + onMediaReadyChangeRef.current = onMediaReadyChange; + onPlaybackStallFailoverRef.current = onPlaybackStallFailover; + onFatalErrorRef.current = onFatalError; + useEffect(() => { setMediaReady(false); setPlaybackStartError(null); - onMediaReadyChange?.(false); - }, [onMediaReadyChange, playbackSessionKey]); + onMediaReadyChangeRef.current?.(false); + }, [playbackSessionKey]); const directHasPlayer = (() => { if (!isDirectMode || !directPlayback) { @@ -172,6 +206,23 @@ export function HeroScrapePlayerPanel({ const hasActivePlayer = directHasPlayer || scrapeHasPlayer; + useEffect(() => { + if ( + !hasActivePlayer || + mediaReady || + isDirectMode || + !onPlaybackStallFailover + ) { + return undefined; + } + + const timeout = window.setTimeout(() => { + onPlaybackStallFailoverRef.current?.(); + }, PLAYBACK_STALL_FAILOVER_MS); + + return () => window.clearTimeout(timeout); + }, [hasActivePlayer, isDirectMode, mediaReady, playbackSessionKey]); + useEffect(() => { if (!hasActivePlayer || mediaReady) { setPlaybackStartError(null); @@ -180,11 +231,11 @@ export function HeroScrapePlayerPanel({ const timeout = window.setTimeout(() => { setPlaybackStartError("Playback didn't start in time."); - onFatalError(); + onFatalErrorRef.current(); }, PLAYBACK_START_TIMEOUT_MS); return () => window.clearTimeout(timeout); - }, [hasActivePlayer, mediaReady, onFatalError, playbackSessionKey]); + }, [hasActivePlayer, mediaReady, playbackSessionKey]); const handleMediaReady = useCallback(() => { setMediaReady(true); @@ -228,18 +279,33 @@ export function HeroScrapePlayerPanel({ {hasPlayer && progressKey && directPlayback.activeStream ? ( - + {moviPreview ? ( + + ) : ( + + )} ) : null} @@ -279,7 +345,8 @@ export function HeroScrapePlayerPanel({ scrapeStatus === "playing" && Boolean(scrapeResult?.playUrl) && Boolean(progressKey); - const showDiscoveryOverlay = isDiscovering; + const showDiscoveryOverlay = isDiscovering && !isResolvingEpisode; + const showResolvingOverlay = isResolvingEpisode && scrapeStatus === "idle"; const showBufferingOverlay = hasPlayer && !mediaReady; const showErrorOverlay = scrapeStatus === "error"; @@ -287,6 +354,10 @@ export function HeroScrapePlayerPanel({ <> {maintenanceBanner} + {showResolvingOverlay ? ( + + ) : null} + {hasPlayer && scrapeResult?.playUrl && progressKey ? ( scrapeResult.providerId === "direct" && scrapeResult.directPlayback ? ( @@ -296,6 +367,7 @@ export function HeroScrapePlayerPanel({ qualities: scrapeResult.qualities, subtitles: scrapeResult.subtitles, audioVersions: scrapeResult.audioVersions, + progressKey, })} mediaUrl={scrapeResult.playUrl} fallbackUrl={scrapeResult.directFallbackUrl} @@ -325,10 +397,38 @@ export function HeroScrapePlayerPanel({ playUrl: scrapeResult.playUrl, qualities: scrapeResult.qualities, subtitles: scrapeResult.subtitles, + progressKey, + })} + playUrl={scrapeResult.playUrl} + referer={scrapeResult.referer} + subtitles={scrapeResult.subtitles} + title={playbackTitle} + poster={playbackPosterUrl} + progressKey={progressKey} + imdbId={imdbId} + className="h-full w-full" + onFatalError={onFatalError} + onMediaReady={handleMediaReady} + onEnded={isTv ? onEnded : undefined} + /> + ) : moviPreview ? ( + , + seasonNumber: number, +): boolean => (seasons[seasonNumber]?.episodes?.length ?? 0) > 0; + const fetchKitsuEpisodeThumbnails = async ( tvId: string, request: KitsuThumbnailRequest, @@ -106,6 +118,7 @@ export function HeroTvEpisodePanel({ allSeasonDetails, }: HeroTvEpisodePanelProps) { const isHydrated = useIsHydrated(); + const queryClient = useQueryClient(); const catalog = useTvDetailCatalog(); const router = useRouter(); const pathname = usePathname(); @@ -151,10 +164,14 @@ export function HeroTvEpisodePanel({ setSeasonNumber, } = useEpisodeStore(); const requestedSeason = Number.parseInt(searchParams.get("season") ?? "", 10); + const localCoords = useLocalTvWatchCoords(details.id); const selectedSeason = useMemo(() => { + const storeMatchesRoute = + normalizeTvContentKey(tvShowId ?? "") === normalizeTvContentKey(tvId); + if ( - tvShowId === tvId && + storeMatchesRoute && storeSeason && seasonNumbers.includes(storeSeason) ) { @@ -169,8 +186,19 @@ export function HeroTvEpisodePanel({ return requestedSeason; } + if (localCoords && seasonNumbers.includes(localCoords.seasonNumber)) { + return localCoords.seasonNumber; + } + return seasonNumbers[0] ?? 1; - }, [requestedSeason, seasonNumbers, storeSeason, tvId, tvShowId]); + }, [ + localCoords, + requestedSeason, + seasonNumbers, + storeSeason, + tvId, + tvShowId, + ]); const [selectedAnimeSegment, setSelectedAnimeSegment] = useState(0); const [query, setQuery] = useState(""); @@ -186,7 +214,23 @@ export function HeroTvEpisodePanel({ useEffect(() => { if (!allSeasonDetails) return; - setLoadedSeasonDetails((current) => ({ ...allSeasonDetails, ...current })); + setLoadedSeasonDetails((current) => { + const merged: Record = { + ...allSeasonDetails, + ...current, + }; + for (const [key, season] of Object.entries(allSeasonDetails)) { + const seasonNumber = Number(key); + if (!Number.isInteger(seasonNumber) || seasonNumber <= 0) continue; + const existing = merged[seasonNumber]; + if ( + (existing?.episodes?.length ?? 0) < (season.episodes?.length ?? 0) + ) { + merged[seasonNumber] = season; + } + } + return merged; + }); }, [allSeasonDetails]); const parsedQuery = useMemo(() => parseEpisodeSearchQuery(query), [query]); @@ -242,50 +286,72 @@ export function HeroTvEpisodePanel({ setDefaultAnilistId, ]); + const selectedSeasonHasEpisodes = seasonHasLoadedEpisodes( + loadedSeasonDetails, + selectedSeason, + ); + const selectedSeasonQuery = useQuery({ - queryKey: ["nyumatflix", "media", "tv", tvId, "season", selectedSeason], - queryFn: () => fetchSeasonDetails(tvId, selectedSeason), + queryKey: queryKeys.tvSeasonRoute(tvId, selectedSeason), + queryFn: async () => { + const seasonDetail = await fetchSeasonDetails(tvId, selectedSeason); + if (!seasonDetail?.episodes?.length) { + throw new Error(`Season ${selectedSeason} has no episodes`); + } + return seasonDetail; + }, enabled: isHydrated && seasonNumbers.includes(selectedSeason) && - !loadedSeasonDetails[selectedSeason], + !selectedSeasonHasEpisodes, staleTime: queryStaleTime(60 * 60 * 1000), + retry: 2, }); + const animeMapTmdbShowId = + typeof mappedTmdbTvId === "number" && mappedTmdbTvId > 0 + ? mappedTmdbTvId + : isAnilistRoute + ? null + : Number(tvId); + const canMapAnimeSeason = + Number.isInteger(defaultAnilistId) && + typeof animeMapTmdbShowId === "number" && + Number.isInteger(animeMapTmdbShowId) && + animeMapTmdbShowId > 0; + const animeSeasonMapQuery = useQuery({ - queryKey: [ - "nyumatflix", - "anime-season-map", - tvId, + queryKey: queryKeys.animeSeasonMap( + animeMapTmdbShowId ?? 0, selectedSeason, - defaultAnilistId, - ], + defaultAnilistId ?? 0, + ), queryFn: async (): Promise => { const response = await fetch( - `/api/map?tmdbShowId=${encodeURIComponent(tvId)}&tmdbSeason=${selectedSeason}&sourceAnilistId=${defaultAnilistId}`, + `/api/map?tmdbShowId=${encodeURIComponent(String(animeMapTmdbShowId))}&tmdbSeason=${selectedSeason}&sourceAnilistId=${defaultAnilistId}`, ); - if (!response.ok) return {}; + if (!response.ok) { + throw new Error("anime season map failed"); + } return response.json() as Promise; }, - enabled: - isHydrated && !isAnilistRoute && Number.isInteger(defaultAnilistId), + enabled: isHydrated && canMapAnimeSeason, staleTime: queryStaleTime(60 * 60 * 1000), }); const animeSegments = animeSeasonMapQuery.data?.segments ?? []; - const useAnimeSeasonGroups = - !isAnilistRoute && seasonNumbers.length === 1 && animeSegments.length > 1; + const listPresentation = episodeListPresentation({ + tmdbSeasonCount: seasonNumbers.length, + segmentCount: animeSegments.length, + }); + const { splitCour, showTmdbSeasonSelect, showCourSelect } = listPresentation; useEffect(() => { setSelectedAnimeSegment(0); }, [selectedSeason, animeSegments.length]); useEffect(() => { - if ( - !useAnimeSeasonGroups || - !selectedEpisode || - animeSegments.length === 0 - ) { + if (!splitCour || !selectedEpisode || animeSegments.length === 0) { return; } @@ -299,7 +365,7 @@ export function HeroTvEpisodePanel({ } // Only re-sync when the selected episode changes or segments first arrive — // not when the user manually browses another anime season group. - }, [animeSegments, selectedEpisode?.id, useAnimeSeasonGroups]); + }, [animeSegments, selectedEpisode?.id, splitCour]); useEffect(() => { const seasonDetail = selectedSeasonQuery.data; @@ -312,27 +378,131 @@ export function HeroTvEpisodePanel({ const seasonEpisodes = useMemo(() => { const episodes = loadedSeasonDetails[selectedSeason]?.episodes ?? []; - if (!useAnimeSeasonGroups) return episodes; + if (!splitCour) return episodes; const segment = animeSegments[selectedAnimeSegment]; if (!segment) return episodes; - return episodes.filter( + + const maxSegmentEnd = Math.max( + ...animeSegments.map((entry) => entry.endEpisode), + 0, + ); + const lastSegmentIndex = animeSegments.length - 1; + + const filtered = episodes.filter( (episode) => episode.episode_number >= segment.startEpisode && episode.episode_number <= segment.endEpisode, ); + + if (selectedAnimeSegment !== lastSegmentIndex) { + return filtered.length > 0 ? filtered : episodes; + } + + const trailingAppendix = episodes.filter( + (episode) => episode.episode_number > maxSegmentEnd, + ); + if (trailingAppendix.length === 0) { + return filtered.length > 0 ? filtered : episodes; + } + + const seen = new Set(filtered.map((episode) => episode.id)); + return [ + ...filtered, + ...trailingAppendix.filter((episode) => !seen.has(episode.id)), + ]; }, [ animeSegments, loadedSeasonDetails, selectedAnimeSegment, selectedSeason, - useAnimeSeasonGroups, + splitCour, ]); const searchActive = query.trim().length > 0; const isAnimeTmdbPage = !isAnilistRoute && Number.isInteger(defaultAnilistId); const tmdbSeasonCount = seasonNumbers.length; + const allSeasonsForSearchQuery = useQuery({ + queryKey: queryKeys.tvAllSeasons(tvId), + queryFn: () => fetchTvAllSeasonsClient(tvId, catalog), + enabled: isHydrated && searchActive, + staleTime: queryStaleTime(60 * 60 * 1000), + }); + + useEffect(() => { + const searchedSeasons = allSeasonsForSearchQuery.data; + if (!searchedSeasons) return; + setLoadedSeasonDetails((current) => { + const merged: Record = { ...current }; + for (const [key, season] of Object.entries(searchedSeasons)) { + const seasonNumber = Number(key); + if (!Number.isInteger(seasonNumber) || seasonNumber <= 0) continue; + const existing = merged[seasonNumber]; + if ( + (existing?.episodes?.length ?? 0) < (season.episodes?.length ?? 0) + ) { + merged[seasonNumber] = season; + } + } + return merged; + }); + }, [allSeasonsForSearchQuery.data]); + + useEffect(() => { + if (!isHydrated || !selectedSeasonHasEpisodes || searchActive) { + return; + } + + const unloadedSeasons = seasonNumbers.filter( + (seasonNumber) => + !seasonHasLoadedEpisodes(loadedSeasonDetails, seasonNumber), + ); + if (unloadedSeasons.length === 0) { + return; + } + + const selectedIndex = seasonNumbers.indexOf(selectedSeason); + const prefetchOrder = [ + seasonNumbers[selectedIndex + 1], + seasonNumbers[selectedIndex - 1], + ...unloadedSeasons, + ].filter( + (seasonNumber): seasonNumber is number => + Number.isInteger(seasonNumber) && + seasonNumber > 0 && + seasonNumber !== selectedSeason && + !seasonHasLoadedEpisodes(loadedSeasonDetails, seasonNumber), + ); + + const nextSeason = prefetchOrder[0]; + if (!nextSeason) { + return; + } + + void queryClient.prefetchQuery({ + queryKey: queryKeys.tvSeasonRoute(tvId, nextSeason), + queryFn: () => fetchSeasonDetails(tvId, nextSeason), + staleTime: queryStaleTime(60 * 60 * 1000), + }); + }, [ + isHydrated, + loadedSeasonDetails, + queryClient, + searchActive, + seasonNumbers, + selectedSeason, + selectedSeasonHasEpisodes, + tvId, + ]); + const kitsuThumbnailRequests = useMemo((): KitsuThumbnailRequest[] => { + if (splitCour) { + return animeSegments.map((segment) => ({ + kind: "anilist" as const, + anilistId: segment.anilistMediaId, + })); + } + if (isAnilistRoute && routeAnilistId !== null) { return [ { @@ -352,13 +522,6 @@ export function HeroTvEpisodePanel({ const sourceAnilistId = defaultAnilistId; - if (useAnimeSeasonGroups) { - return animeSegments.map((segment) => ({ - kind: "anilist" as const, - anilistId: segment.anilistMediaId, - })); - } - const seasonNumbersToFetch = new Set([selectedSeason]); if (searchActive) { for (const season of Object.keys(loadedSeasonDetails)) { @@ -383,7 +546,9 @@ export function HeroTvEpisodePanel({ searchActive, selectedSeason, tmdbSeasonCount, - useAnimeSeasonGroups, + splitCour, + isAnilistRoute, + routeAnilistId, tvId, ]); @@ -435,14 +600,7 @@ export function HeroTvEpisodePanel({ const resolveKitsuThumbnail = useCallback( (episode: Episode, seasonNumber: number) => { - if (isAnilistRoute && routeAnilistId !== null) { - return ( - kitsuThumbnailsByAnilist[routeAnilistId]?.[episode.episode_number] ?? - null - ); - } - - if (useAnimeSeasonGroups) { + if (splitCour && seasonNumber === selectedSeason) { const segment = findSegmentForEpisode( animeSegments, episode.episode_number, @@ -456,16 +614,25 @@ export function HeroTvEpisodePanel({ ); } + if (isAnilistRoute && routeAnilistId !== null) { + return ( + kitsuThumbnailsByAnilist[routeAnilistId]?.[episode.episode_number] ?? + null + ); + } + return ( kitsuThumbnailsBySeason[seasonNumber]?.[episode.episode_number] ?? null ); }, [ animeSegments, + isAnilistRoute, kitsuThumbnailsByAnilist, kitsuThumbnailsBySeason, - tvId, - useAnimeSeasonGroups, + routeAnilistId, + selectedSeason, + splitCour, ], ); @@ -508,28 +675,31 @@ export function HeroTvEpisodePanel({ allSeasonDetails?.[episodeSeason]?.episodes; const segment = - animeSegments.find( - (entry) => - episode.episode_number >= entry.startEpisode && - episode.episode_number <= entry.endEpisode, - ) ?? null; + episodeSeason === selectedSeason + ? findSegmentForEpisode(animeSegments, episode.episode_number) + : null; + const animeDisplay = segment + ? toAnimeDisplayCoords(animeSegments, episode.episode_number) + : null; const anilistRouteId = routeAnilistId; - // On AniList routes we disable the season map, so never guess coords from the - // route id (e.g. 16498 + ep 21 is S1E21, not TMDB S3E21). Wait for playback-coords. + const embeddedSelection = resolveEpisodeAnimeSelection(episode, { + animeSeasonNumber: anilistRouteId ? episodeSeason : null, + isAdult: + animeSeasonMapQuery.data?.isAdult === true || + defaultIsAdultAnime || + details.adult === true, + }); const animeInfo = segment ? { anilistId: segment.anilistMediaId, startEpisode: segment.startEpisode, endEpisode: segment.endEpisode, } - : undefined; - const animeSeasonNumber = segment - ? useAnimeSeasonGroups - ? animeSeasonNumberForEpisode(animeSegments, episode.episode_number) - : episodeSeason - : anilistRouteId - ? episodeSeason - : null; + : embeddedSelection?.animeInfo; + const animeSeasonNumber = animeDisplay + ? animeDisplay.seasonNumber + : (embeddedSelection?.mapping.animeSeasonNumber ?? + (anilistRouteId ? episodeSeason : null)); const mapIsAdult = animeSeasonMapQuery.data?.isAdult === true; setSelectedEpisode( @@ -539,15 +709,25 @@ export function HeroTvEpisodePanel({ animeInfo, false, seasonEpisodes, - segment || anilistRouteId + segment ? { - confidence: segment ? "high" : "low", - // TMDB often omits adult for hentai OVAs — prefer AniList map / defaults. + confidence: "high", isAdult: mapIsAdult || defaultIsAdultAnime || details.adult === true, animeSeasonNumber, + relativeEpisodeNumber: animeDisplay?.episodeNumber, } - : undefined, + : (embeddedSelection?.mapping ?? + (anilistRouteId + ? { + confidence: "low", + isAdult: + mapIsAdult || + defaultIsAdultAnime || + details.adult === true, + animeSeasonNumber, + } + : undefined)), ); }, [ @@ -556,12 +736,11 @@ export function HeroTvEpisodePanel({ animeSegments, defaultIsAdultAnime, details.adult, - details.number_of_episodes, loadedSeasonDetails, routeAnilistId, + selectedSeason, setSelectedEpisode, tvId, - useAnimeSeasonGroups, ], ); @@ -578,83 +757,81 @@ export function HeroTvEpisodePanel({ } if (!isHydrated) { - return ( -
-
-
-
-
-
-
-
- -
-
- {Array.from({ length: 4 }).map((_, index) => ( -
- ))} -
-
-
- ); + return ; } return (
- { + setSeasonNumber(tvId, Number(value)); + if (searchParams.has("season")) { + router.replace( + stripSearchParam(pathname, searchParams, "season"), + { + scroll: false, + }, + ); + } + }} + > + + + + + {seasonNumbers.map((num) => ( + + Season {num} + + ))} + + + ) : null} + + {showCourSelect ? ( + + + + + + {animeSegments.map((segment, index) => ( + + {formatCourSelectLabel(segment, index, { + besideTmdbSeasons: showTmdbSeasonSelect, + })} + + ))} + + + ) : null}
- {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 ? ( - {person.name} - ) : ( -
- -
+
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handlePersonClick(); + } + } + : undefined + } + role={personHref ? "link" : undefined} + tabIndex={personHref ? 0 : undefined} + > +
+ {person.profile_path ? ( + {person.name} + ) : ( +
+ +
+ )} +
+
+

+ {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<AnimeScrapeResult> { - 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<typeof tmdbScrapeBodySchema> & { 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<typeof animeScrapeBodySchema>, + 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<boolean> => { + 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<boolean> => { + 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<ClientPlaybackStartupProbeResult> { + 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<ErrorData, "response">, +): 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<TProviderId, TPayload>; + failed: ReadonlySet<TProviderId>; + excludeIds?: ReadonlySet<TProviderId>; + scorePayload?: (payload: TPayload) => number; + requireStartupProbePass?: boolean; +}): { providerId: TProviderId; payload: TPayload } | null => { + const excludeIds = options.excludeIds ?? new Set<TProviderId>(); + 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<string>(["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<string>(); +/** TMDB overlay solos Direct first so it is the default source when healthy. */ +export const TMDB_SCRAPE_SOLO_FIRST_PROVIDERS = new Set<string>(["direct"]); /** Anime playback races Direct with leading anime scrapers instead of soloing it. */ export const ANIME_PLAYBACK_SOLO_FIRST_PROVIDERS = new Set<string>(); @@ -30,7 +27,6 @@ const PROVIDER_ATTEMPT_TIMEOUT_MS: Partial<Record<string, number>> = { 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<string>([ - "vidrock", -]); - export type RaceBatchResult<T> = { 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 <T>( - promise: Promise<T>, + work: (signal: AbortSignal) => Promise<T>, timeoutMs: number, onTimeout: T, + options?: { signal?: AbortSignal }, ): Promise<{ value: T; timedOut: boolean }> => { let winner: "work" | "timeout" | undefined; let timeoutId: ReturnType<typeof setTimeout> | undefined; + const cancelController = new AbortController(); + const cancelSignal = options?.signal + ? mergeAbortSignals(cancelController.signal, options.signal) + : cancelController.signal; const timeoutPromise = new Promise<T>((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<BingrStreamResponse | null> => { 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<boolean> => { try { const response = await scrapeFetch(streamUrl, { @@ -377,6 +409,7 @@ const probeMp4 = async ( }, timeoutMs: BINGR_HLS_PROBE_TIMEOUT_MS, retryAttempts: 1, + signal, }); const ok = response.ok || response.status === 206; await cancelResponseBody(response); @@ -396,7 +429,7 @@ export async function scrapeBingr( for (const server of BINGR_SERVERS) { const payloadResult = await raceWithTimeout( - postStream(input, server, details), + (signal) => postStream(input, server, details, signal), BINGR_SERVER_TIMEOUT_MS, null, ); @@ -424,7 +457,7 @@ export async function scrapeBingr( if (hls) { const master = await raceWithTimeout( - probeHlsMaster(streamUrl, referer), + (signal) => probeHlsMaster(streamUrl, referer, signal), BINGR_HLS_PROBE_TIMEOUT_MS + 500, null, ); @@ -437,7 +470,7 @@ export async function scrapeBingr( const rendition = master.value.qualities[0]; if (rendition) { const child = await raceWithTimeout( - probeHlsMaster(rendition.url, referer), + (signal) => probeHlsMaster(rendition.url, referer, signal), BINGR_HLS_PROBE_TIMEOUT_MS + 500, null, ); @@ -463,7 +496,7 @@ export async function scrapeBingr( } const mp4 = await raceWithTimeout( - probeMp4(streamUrl, referer), + (signal) => probeMp4(streamUrl, referer, signal), BINGR_HLS_PROBE_TIMEOUT_MS + 500, false, ); diff --git a/apps/web/lib/scrape/race-first.ts b/apps/web/lib/scrape/race-first.ts index 458614a0..e5c0a916 100644 --- a/apps/web/lib/scrape/race-first.ts +++ b/apps/web/lib/scrape/race-first.ts @@ -1,11 +1,46 @@ +export type RaceFirstOkOptions = { + signal?: AbortSignal; +}; + +const mergeRaceSignals = ( + raceSignal: AbortSignal, + parentSignal?: AbortSignal, +): AbortSignal => { + if (!parentSignal) { + return raceSignal; + } + + if (typeof AbortSignal.any === "function") { + return AbortSignal.any([raceSignal, parentSignal]); + } + + const merged = new AbortController(); + const abort = () => merged.abort(); + if (raceSignal.aborted || parentSignal.aborted) { + merged.abort(); + return merged.signal; + } + raceSignal.addEventListener("abort", abort, { once: true }); + parentSignal.addEventListener("abort", abort, { once: true }); + return merged.signal; +}; + export const raceFirstOk = async <T, R>( items: readonly T[], - probe: (item: T) => Promise<R | null>, + probe: (item: T, signal: AbortSignal) => Promise<R | null>, + options: RaceFirstOkOptions = {}, ): Promise<{ item: T; value: R } | null> => { if (items.length === 0) { return null; } + if (options.signal?.aborted) { + return null; + } + + const raceAbort = new AbortController(); + const probeSignal = mergeRaceSignals(raceAbort.signal, options.signal); + return await new Promise((resolve) => { let pending = items.length; let settled = false; @@ -15,13 +50,32 @@ export const raceFirstOk = async <T, R>( return; } settled = true; + if (!raceAbort.signal.aborted) { + raceAbort.abort(); + } resolve(result); }; for (const item of items) { + if (probeSignal.aborted) { + pending -= 1; + if (pending === 0) { + finish(null); + } + continue; + } + void Promise.resolve() - .then(() => probe(item)) + .then(() => probe(item, probeSignal)) .then((value) => { + if (probeSignal.aborted) { + pending -= 1; + if (pending === 0) { + finish(null); + } + return; + } + if (value !== null) { finish({ item, value }); return; @@ -43,13 +97,18 @@ export const raceFirstOk = async <T, R>( export const firstOkInBatches = async <T, R>( items: readonly T[], - probe: (item: T) => Promise<R | null>, + probe: (item: T, signal: AbortSignal) => Promise<R | null>, batchSize: number, + options: RaceFirstOkOptions = {}, ): Promise<{ item: T; value: R } | null> => { const size = Math.max(1, batchSize); for (let index = 0; index < items.length; index += size) { + if (options.signal?.aborted) { + return null; + } + const batch = items.slice(index, index + size); - const winner = await raceFirstOk(batch, probe); + const winner = await raceFirstOk(batch, probe, options); if (winner) { return winner; } diff --git a/apps/web/lib/scrape/scrape-race-batch.ts b/apps/web/lib/scrape/scrape-race-batch.ts index b1926d38..63813e65 100644 --- a/apps/web/lib/scrape/scrape-race-batch.ts +++ b/apps/web/lib/scrape/scrape-race-batch.ts @@ -3,6 +3,10 @@ import { pickScoredRaceWinner, type RaceAttemptEntry, } from "./provider-race"; +import { + payloadStartupProbePassed, + type ScrapeStartupProbeFields, +} from "./playback-startup-score"; export type ScrapeCancelReason = "winner" | "timeout" | "run"; @@ -23,16 +27,53 @@ export function shouldFinalizeFirstPreferenceMatch<T extends string, TPayload>( ); } +export const hasPendingStartupProbes = <T extends string, TPayload>( + successes: ReadonlyArray<RaceAttemptEntry<T, TPayload>>, +): boolean => + successes.some( + (entry) => + entry.attempt.outcome === "success" && + entry.attempt.payload !== undefined && + (entry.attempt.payload as ScrapeStartupProbeFields).startupProbeOk === + undefined, + ); + +/** Pref match plus a successful client startup probe (master → first segment). */ +export function shouldFinalizeFirstPreferenceMatchWithStartup< + T extends string, + TPayload extends ScrapeStartupProbeFields, +>( + successes: ReadonlyArray<RaceAttemptEntry<T, TPayload>>, + matchesPayload: (payload: TPayload) => boolean, +): RaceAttemptEntry<T, TPayload> | undefined { + return successes.find( + ( + entry, + ): entry is RaceAttemptEntry<T, TPayload> & { + attempt: { outcome: "success"; payload: TPayload }; + } => + entry.attempt.outcome === "success" && + entry.attempt.payload !== undefined && + matchesPayload(entry.attempt.payload) && + payloadStartupProbePassed(entry.attempt.payload), + ); +} + export function shouldFinalizeBatchWinner<T extends string, TPayload>( order: readonly T[], successes: ReadonlyArray<RaceAttemptEntry<T, TPayload>>, pending: ReadonlySet<T>, scorePayload?: (payload: TPayload) => number, + waitForStartupProbes = false, ): RaceAttemptEntry<T, TPayload> | undefined { if (scorePayload && pending.size > 0) { return undefined; } + if (waitForStartupProbes && hasPendingStartupProbes(successes)) { + return undefined; + } + const winnerEntry = scorePayload ? pickScoredRaceWinner(order, successes, scorePayload) : pickRaceWinner(order, successes); diff --git a/apps/web/lib/scrape/subtitle-harvest.ts b/apps/web/lib/scrape/subtitle-harvest.ts index 969a8115..63e3c870 100644 --- a/apps/web/lib/scrape/subtitle-harvest.ts +++ b/apps/web/lib/scrape/subtitle-harvest.ts @@ -17,6 +17,9 @@ export type HarvestableScrapePayload = { subtitles?: ScrapeSubtitle[]; qualities?: ScrapeQuality[]; audioVersions?: ScrapeAudioVersion[]; + playUrl?: string; + startupProbeMs?: number; + startupProbeOk?: boolean; }; const catalogHost = (() => { diff --git a/apps/web/lib/scrape/validate-stream.ts b/apps/web/lib/scrape/validate-stream.ts index 79a385e0..60b2fff5 100644 --- a/apps/web/lib/scrape/validate-stream.ts +++ b/apps/web/lib/scrape/validate-stream.ts @@ -1,5 +1,6 @@ import { cancelResponseBody, scrapeFetch } from "./fetch"; import { appendAnimeCdnReferers } from "./anime/cdn-referer"; +import { extractHlsProbeTargets } from "./hls-probe-targets"; import { resolveHlsPlaylistUrl } from "./hls-url"; import { resolveKaaSegmentFallbackUrls } from "./playback"; import { @@ -11,6 +12,8 @@ import { scrapeUpstreamHeaders } from "./upstream-headers"; import { normalizeVidKingAssetHost } from "./vidking-cdn-url"; import { isVixsrcStubPlaylistBody } from "./vixsrc-stub"; +export { extractHlsProbeTargets } from "./hls-probe-targets"; + export type ValidateStreamDepth = "full" | "master"; export type ValidateStreamOptions = { @@ -178,56 +181,6 @@ const okContentTypesForKind = ( ); }; -const HLS_URI_ATTRIBUTE_PATTERN = /\bURI=(?:"([^"]+)"|'([^']+)')/i; - -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 }; -}; - const probeHlsAsset = async ( assetUrl: string, referer?: string, diff --git a/apps/web/lib/search/merge-search-media.ts b/apps/web/lib/search/merge-search-media.ts index fd473823..876f7d1c 100644 --- a/apps/web/lib/search/merge-search-media.ts +++ b/apps/web/lib/search/merge-search-media.ts @@ -17,7 +17,7 @@ const readSourceAnilistId = ( }; const isAnilistOnlyHref = (href: string): boolean => - /\/(?:tvshows\/anilist-\d+|anime\/\d+)/i.test(href); + /\/(?:tvshows\/anilist-\d+|anime\/(?:anilist-)?\d+)/i.test(href); const normalizeSearchTitle = (value: string): string => value @@ -49,6 +49,13 @@ export const rankSearchMediaResults = ( score += 80; } + if ( + normalizedQuery.includes("final chapter") && + title.includes("final chapter") + ) { + score += 160; + } + score += (card.vote_average ?? 0) * 12; score += Math.min(card.vote_count ?? 0, 5000) / 250; @@ -80,8 +87,17 @@ const dedupeAnilistAgainstTmdb = ( seenAnilistIds.add(sourceAnilistId); } - if (tmdbIds.has(card.id) && !isAnilistOnlyHref(card.href)) { - return false; + if (tmdbIds.has(card.id)) { + if ( + sourceAnilistId !== null && + sourceAnilistId !== card.id && + isAnilistOnlyHref(card.href) + ) { + return true; + } + if (!isAnilistOnlyHref(card.href)) { + return false; + } } return true; diff --git a/apps/web/lib/seo/constants.ts b/apps/web/lib/seo/constants.ts index 370f727a..99bae7a3 100644 --- a/apps/web/lib/seo/constants.ts +++ b/apps/web/lib/seo/constants.ts @@ -13,7 +13,11 @@ export const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"; export const tmdbImageUrl = ( path: string | null | undefined, size: "w342" | "w500" | "w780" | "w1280" | "original" = "w780", -) => (path ? `${TMDB_IMAGE_BASE}/${size}${path}` : null); +) => { + if (!path) return null; + if (/^https?:\/\//i.test(path)) return path; + return `${TMDB_IMAGE_BASE}/${size}${path.startsWith("/") ? path : `/${path}`}`; +}; export const buildCanonicalUrl = (path = "/") => { const normalized = path.startsWith("/") ? path : `/${path}`; diff --git a/apps/web/lib/seo/structured-data.ts b/apps/web/lib/seo/structured-data.ts index 1797db9a..ecbe5e8c 100644 --- a/apps/web/lib/seo/structured-data.ts +++ b/apps/web/lib/seo/structured-data.ts @@ -45,7 +45,11 @@ export const buildMovieStructuredData = (media: MediaItem, mediaId: string) => { }; }; -export const buildTvStructuredData = (media: MediaItem, mediaId: string) => { +export const buildTvStructuredData = ( + media: MediaItem, + mediaId: string, + options?: { path?: string }, +) => { const title = getMediaTitle(media, "tv"); const startDate = getMediaDate(media, "tv"); const image = tmdbImageUrl(media.poster_path, "w500"); @@ -55,7 +59,7 @@ export const buildTvStructuredData = (media: MediaItem, mediaId: string) => { "@type": "TVSeries", name: title, description: media.overview, - url: buildCanonicalUrl(`/tvshows/${mediaId}`), + url: buildCanonicalUrl(options?.path ?? `/tvshows/${mediaId}`), ...(startDate && { datePublished: startDate }), ...(image && { image }), ...(media.vote_average > 0 && { diff --git a/apps/web/lib/server/anime-tv-detail-route.ts b/apps/web/lib/server/anime-tv-detail-route.ts index 90ebd473..02604a7a 100644 --- a/apps/web/lib/server/anime-tv-detail-route.ts +++ b/apps/web/lib/server/anime-tv-detail-route.ts @@ -1,10 +1,9 @@ import { buildAnilistTvDetailHref, fromAnilistTvRouteId, - isAnilistTvRouteId, isAnimeAnilistRouteId, - normalizeAnilistTvRouteSlug, parseAnimeAnilistRouteId, + toAnilistTvRouteSlug, } from "@/lib/anilist-route-id"; import { resolveAnilistMovieTmdbRoute } from "@/lib/anilist-movie-route"; import { resolveCanonicalAnilistRoute } from "@/lib/anilist-tv-detail"; @@ -28,14 +27,6 @@ const appendSearchParams = ( return mergedQuery ? `${path}?${mergedQuery}` : path; }; -const toCanonicalLookupRouteId = (id: string): string => { - if (isAnilistTvRouteId(id)) { - return normalizeAnilistTvRouteSlug(id); - } - - return id; -}; - export async function resolveAnilistTvDetailRedirects( id: string, ): Promise<void> { @@ -61,21 +52,19 @@ export async function resolveAnilistTvDetailRedirects( ); } - if (isAnilistTvRouteId(id)) { - redirect( - appendSearchParams( - buildAnilistTvDetailHref(entryAnilistId), - requestSearchParams, - ), - ); - } - const canonical = await resolveCanonicalAnilistRoute( - toCanonicalLookupRouteId(id), + toAnilistTvRouteSlug(entryAnilistId), { acceptBareNumeric: true }, ); - if (!canonical) { - // If AniList does not have this bare ID directly, check if it is a TMDB ID that maps to AniList + if (canonical) { + const canonicalAnilistId = fromAnilistTvRouteId(canonical.slug); + if (canonicalAnilistId !== entryAnilistId) { + const canonicalHref = buildAnilistTvDetailHref(canonicalAnilistId, { + season: canonical.season > 1 ? canonical.season : undefined, + }); + redirect(appendSearchParams(canonicalHref, requestSearchParams)); + } + } else { const mappedAnilistId = (await getAnilistIdFromFribb(entryAnilistId, "tv")) ?? (await resolveTmdbToAnilistFromMappings(entryAnilistId, "tv")) ?? @@ -90,17 +79,15 @@ export async function resolveAnilistTvDetailRedirects( ), ); } - - return; } - const canonicalAnilistId = fromAnilistTvRouteId(canonical.slug); - if (canonicalAnilistId === entryAnilistId) { - return; + const canonicalSlug = toAnilistTvRouteSlug(entryAnilistId); + if (id !== canonicalSlug) { + redirect( + appendSearchParams( + buildAnilistTvDetailHref(entryAnilistId), + requestSearchParams, + ), + ); } - - const canonicalHref = buildAnilistTvDetailHref(canonicalAnilistId, { - season: canonical.season > 1 ? canonical.season : undefined, - }); - redirect(appendSearchParams(canonicalHref, requestSearchParams)); } diff --git a/apps/web/lib/server/tv-detail-layout-content.tsx b/apps/web/lib/server/tv-detail-layout-content.tsx index b9825819..0c0ece04 100644 --- a/apps/web/lib/server/tv-detail-layout-content.tsx +++ b/apps/web/lib/server/tv-detail-layout-content.tsx @@ -8,9 +8,6 @@ import { parseAnimeAnilistRouteId, } from "@/lib/anilist-route-id"; import { hydrateTvShowDetailQueries } from "@/lib/prefetch-media-detail-queries"; -import { getCachedAnilistTvAllSeasons } from "@/lib/anilist-tv-detail"; -import { queryKeys } from "@/lib/query-keys"; -import { fetchAllSeasonDetails } from "@/lib/server/tvshow-api"; import type { TvDetailCatalog } from "@/lib/tv-detail-catalog"; import { getCachedTvShowDetail } from "@/lib/media-detail-cache"; import { getAnilistIdForMedia } from "@/utils/anilist-helpers"; @@ -100,18 +97,6 @@ export async function TvShowDetailLayoutContent({ const queryClient = new QueryClient(); await hydrateTvShowDetailQueries(queryClient, id, details, catalog); - if (catalog === "anime") { - const allSeasons = await getCachedAnilistTvAllSeasons(id, { - acceptBareNumeric: true, - }); - queryClient.setQueryData(queryKeys.tvAllSeasons(id), allSeasons); - } else if (details.seasons?.length) { - const allSeasons = await fetchAllSeasonDetails(id, details.seasons, { - catalog, - }); - queryClient.setQueryData(queryKeys.tvAllSeasons(id), allSeasons); - } - return ( <HydrationBoundary state={dehydrate(queryClient)}> <DirectPlaybackWarmup /> diff --git a/apps/web/lib/server/tvshow-api.ts b/apps/web/lib/server/tvshow-api.ts index 73e66e93..eb654397 100644 --- a/apps/web/lib/server/tvshow-api.ts +++ b/apps/web/lib/server/tvshow-api.ts @@ -4,10 +4,13 @@ import { getCachedAnilistTvAllSeasons, getCachedAnilistTvSeasonDetails, } from "@/lib/anilist-tv-detail"; +import { movieDb } from "@/lib/constants"; import { isAnilistBackedTvRouteId, type TvDetailCatalog, } from "@/lib/tv-detail-catalog"; +import { isTmdbNotFoundError } from "@/lib/tmdb-errors"; +import { unwrapTmdbLookupId } from "@/lib/tmdb-anime-route-id"; import { CACHE_REVALIDATE_SECONDS, CACHE_SEASON_REVALIDATE_SECONDS, @@ -41,7 +44,7 @@ type RawSeasonDetails = { season_number?: unknown; }; -const SEASON_DETAIL_BATCH_SIZE = 3; +const SEASON_DETAIL_BATCH_SIZE = 6; const readString = (value: unknown, fallback = "") => typeof value === "string" ? value : fallback; @@ -113,11 +116,12 @@ const toSlimSeasonDetails = (raw: RawSeasonDetails): SeasonDetails | null => { }; export async function fetchTVShowDetails(id: string): Promise<TvShowDetails> { + const tmdbId = unwrapTmdbLookupId(id); const TV_DETAIL_APPEND = "content_ratings,keywords,external_ids,videos,images,recommendations,similar,reviews,credits"; try { - const url = new URL(`https://api.themoviedb.org/3/tv/${id}`); + const url = new URL(`https://api.themoviedb.org/3/tv/${tmdbId}`); url.searchParams.set("api_key", process.env.TMDB_API_KEY ?? ""); url.searchParams.set("language", "en-US"); url.searchParams.set("append_to_response", TV_DETAIL_APPEND); @@ -125,7 +129,7 @@ export async function fetchTVShowDetails(id: string): Promise<TvShowDetails> { const response = await fetch( url, tmdbFetchInit({ - endpoint: `/tv/${id}`, + endpoint: `/tv/${tmdbId}`, params: { append_to_response: TV_DETAIL_APPEND }, revalidate: CACHE_REVALIDATE_SECONDS, }), @@ -150,17 +154,41 @@ export async function fetchTVShowDetails(id: string): Promise<TvShowDetails> { export type TvSeasonFetchOptions = { catalog?: TvDetailCatalog | null; + source?: "tmdb" | "auto"; }; const anilistSeasonResolveOptions = (catalog: TvDetailCatalog | null) => catalog === "anime" ? { acceptBareNumeric: true as const } : undefined; +const shouldResolveAnilistSeason = ( + tvId: string, + options?: TvSeasonFetchOptions, +): boolean => + options?.source !== "tmdb" && + isAnilistBackedTvRouteId(tvId, options?.catalog ?? null); + +export async function resolveTvShowDetailForApiRoute( + id: string, +): Promise<TvShowDetails | null> { + const tmdbId = unwrapTmdbLookupId(id); + + try { + const tmdbDetail = await movieDb.tvInfo({ id: tmdbId }); + return (tmdbDetail ?? null) as TvShowDetails | null; + } catch (error) { + if (isTmdbNotFoundError(error)) { + return null; + } + throw error; + } +} + export async function fetchSeasonDetailsServer( tvId: string, seasonNumber: number, options?: TvSeasonFetchOptions, ): Promise<SeasonDetails | null> { - if (isAnilistBackedTvRouteId(tvId, options?.catalog ?? null)) { + if (shouldResolveAnilistSeason(tvId, options)) { return getCachedAnilistTvSeasonDetails( tvId, seasonNumber, @@ -168,10 +196,12 @@ export async function fetchSeasonDetailsServer( ); } + const tmdbId = unwrapTmdbLookupId(tvId); + try { const response = await fetch( - `https://api.themoviedb.org/3/tv/${tvId}/season/${seasonNumber}?api_key=${process.env.TMDB_API_KEY}&language=en-US`, - tvSeasonFetchInit(tvId, seasonNumber), + `https://api.themoviedb.org/3/tv/${tmdbId}/season/${seasonNumber}?api_key=${process.env.TMDB_API_KEY}&language=en-US`, + tvSeasonFetchInit(tmdbId, seasonNumber), ); if (!response.ok) { throw new Error(`Failed to fetch season details: ${response.status}`); @@ -188,7 +218,7 @@ export async function fetchAllSeasonDetails( seasons: Season[] | undefined, options?: TvSeasonFetchOptions, ): Promise<Record<number, SeasonDetails>> { - if (isAnilistBackedTvRouteId(tvId, options?.catalog ?? null)) { + if (shouldResolveAnilistSeason(tvId, options)) { return getCachedAnilistTvAllSeasons( tvId, anilistSeasonResolveOptions(options?.catalog ?? null), diff --git a/apps/web/lib/stores/episode-store.ts b/apps/web/lib/stores/episode-store.ts index db35203e..78e0e678 100644 --- a/apps/web/lib/stores/episode-store.ts +++ b/apps/web/lib/stores/episode-store.ts @@ -2,9 +2,20 @@ import { Episode } from "@/lib/domain/typings"; import { fetchSeasonDetails } from "@/components/tvshow/tvshow-api"; import { episodeNumberForProviders } from "@/lib/tv-provider-episode"; import type { MappingConfidence } from "@/lib/anime/tmdb-anilist-map"; +import { rememberLastTvEpisode } from "@/lib/playback/progress-storage"; +import { normalizeTvContentKey } from "@/lib/tv-watch-target"; import { create } from "zustand"; import { useServerStore, isScrapeServer } from "./server-store"; +export type AnimeEpisodeMappingContext = { + tvShowId: string; + seasonNumber: number; + episodeNumber: number; +}; + +let advanceRequestId = 0; +let advanceInFlight: Promise<boolean> | null = null; + export type NextEpisodeTarget = { episode: Episode; seasonNumber: number; @@ -64,18 +75,21 @@ interface EpisodeState { relativeEpisodeNumber?: number; }, ) => void; - applyAnimeEpisodeMapping: (mapping: { - animeInfo: { - anilistId: number; - startEpisode: number; - endEpisode: number; - }; - confidence: MappingConfidence; - isAdult: boolean; - genres?: string[]; - animeSeasonNumber?: number | null; - relativeEpisodeNumber?: number; - }) => void; + applyAnimeEpisodeMapping: ( + mapping: { + animeInfo: { + anilistId: number; + startEpisode: number; + endEpisode: number; + }; + confidence: MappingConfidence; + isAdult: boolean; + genres?: string[]; + animeSeasonNumber?: number | null; + relativeEpisodeNumber?: number; + }, + context?: AnimeEpisodeMappingContext, + ) => void; setAnimeCoordsStatus: (status: EpisodeState["animeCoordsStatus"]) => void; clearSelectedEpisode: () => void; setDefaultAnilistId: (anilistId: number | null, isAdult?: boolean) => void; @@ -171,6 +185,11 @@ export const useEpisodeStore = create<EpisodeState>((set, get) => ({ ? "pending" : "idle"; + const contentKey = normalizeTvContentKey(tvShowId); + if (contentKey !== null) { + rememberLastTvEpisode(contentKey, seasonNumber, episode.episode_number); + } + set({ selectedEpisode: episode, tvShowId, @@ -217,12 +236,23 @@ export const useEpisodeStore = create<EpisodeState>((set, get) => ({ animeCoordsStatus: "idle", }); }, - applyAnimeEpisodeMapping: (mapping) => { - const { selectedEpisode } = get(); - if (!selectedEpisode) { + applyAnimeEpisodeMapping: (mapping, context) => { + const state = get(); + const { selectedEpisode, tvShowId, seasonNumber } = state; + if (!selectedEpisode || !tvShowId || seasonNumber == null) { return; } + if (context) { + if ( + tvShowId !== context.tvShowId || + seasonNumber !== context.seasonNumber || + selectedEpisode.episode_number !== context.episodeNumber + ) { + return; + } + } + const relativeEpisodeNumber = mapping.relativeEpisodeNumber ?? selectedEpisode.episode_number - mapping.animeInfo.startEpisode + 1; @@ -326,14 +356,21 @@ export const useEpisodeStore = create<EpisodeState>((set, get) => ({ if (selectedServer.getAnimePaheUrl) { return selectedServer.getAnimePaheUrl(anilistId, relativeEpisodeNumber); } + + return null; + } + + const { playbackTmdbTvId } = get(); + const tmdbShowId = playbackTmdbTvId ?? normalizeTvContentKey(tvShowId); + if (!tmdbShowId) { + return null; } - const url = selectedServer.getEpisodeUrl( - parseInt(tvShowId), + return selectedServer.getEpisodeUrl( + tmdbShowId, seasonNumber, providerEpisodeNumber ?? selectedEpisode.episode_number, ); - return url; }, getNextEpisodeTarget: () => { const { selectedEpisode, seasonNumber, seasonEpisodes } = get(); @@ -364,6 +401,12 @@ export const useEpisodeStore = create<EpisodeState>((set, get) => ({ }; }, advanceToNextEpisode: async () => { + if (advanceInFlight) { + return advanceInFlight; + } + + const requestId = ++advanceRequestId; + const started = get(); const { selectedEpisode, seasonNumber, @@ -371,17 +414,38 @@ export const useEpisodeStore = create<EpisodeState>((set, get) => ({ seasonEpisodes, setSelectedEpisode, getNextEpisodeTarget, - } = get(); + } = started; if (!selectedEpisode || seasonNumber == null || !tvShowId) { return false; } + const startEpisodeId = selectedEpisode.id; + const startEpisodeNumber = selectedEpisode.episode_number; + + const isStale = () => { + if (requestId !== advanceRequestId) { + return true; + } + + const state = get(); + return ( + state.tvShowId !== tvShowId || + state.seasonNumber !== seasonNumber || + state.selectedEpisode?.id !== startEpisodeId || + state.selectedEpisode?.episode_number !== startEpisodeNumber + ); + }; + const advanceEpisode = ( episode: Episode, targetSeason: number, episodes: Episode[], ) => { + if (isStale()) { + return; + } + const { anilistId, animeSeasonNumber, @@ -423,50 +487,80 @@ export const useEpisodeStore = create<EpisodeState>((set, get) => ({ ); }; - const inSeasonNext = getNextEpisodeTarget(); - if (inSeasonNext) { - advanceEpisode( - inSeasonNext.episode, - inSeasonNext.seasonNumber, - inSeasonNext.seasonEpisodes, - ); - return true; - } + advanceInFlight = (async () => { + try { + const inSeasonNext = getNextEpisodeTarget(); + if (inSeasonNext) { + if (isStale()) { + return false; + } + advanceEpisode( + inSeasonNext.episode, + inSeasonNext.seasonNumber, + inSeasonNext.seasonEpisodes, + ); + return !isStale(); + } - let currentSeasonEpisodes = seasonEpisodes; - if (!currentSeasonEpisodes?.length) { - const currentSeason = await fetchSeasonDetails(tvShowId, seasonNumber); - currentSeasonEpisodes = currentSeason?.episodes ?? []; - } + let currentSeasonEpisodes = seasonEpisodes; + if (!currentSeasonEpisodes?.length) { + const currentSeason = await fetchSeasonDetails( + tvShowId, + seasonNumber, + ); + if (isStale()) { + return false; + } + currentSeasonEpisodes = currentSeason?.episodes ?? []; + } - if (currentSeasonEpisodes.length > 0) { - const sorted = [...currentSeasonEpisodes].sort( - (a, b) => a.episode_number - b.episode_number, - ); - const currentIndex = sorted.findIndex( - (episode) => - episode.id === selectedEpisode.id || - episode.episode_number === selectedEpisode.episode_number, - ); + if (currentSeasonEpisodes.length > 0) { + const sorted = [...currentSeasonEpisodes].sort( + (a, b) => a.episode_number - b.episode_number, + ); + const currentIndex = sorted.findIndex( + (episode) => + episode.id === startEpisodeId || + episode.episode_number === startEpisodeNumber, + ); + + if (currentIndex >= 0 && currentIndex < sorted.length - 1) { + if (isStale()) { + return false; + } + advanceEpisode(sorted[currentIndex + 1]!, seasonNumber, sorted); + return !isStale(); + } + } - if (currentIndex >= 0 && currentIndex < sorted.length - 1) { - advanceEpisode(sorted[currentIndex + 1]!, seasonNumber, sorted); - return true; - } - } + const nextSeasonNumber = seasonNumber + 1; + const nextSeason = await fetchSeasonDetails(tvShowId, nextSeasonNumber); + if (isStale()) { + return false; + } - const nextSeasonNumber = seasonNumber + 1; - const nextSeason = await fetchSeasonDetails(tvShowId, nextSeasonNumber); - const nextSeasonEpisodes = nextSeason?.episodes ?? []; - if (nextSeasonEpisodes.length === 0) { - return false; - } + const nextSeasonEpisodes = nextSeason?.episodes ?? []; + if (nextSeasonEpisodes.length === 0) { + return false; + } - const sortedNextSeason = [...nextSeasonEpisodes].sort( - (a, b) => a.episode_number - b.episode_number, - ); - advanceEpisode(sortedNextSeason[0]!, nextSeasonNumber, sortedNextSeason); - return true; + const sortedNextSeason = [...nextSeasonEpisodes].sort( + (a, b) => a.episode_number - b.episode_number, + ); + advanceEpisode( + sortedNextSeason[0]!, + nextSeasonNumber, + sortedNextSeason, + ); + return !isStale(); + } finally { + if (requestId === advanceRequestId) { + advanceInFlight = null; + } + } + })(); + + return advanceInFlight; }, setWatchCallback: (callback) => { set({ watchCallback: callback }); diff --git a/apps/web/lib/stores/playback-mode-store.ts b/apps/web/lib/stores/playback-mode-store.ts index 9892e7f6..c108d6e8 100644 --- a/apps/web/lib/stores/playback-mode-store.ts +++ b/apps/web/lib/stores/playback-mode-store.ts @@ -26,9 +26,17 @@ if (!defaultServer) { throw new Error("At least one iframe video server must be configured"); } +export type SetSelectedServerOptions = { + userInitiated?: boolean; +}; + interface PlaybackModeState { selectedServer: VideoServer; - setSelectedServer: (server: VideoServer) => void; + hasUserSelectedPlaybackServer: boolean; + setSelectedServer: ( + server: VideoServer, + options?: SetSelectedServerOptions, + ) => void; } const resolveStoredServer = (serverId: string): VideoServer => { @@ -42,12 +50,24 @@ export const usePlaybackModeStore = create<PlaybackModeState>()( persist( (set) => ({ selectedServer: defaultServer, - setSelectedServer: (server) => { - set((state) => - state.selectedServer.id === server.id - ? state - : { selectedServer: server }, - ); + hasUserSelectedPlaybackServer: false, + setSelectedServer: (server, options) => { + set((state) => { + const userInitiated = options?.userInitiated === true; + const sameServer = state.selectedServer.id === server.id; + if ( + sameServer && + (!userInitiated || state.hasUserSelectedPlaybackServer) + ) { + return state; + } + + return { + selectedServer: server, + hasUserSelectedPlaybackServer: + state.hasUserSelectedPlaybackServer || userInitiated, + }; + }); }, }), { diff --git a/apps/web/lib/tmdb-anime-route-id.ts b/apps/web/lib/tmdb-anime-route-id.ts index 272606cc..9bc4293b 100644 --- a/apps/web/lib/tmdb-anime-route-id.ts +++ b/apps/web/lib/tmdb-anime-route-id.ts @@ -65,3 +65,8 @@ export const buildTmdbAnimeDetailHref = ( tmdbId: number, mediaType: "tv" | "movie" = "tv", ): string => `/anime/${toTmdbAnimeRouteSlug(tmdbId, mediaType)}`; + +export const unwrapTmdbLookupId = (routeId: string): string => { + const parsed = parseTmdbAnimeRouteId(routeId); + return parsed ? String(parsed.tmdbId) : routeId; +}; diff --git a/apps/web/lib/tmdb-cache-policy.ts b/apps/web/lib/tmdb-cache-policy.ts index 7fdc5767..948e696e 100644 --- a/apps/web/lib/tmdb-cache-policy.ts +++ b/apps/web/lib/tmdb-cache-policy.ts @@ -7,7 +7,6 @@ const LARGE_APPEND_ITEMS = new Set([ const LARGE_ENDPOINT_PATTERNS = [ /\/(?:aggregate_credits|combined_credits|credits|images)(?:\/|$)/, - /\/tv\/[^/]+\/season\/\d+(?:\/|$)/, ]; type TmdbParams = diff --git a/apps/web/lib/tv-card-href.ts b/apps/web/lib/tv-card-href.ts new file mode 100644 index 00000000..9249c40e --- /dev/null +++ b/apps/web/lib/tv-card-href.ts @@ -0,0 +1,30 @@ +import { + buildAnilistTvDetailHref, + normalizeAnilistAnimeDetailHref, +} from "@/lib/anilist-route-id"; +import { isRemoteImagePath } from "@/lib/media-poster-path"; +import { buildTmdbAnimeDetailHref } from "@/lib/tmdb-anime-route-id"; +import type { TvDetailCatalog } from "@/lib/tv-detail-catalog"; + +export const resolveTvCardHref = ( + show: { + id: number; + href?: string | null; + poster_path?: string | null; + }, + catalog?: TvDetailCatalog | null, +): string => { + if (typeof show.href === "string" && show.href.trim().length > 0) { + return normalizeAnilistAnimeDetailHref(show.href); + } + + if (isRemoteImagePath(show.poster_path)) { + return buildAnilistTvDetailHref(show.id); + } + + if (catalog === "anime") { + return buildTmdbAnimeDetailHref(show.id); + } + + return `/tvshows/${show.id}`; +}; diff --git a/apps/web/lib/tv-detail-catalog.ts b/apps/web/lib/tv-detail-catalog.ts index 5463935b..c006db5c 100644 --- a/apps/web/lib/tv-detail-catalog.ts +++ b/apps/web/lib/tv-detail-catalog.ts @@ -1,8 +1,17 @@ import { + fromAnilistTvRouteId, isAnimeAnilistRouteId, isAnilistTvRouteId, + normalizeAnilistTvRouteSlug, + parseAnimeAnilistRouteId, + resolveTvDetailRouteId, + toAnilistTvRouteSlug, } from "@/lib/anilist-route-id"; import { isMalAnimeRouteId } from "@/lib/mal/route-id"; +import { + isTmdbAnimeRouteId, + parseTmdbAnimeRouteId, +} from "@/lib/tmdb-anime-route-id"; export type TvDetailCatalog = "anime" | "tvshows"; @@ -13,6 +22,18 @@ export const resolveTvDetailCatalogFromPathname = ( pathname: string, ): TvDetailCatalog => (pathname.startsWith("/anime/") ? "anime" : "tvshows"); +export const resolveTvDetailCatalogFromHref = ( + href: string, +): TvDetailCatalog | null => { + if (href.startsWith("/anime/")) { + return "anime"; + } + if (href.startsWith("/tvshows/")) { + return "tvshows"; + } + return null; +}; + export const readTvDetailCatalogFromRequestUrl = ( url: URL, ): TvDetailCatalog | null => { @@ -42,6 +63,112 @@ export const isAnilistBackedTvRouteId = ( return catalog === "anime" && isAnimeAnilistRouteId(routeId); }; +/** Prefix AniList ids in `/api/media/tv` paths so they cannot collide with TMDB ids. */ +export const toTvApiRouteId = ( + routeId: string, + catalog: TvDetailCatalog | null, +): string => { + if (isMalAnimeRouteId(routeId) || isTmdbAnimeRouteId(routeId)) { + return routeId; + } + + if (isAnilistTvRouteId(routeId)) { + return normalizeAnilistTvRouteSlug(routeId); + } + + if (catalog === "anime" && isAnimeAnilistRouteId(routeId)) { + return toAnilistTvRouteSlug(fromAnilistTvRouteId(routeId)); + } + + return routeId; +}; + +/** Bare AniList id for `/api/anime/{id}` — never a TMDB id. */ +export const toAnimeApiId = (routeId: string): string | null => { + const anilistId = parseAnimeAnilistRouteId(routeId); + return anilistId ? String(anilistId) : null; +}; + +export const resolveAnimePrefetchRouteId = (href: string): string | null => { + const match = href.match(/^\/anime\/([^/?#]+)/); + if (!match?.[1]) { + return null; + } + + return toTvApiRouteId(decodeURIComponent(match[1]), "anime"); +}; + +const rewriteAnimeCatalogTvApiPath = ( + pathname: string, + querySuffix: string, +): string | null => { + const tvMatch = pathname.match(/^\/api\/tv\/([^/]+)(\/.*)?$/); + if (!tvMatch?.[1]) { + return null; + } + + const routeId = decodeURIComponent(tvMatch[1]); + const rest = tvMatch[2] ?? ""; + const animeId = toAnimeApiId(routeId); + if (animeId) { + return `/api/anime/${animeId}${rest}${querySuffix}`; + } + + const tmdb = parseTmdbAnimeRouteId(routeId); + if (tmdb) { + return `/api/tv/${tmdb.tmdbId}${rest}${querySuffix}`; + } + + return null; +}; + +export const withTvApiRouteId = ( + path: string, + catalog: TvDetailCatalog | null, +): string => { + const [pathname, query = ""] = path.split("?"); + const querySuffix = query ? `?${query}` : ""; + + if (catalog === "anime") { + const animePath = rewriteAnimeCatalogTvApiPath(pathname, querySuffix); + if (animePath) { + return animePath; + } + } + + const rewritten = pathname.replace( + /^(\/api\/media\/tv\/)([^/]+)/, + (_full, prefix: string, routeId: string) => + `${prefix}${toTvApiRouteId(decodeURIComponent(routeId), catalog)}`, + ); + const withQuery = query ? `${rewritten}?${query}` : rewritten; + return withTvDetailCatalogQuery(withQuery, catalog); +}; + +/** Use the page URL's namespace so AniList and TMDB ids never share `/api/tv`. */ +export const withPageTvApiPath = ( + path: string, + pagePathname: string | null | undefined, +): string => { + const catalog = pagePathname + ? resolveTvDetailCatalogFromPathname(pagePathname) + : null; + const tvCatalog = catalog === "anime" ? "anime" : null; + const [apiPathname, query = ""] = path.split("?"); + const tvMatch = apiPathname.match(/^\/api\/tv\/([^/]+)(\/.*)?$/); + if (!tvMatch?.[1] || !pagePathname) { + return withTvApiRouteId(path, tvCatalog); + } + + const routeId = resolveTvDetailRouteId( + pagePathname, + decodeURIComponent(tvMatch[1]), + ); + const rest = tvMatch[2] ?? ""; + const rebuilt = `/api/tv/${routeId}${rest}${query ? `?${query}` : ""}`; + return withTvApiRouteId(rebuilt, tvCatalog); +}; + export const withTvDetailCatalogQuery = ( path: string, catalog: TvDetailCatalog | null, diff --git a/apps/web/lib/tv-show-detail-client.ts b/apps/web/lib/tv-show-detail-client.ts new file mode 100644 index 00000000..5b9c10a7 --- /dev/null +++ b/apps/web/lib/tv-show-detail-client.ts @@ -0,0 +1,71 @@ +export type TvShowDetailClientPayload = { + 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 }>; + status?: string | null; + last_episode_to_air?: unknown; + next_episode_to_air?: unknown; +}; + +export type FetchedTvShowDetail = { + detail: TvShowDetailClientPayload; + catalog: "anime" | null; +}; + +const normalizeTitleKey = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); + +export const tvDetailTitleMatches = ( + actual: string | undefined, + expected: string | undefined, +): boolean => { + if (!expected?.trim()) return true; + if (!actual?.trim()) return false; + const left = normalizeTitleKey(actual); + const right = normalizeTitleKey(expected); + return left === right || left.startsWith(right) || right.startsWith(left); +}; + +const readTvDetail = async ( + path: string, +): Promise<TvShowDetailClientPayload | null> => { + const response = await fetch(path); + if (!response.ok) return null; + return (await response.json()) as TvShowDetailClientPayload; +}; + +export const fetchTvShowDetailClient = async ( + contentId: number, + options?: { expectedTitle?: string; catalog?: "anime" | null }, +): Promise<FetchedTvShowDetail | null> => { + const expected = options?.expectedTitle; + + if (options?.catalog === "anime") { + const anilist = await readTvDetail(`/api/anime/${contentId}`); + return anilist ? { detail: anilist, catalog: "anime" } : null; + } + + const tmdb = await readTvDetail(`/api/tv/${contentId}`); + if (tmdb && tvDetailTitleMatches(tmdb.name, expected)) { + return { detail: tmdb, catalog: null }; + } + + const anilist = await readTvDetail(`/api/anime/${contentId}`); + if (anilist && tvDetailTitleMatches(anilist.name, expected)) { + return { detail: anilist, catalog: "anime" }; + } + + if (tmdb) { + return { detail: tmdb, catalog: null }; + } + + return anilist ? { detail: anilist, catalog: "anime" } : null; +}; diff --git a/apps/web/lib/tv-watch-target.ts b/apps/web/lib/tv-watch-target.ts index e4a0bd8e..cbcd8e1f 100644 --- a/apps/web/lib/tv-watch-target.ts +++ b/apps/web/lib/tv-watch-target.ts @@ -5,9 +5,13 @@ import { isAnilistTvRouteId, } from "@/lib/anilist-route-id"; import { + getLatestTvPlaybackCoords, getPlaybackProgress, + getRememberedLastTvEpisode, resolveResumeTime, + type TvEpisodeCoords, } from "@/lib/playback/progress-storage"; +import { getVidsrcLastTvEpisode } from "@/lib/playback/recently-watched"; export const normalizeTvContentKey = (id: string | number): number | null => { const asString = String(id); @@ -21,7 +25,11 @@ export const normalizeTvContentKey = (id: string | number): number | null => { export type TvWatchTarget = | { source: "selection"; episode: Episode; seasonNumber: number } - | { source: "watchlist"; seasonNumber: number; episodeNumber: number } + | { + source: "watchlist" | "progress"; + seasonNumber: number; + episodeNumber: number; + } | { source: "initial"; episode: Episode; seasonNumber: number }; export type TvWatchTargetState = { @@ -30,19 +38,70 @@ export type TvWatchTargetState = { seasonNumber: number | null; }; +export type LocalTvWatchCoords = { + seasonNumber: number; + episodeNumber: number; +}; + +const pickNewestTvCoords = ( + candidates: Array<TvEpisodeCoords | null>, +): LocalTvWatchCoords | null => { + const present = candidates.filter( + (candidate): candidate is TvEpisodeCoords => candidate != null, + ); + if (present.length === 0) { + return null; + } + + present.sort((left, right) => right.updatedAt - left.updatedAt); + const newest = present[0]; + if (!newest) { + return null; + } + + return { + seasonNumber: newest.seasonNumber, + episodeNumber: newest.episodeNumber, + }; +}; + +export function resolveLocalTvWatchCoords( + contentId: number, +): LocalTvWatchCoords | null { + const contentKey = normalizeTvContentKey(contentId); + if (contentKey === null) { + return null; + } + + return pickNewestTvCoords([ + getRememberedLastTvEpisode(contentKey), + getLatestTvPlaybackCoords(contentKey), + getVidsrcLastTvEpisode(contentKey), + ]); +} + +export function isTvCoordsWatchTarget( + target: TvWatchTarget, +): target is Extract<TvWatchTarget, { episodeNumber: number }> { + return target.source === "watchlist" || target.source === "progress"; +} + export function resolveTvWatchTarget( contentId: number, state: TvWatchTargetState, watchlistItem?: WatchlistItem | null, initialEpisode?: Episode | null, initialSeasonNumber?: number | null, + localCoords?: LocalTvWatchCoords | null, ): TvWatchTarget | null { const contentKey = normalizeTvContentKey(contentId); + const stateKey = normalizeTvContentKey(state.tvShowId ?? ""); + const storeMatchesContent = contentKey !== null && stateKey === contentKey; + if ( state.selectedEpisode && - contentKey !== null && - normalizeTvContentKey(state.tvShowId ?? "") === contentKey && + storeMatchesContent && state.seasonNumber != null ) { return { @@ -52,6 +111,22 @@ export function resolveTvWatchTarget( }; } + if ( + storeMatchesContent && + state.seasonNumber != null && + state.selectedEpisode === null + ) { + return null; + } + + if (localCoords?.seasonNumber && localCoords.episodeNumber) { + return { + source: "progress", + seasonNumber: localCoords.seasonNumber, + episodeNumber: localCoords.episodeNumber, + }; + } + if (watchlistItem?.lastWatchedSeason && watchlistItem?.lastWatchedEpisode) { return { source: "watchlist", @@ -77,10 +152,9 @@ export function tvWatchTargetCoords(target: TvWatchTarget): { } { return { seasonNumber: target.seasonNumber, - episodeNumber: - target.source === "watchlist" - ? target.episodeNumber - : target.episode.episode_number, + episodeNumber: isTvCoordsWatchTarget(target) + ? target.episodeNumber + : target.episode.episode_number, }; } @@ -147,7 +221,7 @@ export function isSameTvWatchTarget( return false; } - if (target.source === "watchlist") { + if (isTvCoordsWatchTarget(target)) { return ( state.seasonNumber === target.seasonNumber && state.selectedEpisode.episode_number === target.episodeNumber diff --git a/apps/web/lib/watchlist/resolve-initial-watchlist-status.ts b/apps/web/lib/watchlist/resolve-initial-watchlist-status.ts index fda474cf..420a2f85 100644 --- a/apps/web/lib/watchlist/resolve-initial-watchlist-status.ts +++ b/apps/web/lib/watchlist/resolve-initial-watchlist-status.ts @@ -1,6 +1,6 @@ import { + getLatestTvPlaybackCoords, getPlaybackProgress, - listPlaybackProgress, resolveResumeTime, } from "@/lib/playback/progress-storage"; import type { WatchlistStatus } from "@/lib/domain/watchlist"; @@ -58,27 +58,20 @@ export function resolveInitialWatchlistStatus(params: { }; } - // TV Shows: check for any recorded episode progress - const allProgress = listPlaybackProgress(); - const tvEntries = allProgress.filter( - (item) => item.mediaType === "tv" && item.contentId === contentId, - ); + const latest = getLatestTvPlaybackCoords(contentId); - if (tvEntries.length > 0) { - // List is already sorted by updatedAt desc - const latest = tvEntries[0]; - if (latest && latest.watched > 0) { - const hasEpisodeInfo = - typeof latest.seasonNumber === "number" && - typeof latest.episodeNumber === "number"; - - const epLabel = hasEpisodeInfo - ? ` (S${latest.seasonNumber}:E${latest.episodeNumber})` - : ""; + if (latest) { + const progress = getPlaybackProgress({ + mediaType: "tv", + contentId, + seasonNumber: latest.seasonNumber, + episodeNumber: latest.episodeNumber, + }); + if (progress && progress.watched > 0) { return { status: "watching", - label: `Saved to Watching${epLabel}`, + label: `Saved to Watching (S${latest.seasonNumber}:E${latest.episodeNumber})`, seasonNumber: latest.seasonNumber, episodeNumber: latest.episodeNumber, }; diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 20e3a3b0..a2fcaf76 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,3 +1,4 @@ +import { isFfsHostAllowedPath } from "@/lib/ffs/ffs-host-paths"; import { isFfsHost } from "@/lib/ffs/require-ffs-host"; import { apiRequestGuardResponse } from "@/lib/api/request-guard"; import { DEFAULT_FLAG_VALUES } from "@/lib/flags/flag-catalog"; @@ -44,11 +45,7 @@ export async function middleware(request: NextRequest) { const host = request.headers.get("host"); const ffsHost = isFfsHost(host); - if ( - ffsHost && - !pathname.startsWith("/ffs") && - !pathname.startsWith("/api/ffs") - ) { + if (ffsHost && !isFfsHostAllowedPath(pathname)) { return NextResponse.redirect(new URL("/ffs", request.url)); } diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index be83049e..3d6bd0a4 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -44,6 +44,16 @@ const nextConfig = { ]; }, headers: async () => { + const isDev = process.env.NODE_ENV !== "production"; + const playerAssetCache = isDev + ? { + key: "Cache-Control", + value: "no-cache, must-revalidate", + } + : { + key: "Cache-Control", + value: "public, max-age=31536000, immutable", + }; const immutablePublicAsset = { key: "Cache-Control", value: "public, max-age=31536000, immutable", @@ -52,11 +62,11 @@ const nextConfig = { return [ { source: "/vendor/player/:path*", - headers: [immutablePublicAsset], + headers: [playerAssetCache], }, { source: "/vendor/movi-player/:path*", - headers: [immutablePublicAsset], + headers: [playerAssetCache], }, { source: "/movie-banner.webp", diff --git a/apps/web/package.json b/apps/web/package.json index 8abeadd5..7e423fd9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,7 @@ "dev:next": "next dev --turbopack", "dev:webpack": "next dev", "build": "next build", - "prebuild": "node scripts/ensure-env.mjs", + "prebuild": "node scripts/clean-next.mjs && node scripts/ensure-env.mjs", "start": "next start", "lint": "biome check .", "lint:fix": "biome check . --write", @@ -26,7 +26,9 @@ "db:push": "drizzle-kit push", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", - "prepare-anime-mappings": "node scripts/prepare-anime-mappings.mjs" + "prepare-anime-mappings": "node scripts/prepare-anime-mappings.mjs", + "prepare-movi-player": "node scripts/prepare-movi-player.mjs", + "ensure-player": "node scripts/ensure-player.mjs" }, "dependencies": { "@auth/drizzle-adapter": "^1.10.0", @@ -35,9 +37,12 @@ "@calluspirates/shared": "workspace:*", "@cap.js/widget": "0.1.56", "@esbuild/linux-x64": "^0.25.4", + "@img/sharp-linux-x64": "0.35.3", "@gsap/react": "^2.1.2", "@hookform/resolvers": "^3.9.0", "@neondatabase/serverless": "^1.0.1", + "@nyumatflix/playback": "workspace:*", + "@nyumatflix/player": "workspace:*", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-aspect-ratio": "^1.1.0", @@ -102,6 +107,7 @@ "next-auth": "^5.0.0-beta.29", "nuqs": "^2.8.9", "postgres": "^3.4.7", + "prettier": "^3.5.3", "react": "^19.2.0", "react-day-picker": "^8.10.1", "react-dom": "^19.2.0", @@ -123,9 +129,7 @@ "vercel": "^54.2.0", "vite-plugin-magical-svg": "^1.3.0", "zod": "^3.24.4", - "zustand": "^5.0.6", - "@nyumatflix/playback": "workspace:*", - "@nyumatflix/player": "workspace:*" + "zustand": "^5.0.6" }, "devDependencies": { "@auth/core": "^0.41.3", @@ -144,7 +148,6 @@ "@vitest/ui": "^2.0.5", "dotenv": "^17.2.3", "drizzle-kit": "^0.31.8", - "emojibase-regex": "^17.0.0", "husky": "^9.1.6", "jsdom": "^25.0.0", "postcss": "^8", diff --git a/apps/web/scripts/clean-next.mjs b/apps/web/scripts/clean-next.mjs new file mode 100644 index 00000000..f4dfac85 --- /dev/null +++ b/apps/web/scripts/clean-next.mjs @@ -0,0 +1,7 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; + +const webRoot = join(import.meta.dirname, ".."); + +rmSync(join(webRoot, ".next"), { recursive: true, force: true }); +console.log("[clean-next] removed .next"); diff --git a/apps/web/scripts/ensure-env.mjs b/apps/web/scripts/ensure-env.mjs index a66dd6c6..1de5342a 100644 --- a/apps/web/scripts/ensure-env.mjs +++ b/apps/web/scripts/ensure-env.mjs @@ -1,10 +1,44 @@ -import { existsSync, lstatSync, symlinkSync } from "node:fs"; +import { + existsSync, + lstatSync, + readlinkSync, + symlinkSync, + unlinkSync, +} from "node:fs"; const links = [ [".env.local", "../../.env.local"], [".env", "../../.env"], ]; +const removeBrokenSymlink = (target) => { + try { + const stat = lstatSync(target); + if (!stat.isSymbolicLink()) { + return; + } + const linkTarget = readlinkSync(target); + if (!existsSync(linkTarget)) { + unlinkSync(target); + console.log(`[ensure-env] removed broken symlink ${target}`); + } + } catch { + // target does not exist + } +}; + +if (process.env.SKIP_ENSURE_ENV === "1") { + for (const [target] of links) { + removeBrokenSymlink(target); + } + console.log("[ensure-env] skipped (SKIP_ENSURE_ENV)"); + process.exit(0); +} + +for (const [target] of links) { + removeBrokenSymlink(target); +} + for (const [target, source] of links) { if (existsSync(target)) { continue; diff --git a/apps/web/scripts/ensure-player.mjs b/apps/web/scripts/ensure-player.mjs index c56b0aef..14e4fc89 100644 --- a/apps/web/scripts/ensure-player.mjs +++ b/apps/web/scripts/ensure-player.mjs @@ -9,9 +9,14 @@ const webRoot = path.resolve( ); const repoRoot = path.resolve(webRoot, "../.."); const vendorElement = path.join(webRoot, "public/vendor/player/element.js"); +const vendorCompat = path.join(webRoot, "public/vendor/player/compat.js"); const vendorWasm = path.join(webRoot, "public/vendor/player/wasm/movi.js"); -if (existsSync(vendorElement) && existsSync(vendorWasm)) { +if ( + existsSync(vendorElement) && + existsSync(vendorCompat) && + existsSync(vendorWasm) +) { process.exit(0); } diff --git a/apps/web/scripts/prepare-movi-player.mjs b/apps/web/scripts/prepare-movi-player.mjs new file mode 100644 index 00000000..1663c45b --- /dev/null +++ b/apps/web/scripts/prepare-movi-player.mjs @@ -0,0 +1,17 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const webRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const repoRoot = path.resolve(webRoot, "../.."); + +const result = spawnSync( + "bunx", + ["turbo", "build", "--filter=@nyumatflix/player"], + { cwd: repoRoot, stdio: "inherit" }, +); + +process.exit(result.status ?? 1); diff --git a/apps/web/test/suppress-console.ts b/apps/web/test/suppress-console.ts new file mode 100644 index 00000000..2818b14c --- /dev/null +++ b/apps/web/test/suppress-console.ts @@ -0,0 +1,15 @@ +import { vi, type MockInstance } from "vitest"; + +type ConsoleLevel = "log" | "warn" | "error"; + +export const suppressConsole = (...levels: ConsoleLevel[]): (() => void) => { + const spies: MockInstance[] = levels.map((level) => + vi.spyOn(console, level).mockImplementation(() => undefined), + ); + + return () => { + for (const spy of spies) { + spy.mockRestore(); + } + }; +}; diff --git a/apps/web/utils/media-metadata-helpers.ts b/apps/web/utils/media-metadata-helpers.ts index ff3def8b..69cb81dd 100644 --- a/apps/web/utils/media-metadata-helpers.ts +++ b/apps/web/utils/media-metadata-helpers.ts @@ -7,6 +7,7 @@ type GenerateMediaMetadataOptions = { media: MediaItem | null; mediaType: "movie" | "tv"; mediaId: string; + path?: string; notFoundTitle?: string; notFoundDescription?: string; }; @@ -35,6 +36,7 @@ export async function generateMediaMetadata({ media, mediaType, mediaId, + path: canonicalPath, notFoundTitle, notFoundDescription, }: GenerateMediaMetadataOptions): Promise<Metadata> { @@ -59,7 +61,8 @@ export async function generateMediaMetadata({ const year = dateField ? new Date(dateField).getFullYear() : ""; const titleWithYear = year ? `${title} (${year})` : title; const path = - mediaType === "movie" ? `/movies/${mediaId}` : `/tvshows/${mediaId}`; + canonicalPath ?? + (mediaType === "movie" ? `/movies/${mediaId}` : `/tvshows/${mediaId}`); return buildPageMetadata({ title: titleWithYear, diff --git a/bun.lock b/bun.lock index 69277c2e..4c0b5f3c 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "nyumatflix-v3", "devDependencies": { "husky": "^9.1.6", + "playwright": "^1.62.1", "turbo": "^2.5.4", }, }, @@ -21,6 +22,7 @@ "@esbuild/linux-x64": "^0.25.4", "@gsap/react": "^2.1.2", "@hookform/resolvers": "^3.9.0", + "@img/sharp-linux-x64": "0.35.3", "@neondatabase/serverless": "^1.0.1", "@nyumatflix/playback": "workspace:*", "@nyumatflix/player": "workspace:*", @@ -88,6 +90,7 @@ "next-auth": "^5.0.0-beta.29", "nuqs": "^2.8.9", "postgres": "^3.4.7", + "prettier": "^3.5.3", "react": "^19.2.0", "react-day-picker": "^8.10.1", "react-dom": "^19.2.0", @@ -128,7 +131,6 @@ "@vitest/ui": "^2.0.5", "dotenv": "^17.2.3", "drizzle-kit": "^0.31.8", - "emojibase-regex": "^17.0.0", "husky": "^9.1.6", "jsdom": "^25.0.0", "postcss": "^8", @@ -1535,8 +1537,6 @@ "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], - "emojibase-regex": ["emojibase-regex@17.0.0", "", {}, "sha512-iWlqiz2l2zNf2lDqgN/7qjqKHVUX6H4D+8F5vDZkgjyWyRFXcA5K4rxkoTl7AJmyjHnoytw0m/do2SrGByui+g=="], - "end-of-stream": ["end-of-stream@1.1.0", "", { "dependencies": { "once": "~1.3.0" } }, "sha512-EoulkdKF/1xa92q25PbjuDcgJ9RDHYU2Rs3SCIvs2/dSQ3BpmxneNHmA/M7fe60M3PrV7nNGTTNbkK62l6vXiQ=="], "engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="], @@ -1639,7 +1639,7 @@ "fs-extra": ["fs-extra@11.1.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -2897,8 +2897,6 @@ "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], @@ -2915,6 +2913,8 @@ "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "sandbox/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "schema-utils/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -2933,6 +2933,8 @@ "three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="], + "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "unenv/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -2949,6 +2951,8 @@ "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "vite/postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], "vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], @@ -3245,6 +3249,8 @@ "@vercel/backends/tsx/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], + "@vercel/backends/tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@vercel/fun/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], "@vercel/fun/semver/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -3357,6 +3363,8 @@ "@vercel/node/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "@vercel/node/tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@vercel/rust/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@vercel/rust/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -3621,6 +3629,8 @@ "@nyumatflix/player/vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "@nyumatflix/player/vitest/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@nyumatflix/player/vitest/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "@nyumatflix/player/vitest/vite/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], @@ -3703,6 +3713,8 @@ "@react-email/preview-server/next/sharp/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "@react-email/preview-server/tailwindcss/chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@react-email/preview-server/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "@react-email/preview-server/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], diff --git a/components/onboarding/onboarding-dialog.tsx b/components/onboarding/onboarding-dialog.tsx deleted file mode 100644 index e8f32ed7..00000000 --- a/components/onboarding/onboarding-dialog.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { Info, Loader2, User, X } from "lucide-react"; -import { BrandLogo } from "@/components/brand-logo"; -import { useRouter } from "next/navigation"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { useOnboardingForm } from "@/hooks/useOnboardingForm"; - -const showOnboardingSkipToast = () => { - toast.custom( - (id) => ( - <div className="relative flex w-[calc(100vw-2rem)] max-w-[380px] overflow-hidden rounded-2xl border border-white/12 bg-zinc-950/95 text-white shadow-[0_18px_60px_rgba(0,0,0,0.55)] backdrop-blur-xl"> - <div className="w-1 bg-sky-300" /> - <div className="flex min-w-0 flex-1 gap-3 px-4 py-3.5"> - <div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-xl border border-sky-300/25 bg-sky-300/12 text-sky-200"> - <Info className="size-4" /> - </div> - <div className="min-w-0 flex-1 pr-7"> - <p className="text-sm font-semibold leading-5 text-zinc-50"> - Skipped for now - </p> - <p className="mt-1 text-sm leading-5 text-zinc-400"> - The next time you open NyumatFlix, we'll ask you again. - </p> - </div> - <button - type="button" - aria-label="Dismiss notification" - onClick={() => toast.dismiss(id)} - className="absolute right-3 top-3 flex size-7 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-white/8 hover:text-zinc-100" - > - <X className="size-4" /> - </button> - </div> - </div> - ), - { duration: 4200 }, - ); -}; - -interface OnboardingDialogProps { - open: boolean; - onComplete: () => void; -} - -export const OnboardingDialog = ({ - open, - onComplete, -}: OnboardingDialogProps) => { - const router = useRouter(); - const { name, isLoading, setName, handleSubmit, handleSkip } = - useOnboardingForm(); - - const completeAndRedirect = () => { - onComplete(); - router.push("/watchlist"); - }; - - return ( - <Dialog open={open}> - <DialogContent - aria-describedby={undefined} - className="w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-2xl border-white/12 bg-zinc-950/92 p-0 text-white shadow-[0_24px_80px_rgba(0,0,0,0.6)] backdrop-blur-xl sm:max-w-md" - hideCloseButton - > - <div className="border-b border-white/10 bg-white/[0.03] px-6 py-6 sm:px-8"> - <DialogHeader className="space-y-5 text-center"> - <BrandLogo placement="onboarding" /> - <div className="mx-auto max-w-sm space-y-3"> - <DialogTitle className="text-2xl font-semibold leading-tight tracking-tight text-white sm:text-3xl"> - Welcome to NyumatFlix - </DialogTitle> - </div> - </DialogHeader> - </div> - - <form - onSubmit={async (e) => { - e.preventDefault(); - - if (!name.trim()) { - toast.error("Please enter your name"); - return; - } - - try { - await handleSubmit(e, () => { - toast.success("Welcome to NyumatFlix!"); - completeAndRedirect(); - }); - } catch (error) { - console.error("Error updating name:", error); - toast.error( - error instanceof Error - ? error.message - : "Failed to update name", - ); - } - }} - className="space-y-6 px-6 py-6 sm:px-8" - > - <div> - <Label - htmlFor="name" - className="mb-3 block text-sm font-medium text-zinc-200" - > - Display name - </Label> - <div className="relative flex h-12 items-center rounded-xl border border-white/12 bg-black/30 px-3 transition-colors focus-within:border-sky-300/45 focus-within:ring-2 focus-within:ring-sky-300/20"> - <User className="mr-2.5 size-4 shrink-0 text-zinc-500" /> - <input - id="name" - type="text" - placeholder="What should we call you?" - value={name} - onChange={(e) => setName(e.target.value)} - className="h-full min-w-0 flex-1 appearance-none border-0 bg-transparent px-0 py-0 text-base text-white shadow-none outline-none ring-0 placeholder:text-zinc-600 focus:border-0 focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:opacity-60" - maxLength={100} - disabled={isLoading} - autoFocus - /> - </div> - <p className="mt-3 text-xs leading-5 text-zinc-500"> - This can't be changed later, so choose wisely. - </p> - </div> - - <DialogFooter className="gap-3 pt-1 sm:flex-row sm:justify-between sm:space-x-0"> - <Button - type="button" - onClick={() => { - handleSkip(() => { - showOnboardingSkipToast(); - completeAndRedirect(); - }); - }} - disabled={isLoading} - variant="ghost" - className="h-11 w-full rounded-xl px-5 text-zinc-300 shadow-none hover:bg-white/8 hover:text-white sm:w-auto" - > - Skip for now - </Button> - <Button - type="submit" - disabled={isLoading || !name.trim()} - className="h-11 w-full rounded-xl border-sky-300/20 bg-sky-300/15 px-5 text-sm font-semibold text-sky-50 shadow-none hover:border-sky-300/35 hover:bg-sky-300/22 sm:w-auto" - > - {isLoading ? ( - <> - <Loader2 className="mr-2 size-4 animate-spin" /> - Saving... - </> - ) : ( - "Continue" - )} - </Button> - </DialogFooter> - </form> - </DialogContent> - </Dialog> - ); -}; diff --git a/docker-compose.ffs.yml b/docker-compose.ffs.yml index 62d2f3ed..a03846c0 100644 --- a/docker-compose.ffs.yml +++ b/docker-compose.ffs.yml @@ -17,3 +17,4 @@ networks: volumes: flipt-data: + name: ${FLIPT_VOLUME_NAME:-nyumatflix_flipt-data} diff --git a/docker-compose.local-stack.yml b/docker-compose.local-stack.yml index 3d1838cd..3aaca113 100644 --- a/docker-compose.local-stack.yml +++ b/docker-compose.local-stack.yml @@ -51,5 +51,6 @@ networks: volumes: flipt-data: + name: ${FLIPT_VOLUME_NAME:-nyumatflix_flipt-data} cap-valkey-data: name: nyumatflix-cap-valkey-data diff --git a/flipt/config.yaml b/flipt/config.yaml index 65b81f79..af5cab83 100644 --- a/flipt/config.yaml +++ b/flipt/config.yaml @@ -15,6 +15,9 @@ authentication: metadata: description: "NyumatFlix app read/write" +evaluation: + include_flag_metadata: true + storage: default: backend: diff --git a/lib/personalization/because-you-watched.ts b/lib/personalization/because-you-watched.ts deleted file mode 100644 index 79f64457..00000000 --- a/lib/personalization/because-you-watched.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { takeUniqueByIdInOrder } from "@/lib/catalog-page-dedupe"; -import { - fetchMovieRecommendationsPageClient, - fetchTvRecommendationsPageClient, -} from "@/lib/media-detail-tab-client"; -import type { RecentlyWatchedStub } from "@/lib/playback/recently-watched"; -import type { MovieWithMediaType, TvShowWithMediaType } from "@/tmdb/models"; - -export const BECAUSE_YOU_WATCHED_LIMIT = 20; - -export type BecauseYouWatchedResult = - | { - seedTitle: string; - mediaType: "movie"; - items: MovieWithMediaType[]; - } - | { - seedTitle: string; - mediaType: "tv"; - items: TvShowWithMediaType[]; - }; - -type MediaDetailResponse = { - id?: number; - title?: string; - name?: string; -}; - -const isMatchingTmdbDetail = ( - stub: RecentlyWatchedStub, - detail: MediaDetailResponse, -): boolean => detail.id === stub.contentId; - -async function fetchSeedTitle( - stub: RecentlyWatchedStub, -): Promise<string | null> { - 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; - } - - const detail = (await response.json()) as MediaDetailResponse; - if (!isMatchingTmdbDetail(stub, detail)) { - return null; - } - - return stub.mediaType === "movie" - ? (detail.title ?? stub.title ?? null) - : (detail.name ?? stub.title ?? null); -} - -export async function fetchBecauseYouWatchedRow( - stubs: RecentlyWatchedStub[], - excludeIds: ReadonlySet<number>, -): Promise<BecauseYouWatchedResult | null> { - const seen = new Set(excludeIds); - - for (const stub of stubs) { - const seedTitle = await fetchSeedTitle(stub); - if (!seedTitle) { - continue; - } - - if (stub.mediaType === "movie") { - const page = await fetchMovieRecommendationsPageClient( - String(stub.contentId), - "1", - ); - const items = takeUniqueByIdInOrder( - (page.results ?? []).map( - (movie) => - ({ - ...movie, - media_type: "movie", - }) satisfies MovieWithMediaType, - ), - seen, - BECAUSE_YOU_WATCHED_LIMIT, - ); - - if (items.length === 0) { - continue; - } - - return { - seedTitle, - mediaType: "movie", - items, - }; - } - - const page = await fetchTvRecommendationsPageClient( - String(stub.contentId), - "1", - ); - const items = takeUniqueByIdInOrder( - (page.results ?? []).map( - (show) => - ({ - ...show, - media_type: "tv", - }) satisfies TvShowWithMediaType, - ), - seen, - BECAUSE_YOU_WATCHED_LIMIT, - ); - - if (items.length === 0) { - continue; - } - - return { - seedTitle, - mediaType: "tv", - items, - }; - } - - return null; -} diff --git a/lib/personalization/up-next.ts b/lib/personalization/up-next.ts deleted file mode 100644 index 552afbf9..00000000 --- a/lib/personalization/up-next.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { EpisodeCoordinates, EpisodeInfo } from "@/lib/domain/episodes"; -import type { WatchlistItem } from "@/lib/domain/watchlist"; - -export type UpNextCandidate = { - contentId: number; - watchlistItem: WatchlistItem; - episodeInfo: EpisodeInfo; -}; - -export const buildUpNextHref = ( - contentId: number, - nextUnwatchedEpisode: EpisodeCoordinates | null, - fallbackSeason?: number | null, - fallbackEpisode?: number | null, -): string => { - if (nextUnwatchedEpisode) { - return `/tvshows/${contentId}?season=${nextUnwatchedEpisode.seasonNumber}&episode=${nextUnwatchedEpisode.episodeNumber}&autoplay=true`; - } - - const season = fallbackSeason && fallbackSeason > 0 ? fallbackSeason : 1; - const episode = - fallbackEpisode && fallbackEpisode > 0 ? fallbackEpisode + 1 : 1; - - return `/tvshows/${contentId}?season=${season}&episode=${episode}&autoplay=true`; -}; - -export const isUpNextInboxCandidate = ( - item: WatchlistItem, - episodeInfo: EpisodeInfo | undefined, -): episodeInfo is EpisodeInfo => - item.mediaType === "tv" && - item.status === "watching" && - Boolean(episodeInfo?.hasUnwatchedEpisodes); - -export const collectUpNextCandidates = ( - watchlistItems: WatchlistItem[], - episodeData: Record<number, EpisodeInfo>, -): UpNextCandidate[] => - watchlistItems - .filter((item) => isUpNextInboxCandidate(item, episodeData[item.contentId])) - .map((item) => ({ - contentId: item.contentId, - watchlistItem: item, - episodeInfo: episodeData[item.contentId], - })) - .sort((left, right) => { - const leftDate = left.episodeInfo.latestEpisodeAirDate?.getTime() ?? 0; - const rightDate = right.episodeInfo.latestEpisodeAirDate?.getTime() ?? 0; - if (leftDate !== rightDate) { - return rightDate - leftDate; - } - - return ( - right.episodeInfo.unwatchedEpisodeCount - - left.episodeInfo.unwatchedEpisodeCount - ); - }); - -export const parseEpisodeDataResponse = ( - raw: Record<string, unknown>, -): Record<number, EpisodeInfo> => { - const parsed: Record<number, EpisodeInfo> = {}; - - for (const [contentId, info] of Object.entries(raw)) { - if (!info || typeof info !== "object") { - continue; - } - - const typed = info as EpisodeInfo & { - nextEpisodeDate?: string | Date | null; - latestEpisodeAirDate?: string | Date | null; - }; - - parsed[Number(contentId)] = { - ...typed, - nextEpisodeDate: typed.nextEpisodeDate - ? new Date(typed.nextEpisodeDate) - : null, - latestEpisodeAirDate: typed.latestEpisodeAirDate - ? new Date(typed.latestEpisodeAirDate) - : null, - }; - } - - return parsed; -}; diff --git a/package.json b/package.json index ad0dbf1b..451f6bc5 100644 --- a/package.json +++ b/package.json @@ -37,12 +37,15 @@ "cap:logs": "./scripts/local-compose.sh logs -f cap", "bp": "./scripts/deploy.sh bp", "deploy": "bun scripts/nyumatflix.ts", + "deploy:fast": "bun scripts/nyumatflix.ts fast", "deploy:plain": "./scripts/deploy-remote.sh", + "prepare-movi-player": "node apps/web/scripts/prepare-movi-player.mjs", "deploy:env": "./scripts/sync-prod-env.sh push", "rotate": "bun scripts/rotate-vpn.mts" }, "devDependencies": { - "turbo": "^2.5.4", - "husky": "^9.1.6" + "husky": "^9.1.6", + "playwright": "^1.62.1", + "turbo": "^2.5.4" } } diff --git a/packages/player/NYUMATFLIX-PATCHES.md b/packages/player/NYUMATFLIX-PATCHES.md index 8390891d..eb0e241a 100644 --- a/packages/player/NYUMATFLIX-PATCHES.md +++ b/packages/player/NYUMATFLIX-PATCHES.md @@ -5,5 +5,8 @@ 3. **demux audio starving** — `MoviPlayer.ts`: `audioBuffered < 0.1` → `0.05` 4. **stall audio low** — `MoviPlayer.ts`: stall threshold `0.05` → `0.02` 5. **isAudioStarved** — `AudioRenderer.ts`: buffer bar `0.1` → `0.05` +6. **native presentation** — Phase 1: `presentation: native|canvas` auto-select; adaptive/MP4/DRM → light-DOM `<video>` without canvas rVFC copy loop +7. **compat bundle** — `movi-player/compat` entry without eager WASM; lazy WASM via dynamic import in `FFmpegLoader.ts` +8. **browser matrix** — Safari native HLS first; Firefox prefers hls.js; Cast hidden on Firefox/canvas mode -Previously applied via regex in `scripts/prepare-movi-player.mjs`. +Previously applied via regex in `scripts/prepare-movi-player.mjs` (now builds `@nyumatflix/player` directly). diff --git a/packages/player/package.json b/packages/player/package.json index b755f0b1..a7608a98 100644 --- a/packages/player/package.json +++ b/packages/player/package.json @@ -27,6 +27,11 @@ "import": "./dist/element.js", "require": "./dist/element.cjs", "types": "./dist/element.d.ts" + }, + "./compat": { + "import": "./dist/compat.js", + "require": "./dist/compat.cjs", + "types": "./dist/compat.d.ts" } }, "files": [ @@ -42,7 +47,7 @@ "copy:vendor": "node scripts/copy-vendor.mjs", "build:wasm:force": "mkdir -p dist/wasm && docker compose run --rm -e FORCE_FFMPEG=true -e FORCE_DAV1D=true build", "dev": "vite --open /dev/", - "test": "vitest run --passWithNoTests", + "test": "vitest run src --passWithNoTests", "clean": "rm -rf dist", "typecheck": "tsc --noEmit", "prepublishOnly": "npm run build && npm run typecheck", diff --git a/packages/player/packages/react/index.tsx b/packages/player/packages/react/index.tsx index 5a7c1d58..1aaa6159 100644 --- a/packages/player/packages/react/index.tsx +++ b/packages/player/packages/react/index.tsx @@ -46,13 +46,29 @@ export const MoviPlayer = React.forwardRef<MoviElement, MoviPlayerProps>( const elRef = React.useRef<MoviElement | null>(null); React.useImperativeHandle(ref, () => elRef.current as MoviElement, []); + const onReadyRef = React.useRef(props.onReady); + const onQoeRef = React.useRef(props.onQoe); + const onTimeUpdateRef = React.useRef(props.onTimeUpdate); + const onPlayRef = React.useRef(props.onPlay); + const onPauseRef = React.useRef(props.onPause); + const onEndedRef = React.useRef(props.onEnded); + const onErrorRef = React.useRef(props.onError); + onReadyRef.current = props.onReady; + onQoeRef.current = props.onQoe; + onTimeUpdateRef.current = props.onTimeUpdate; + onPlayRef.current = props.onPlay; + onPauseRef.current = props.onPause; + onEndedRef.current = props.onEnded; + onErrorRef.current = props.onError; + // Reflect declarative attributes onto the element every render. Booleans // become presence/absence; everything else becomes a string attribute. React.useEffect(() => { const el = elRef.current; if (!el) return; for (const [key, value] of Object.entries(props)) { - if (EVENT_PROPS.has(key) || value === undefined || value === null) continue; + if (EVENT_PROPS.has(key) || value === undefined || value === null) + continue; const attr = key.toLowerCase(); if (typeof value === "boolean") { if (value) el.setAttribute(attr, ""); @@ -68,30 +84,23 @@ export const MoviPlayer = React.forwardRef<MoviElement, MoviPlayerProps>( const el = elRef.current; if (!el) return; const listeners: Array<[string, EventListener]> = []; - const add = (name: string, fn?: (detail: any) => void) => { + const add = (name: string, fn?: (detail: unknown) => void) => { if (!fn) return; const l: EventListener = (e) => fn((e as CustomEvent).detail); el.addEventListener(name, l); listeners.push([name, l]); }; - add("movi-qoe", props.onQoe); - add("timeupdate", props.onTimeUpdate); - if (props.onPlay) add("play", () => props.onPlay!()); - if (props.onPause) add("pause", () => props.onPause!()); - if (props.onEnded) add("ended", () => props.onEnded!()); - add("error", props.onError); - props.onReady?.(el); + add("movi-qoe", (detail) => onQoeRef.current?.(detail as QoEEvent)); + add("timeupdate", (detail) => + onTimeUpdateRef.current?.(detail as number), + ); + add("play", () => onPlayRef.current?.()); + add("pause", () => onPauseRef.current?.()); + add("ended", () => onEndedRef.current?.()); + add("error", (detail) => onErrorRef.current?.(detail)); + onReadyRef.current?.(el); return () => listeners.forEach(([n, l]) => el.removeEventListener(n, l)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - props.onQoe, - props.onTimeUpdate, - props.onPlay, - props.onPause, - props.onEnded, - props.onError, - props.onReady, - ]); + }, []); // createElement avoids needing a JSX.IntrinsicElements augmentation for the // custom tag; React passes unknown props straight through as attributes. diff --git a/packages/player/scripts/build-standalone.js b/packages/player/scripts/build-standalone.js index 85f0c902..a2a55e72 100644 --- a/packages/player/scripts/build-standalone.js +++ b/packages/player/scripts/build-standalone.js @@ -18,6 +18,7 @@ const entries = [ { name: 'demuxer', path: 'src/demuxer.ts' }, { name: 'player', path: 'src/player.ts' }, { name: 'element', path: 'src/element.ts' }, + { name: 'compat', path: 'src/compat.ts' }, { name: 'index', path: 'src/index.ts' }, ]; diff --git a/packages/player/scripts/copy-vendor.mjs b/packages/player/scripts/copy-vendor.mjs index a025a264..676dc248 100644 --- a/packages/player/scripts/copy-vendor.mjs +++ b/packages/player/scripts/copy-vendor.mjs @@ -7,8 +7,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const playerRoot = path.resolve(__dirname, ".."); const repoRoot = path.resolve(playerRoot, "../.."); const source = path.join(playerRoot, "dist/element.js"); +const compatSource = path.join(playerRoot, "dist/compat.js"); const targetDir = path.join(repoRoot, "apps/web/public/vendor/player"); const target = path.join(targetDir, "element.js"); +const compatTarget = path.join(targetDir, "compat.js"); const versionFile = path.join( repoRoot, "apps/web/lib/player/player-asset.generated.ts", @@ -23,6 +25,13 @@ const code = fs.readFileSync(source, "utf8"); fs.mkdirSync(targetDir, { recursive: true }); fs.writeFileSync(target, code); +if (fs.existsSync(compatSource)) { + fs.writeFileSync(compatTarget, fs.readFileSync(compatSource, "utf8")); + console.log( + `[copy-vendor] wrote ${path.relative(repoRoot, compatTarget)}`, + ); +} + const wasmDir = path.join(playerRoot, "dist/wasm"); const targetWasmDir = path.join(targetDir, "wasm"); if (fs.existsSync(wasmDir)) { diff --git a/packages/player/src/__tests__/chapter-markers.test.ts b/packages/player/src/__tests__/chapter-markers.test.ts new file mode 100644 index 00000000..9c78f112 --- /dev/null +++ b/packages/player/src/__tests__/chapter-markers.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { + chapterSegmentPercent, + findChapterAtTime, + introDbSegmentClassName, +} from "../utils/chapterMarkers"; + +describe("chapterMarkers", () => { + const chapters = [ + { title: "Intro", start: 0, end: 90 }, + { title: "Credits", start: 1380, end: 1440 }, + ]; + + it("finds a chapter only inside its explicit range", () => { + expect(findChapterAtTime(chapters, 45)?.title).toBe("Intro"); + expect(findChapterAtTime(chapters, 500)).toBeNull(); + expect(findChapterAtTime(chapters, 1390)?.title).toBe("Credits"); + }); + + it("sizes chapter segments from start to end", () => { + expect(chapterSegmentPercent(chapters[0], 1500)).toEqual({ + left: 0, + width: 6, + }); + expect(chapterSegmentPercent(chapters[1], 1500)).toEqual({ + left: 92, + width: 4, + }); + }); + + it("maps introdb segment titles to css classes", () => { + expect(introDbSegmentClassName("Intro")).toBe("movi-chapter-segment--intro"); + expect(introDbSegmentClassName("credits")).toBe( + "movi-chapter-segment--credits", + ); + expect(introDbSegmentClassName("Main")).toBeNull(); + }); +}); diff --git a/packages/player/src/__tests__/external-subtitles.test.ts b/packages/player/src/__tests__/external-subtitles.test.ts new file mode 100644 index 00000000..4d062bf5 --- /dev/null +++ b/packages/player/src/__tests__/external-subtitles.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { getExternalSubtitleId, type SubtitleSourceEntry } from "../types"; + +describe("external subtitle track identity", () => { + it("uses explicit id when provided", () => { + const track: SubtitleSourceEntry = { + id: "scrape-english-abc123", + url: "https://cdn.example/en.vtt", + lang: "English", + label: "English · JustAnime", + }; + + expect(getExternalSubtitleId(track)).toBe("scrape-english-abc123"); + }); + + it("falls back to url when id is omitted", () => { + const track: SubtitleSourceEntry = { + url: "https://cdn.example/en-kickass.vtt", + lang: "English", + label: "English · KickAssAnime", + }; + + expect(getExternalSubtitleId(track)).toBe("https://cdn.example/en-kickass.vtt"); + }); + + it("keeps ids unique for same-language tracks", () => { + const tracks: SubtitleSourceEntry[] = [ + { + id: "scrape-english-aaa", + url: "https://cdn.example/a.vtt", + lang: "English", + label: "English · JustAnime", + }, + { + id: "scrape-english-bbb", + url: "https://cdn.example/b.vtt", + lang: "English", + label: "English · KickAssAnime", + }, + ]; + + const ids = tracks.map(getExternalSubtitleId); + expect(new Set(ids).size).toBe(2); + }); +}); diff --git a/packages/player/src/__tests__/hls-audio-tracks.test.ts b/packages/player/src/__tests__/hls-audio-tracks.test.ts new file mode 100644 index 00000000..b6570b28 --- /dev/null +++ b/packages/player/src/__tests__/hls-audio-tracks.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + mapHlsAudioRenditions, + mapNativeAudioTracks, +} from "../render/HLSPlayerWrapper"; + +describe("mapHlsAudioRenditions", () => { + it("hides the audio menu when the master only has one rendition", () => { + expect( + mapHlsAudioRenditions([{ id: 0, name: "Japanese", lang: "jpn" }]), + ).toEqual([]); + }); + + it("exposes labeled tracks when the master has multiple AUDIO renditions", () => { + expect( + mapHlsAudioRenditions([ + { id: 0, name: "Japanese", lang: "jpn" }, + { id: 1, name: "English", lang: "eng" }, + ]), + ).toEqual([ + { + id: 0, + type: "audio", + codec: "", + language: "jpn", + label: "Japanese", + channels: 0, + sampleRate: 0, + }, + { + id: 1, + type: "audio", + codec: "", + language: "eng", + label: "English", + channels: 0, + sampleRate: 0, + }, + ]); + }); +}); + +describe("mapNativeAudioTracks", () => { + it("exposes Safari audioTracks when more than one language exists", () => { + const list = { + length: 2, + 0: { id: "ja", language: "ja", label: "Japanese", enabled: true }, + 1: { id: "en", language: "en", label: "English", enabled: false }, + }; + + expect(mapNativeAudioTracks(list)).toHaveLength(2); + expect(mapNativeAudioTracks(list)[1]?.label).toBe("English"); + }); +}); diff --git a/packages/player/src/__tests__/host-api.test.ts b/packages/player/src/__tests__/host-api.test.ts new file mode 100644 index 00000000..9d8713c3 --- /dev/null +++ b/packages/player/src/__tests__/host-api.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + isAdaptiveStreamUrl, + resolvePresentationMode, + usesCanvasCopyLoop, +} from "../core/presentation"; +import type { PlayerConfig } from "../types"; + +describe("host API presentation expectations", () => { + it("keeps HLS on native presentation without canvas copy loop", () => { + const fakeCanvas = {} as HTMLCanvasElement; + const config: PlayerConfig = { + presentation: resolvePresentationMode({ + source: { type: "url", url: "https://cdn.example/master.m3u8" }, + }), + source: { type: "url", url: "https://cdn.example/master.m3u8" }, + canvas: fakeCanvas, + renderer: "canvas", + }; + + expect(config.presentation).toBe("native"); + expect(usesCanvasCopyLoop(config)).toBe(false); + }); + + it("keeps MKV on canvas presentation with copy loop when canvas is present", () => { + const fakeCanvas = {} as HTMLCanvasElement; + const config: PlayerConfig = { + presentation: resolvePresentationMode({ + source: { type: "url", url: "https://cdn.example/movie.mkv" }, + }), + source: { type: "url", url: "https://cdn.example/movie.mkv" }, + canvas: fakeCanvas, + renderer: "canvas", + }; + + expect(config.presentation).toBe("canvas"); + expect(usesCanvasCopyLoop(config)).toBe(true); + }); + + it("classifies adaptive URLs for harness native mode", () => { + expect(isAdaptiveStreamUrl("https://x/stream.m3u8")).toBe(true); + expect(isAdaptiveStreamUrl("https://x/movie.mp4")).toBe(false); + }); +}); diff --git a/packages/player/src/__tests__/presentation.test.ts b/packages/player/src/__tests__/presentation.test.ts new file mode 100644 index 00000000..68bc1466 --- /dev/null +++ b/packages/player/src/__tests__/presentation.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + isAdaptiveStreamUrl, + isProgressiveNativeUrl, + resolvePresentationMode, + usesCanvasCopyLoop, +} from "../core/presentation"; +import type { PlayerConfig } from "../types"; + +describe("resolvePresentationMode", () => { + it("selects native for HLS URLs", () => { + expect( + resolvePresentationMode({ + source: { type: "url", url: "https://cdn.example/stream.m3u8" }, + }), + ).toBe("native"); + }); + + it("selects native for progressive MP4", () => { + expect( + resolvePresentationMode({ + source: { type: "url", url: "https://cdn.example/movie.mp4" }, + }), + ).toBe("native"); + }); + + it("selects canvas for MKV URLs by default", () => { + expect( + resolvePresentationMode({ + source: { type: "url", url: "https://cdn.example/movie.mkv" }, + }), + ).toBe("canvas"); + }); + + it("honors presentation=canvas override on HLS", () => { + expect( + resolvePresentationMode({ + source: { type: "url", url: "https://cdn.example/stream.m3u8" }, + presentation: "canvas", + }), + ).toBe("canvas"); + }); + + it("forces canvas when lcevc is enabled", () => { + expect( + resolvePresentationMode({ + source: { type: "url", url: "https://cdn.example/stream.m3u8" }, + lcevc: true, + }), + ).toBe("canvas"); + }); +}); + +describe("usesCanvasCopyLoop", () => { + const fakeCanvas = {} as HTMLCanvasElement; + + it("disables canvas copy on native presentation", () => { + const config: PlayerConfig = { + presentation: "native", + source: { type: "url", url: "https://cdn.example/stream.m3u8" }, + canvas: fakeCanvas, + renderer: "canvas", + }; + expect(usesCanvasCopyLoop(config)).toBe(false); + }); + + it("enables canvas copy for canvas presentation with canvas element", () => { + const config: PlayerConfig = { + presentation: "canvas", + source: { type: "url", url: "https://cdn.example/movie.mkv" }, + canvas: fakeCanvas, + renderer: "canvas", + }; + expect(usesCanvasCopyLoop(config)).toBe(true); + }); +}); + +describe("url helpers", () => { + it("detects adaptive stream extensions", () => { + expect(isAdaptiveStreamUrl("https://x/a.m3u8?token=1")).toBe(true); + expect(isAdaptiveStreamUrl("https://x/a.mpd")).toBe(true); + expect(isAdaptiveStreamUrl("https://x/a.mp4")).toBe(false); + }); + + it("detects progressive native extensions", () => { + expect(isProgressiveNativeUrl("https://x/a.webm")).toBe(true); + expect(isProgressiveNativeUrl("https://x/a.mkv")).toBe(false); + }); +}); diff --git a/packages/player/src/__tests__/progressive-native-tracks.test.ts b/packages/player/src/__tests__/progressive-native-tracks.test.ts new file mode 100644 index 00000000..477ea7ed --- /dev/null +++ b/packages/player/src/__tests__/progressive-native-tracks.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { TrackManager } from "../core/TrackManager"; +import { buildProgressiveNativeTracks } from "../render/ProgressiveVideoWrapper"; + +describe("buildProgressiveNativeTracks", () => { + it("registers a video track so native mp4 is not treated as audio-only", () => { + const tracks = buildProgressiveNativeTracks({ + videoWidth: 1920, + videoHeight: 1080, + }); + const manager = new TrackManager(); + manager.setTracks(tracks); + + expect(manager.getActiveVideoTrack()).toEqual( + expect.objectContaining({ + type: "video", + width: 1920, + height: 1080, + label: "1080p", + }), + ); + expect(manager.getAudioTracks()).toHaveLength(1); + }); + + it("omits a video track when the element has no picture", () => { + const tracks = buildProgressiveNativeTracks({ + videoWidth: 0, + videoHeight: 0, + }); + const manager = new TrackManager(); + manager.setTracks(tracks); + + expect(manager.getActiveVideoTrack()).toBeNull(); + expect(manager.getAudioTracks()).toHaveLength(1); + }); +}); diff --git a/packages/player/src/__tests__/track-manager.test.ts b/packages/player/src/__tests__/track-manager.test.ts new file mode 100644 index 00000000..30708d55 --- /dev/null +++ b/packages/player/src/__tests__/track-manager.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { TrackManager } from "../core/TrackManager"; +import type { VideoTrack } from "../types"; + +const videoTrack = ( + id: number, + height: number, + bitRate?: number, +): VideoTrack => ({ + id, + type: "video", + codec: "avc1", + width: height * 16 / 9, + height, + frameRate: 30, + bitRate, + label: `${height}p`, +}); + +describe("TrackManager.selectHighestVideoTrack", () => { + it("selects the highest height rendition instead of Auto", () => { + const manager = new TrackManager(); + manager.setTracks([ + videoTrack(-1, 0), + videoTrack(0, 480), + videoTrack(1, 1080), + videoTrack(2, 720), + ]); + + expect(manager.selectHighestVideoTrack()).toBe(true); + expect(manager.getActiveVideoTrack()?.id).toBe(1); + expect(manager.getActiveVideoTrack()?.height).toBe(1080); + }); + + it("breaks height ties with bitrate", () => { + const manager = new TrackManager(); + manager.setTracks([ + videoTrack(-1, 0), + videoTrack(0, 1080, 4_000_000), + videoTrack(1, 1080, 8_000_000), + ]); + + expect(manager.selectHighestVideoTrack()).toBe(true); + expect(manager.getActiveVideoTrack()?.id).toBe(1); + }); + + it("falls back to Auto when no manual renditions exist", () => { + const manager = new TrackManager(); + manager.setTracks([videoTrack(-1, 0)]); + + expect(manager.selectHighestVideoTrack()).toBe(true); + expect(manager.getActiveVideoTrack()?.id).toBe(-1); + }); +}); diff --git a/packages/player/src/compat.ts b/packages/player/src/compat.ts new file mode 100644 index 00000000..44eac258 --- /dev/null +++ b/packages/player/src/compat.ts @@ -0,0 +1,41 @@ +/** + * Compat entry — web component + stream/native wrappers without eager WASM. + * MKV/WebCodecs sources require the full `movi-player` bundle. + */ + +export type { + Track, + TrackType, + VideoTrack, + AudioTrack, + SubtitleTrack, + SubtitleCue, + SourceConfig, + CacheConfig, + RendererType, + PresentationMode, + DecoderType, + PlayerConfig, + MediaInfo, + PlayerState, + PlayerEventMap, + ExternalQualityEntry, + ChapterMarker, + PlayerErrorInfo, +} from "./types"; + +export { Logger, LogLevel } from "./utils/Logger"; +export { EventEmitter } from "./events/EventEmitter"; +export { MoviPlayer } from "./core/MoviPlayer"; +export { MoviElement } from "./render/MoviElement"; +export { + resolvePresentationMode, + isAdaptiveStreamUrl, + isProgressiveNativeUrl, +} from "./core/presentation"; + +import { MoviElement } from "./render/MoviElement"; + +if (typeof customElements !== "undefined" && !customElements.get("movi-player")) { + customElements.define("movi-player", MoviElement); +} diff --git a/packages/player/src/core/MoviPlayer.ts b/packages/player/src/core/MoviPlayer.ts index 6e1900f0..cb4a9904 100644 --- a/packages/player/src/core/MoviPlayer.ts +++ b/packages/player/src/core/MoviPlayer.ts @@ -16,7 +16,11 @@ import type { SubtitleSourceEntry, SubtitleCue, Packet, + PresentationMode, + ExternalQualityEntry, + ChapterMarker, } from "../types"; +import { getExternalSubtitleId } from "../types"; import { EventEmitter } from "../events/EventEmitter"; import { HttpSource, @@ -42,7 +46,14 @@ import { loadWasmModuleNew } from "../wasm/FFmpegLoader"; import { ShakaPlayerWrapper } from "../render/ShakaPlayerWrapper"; import { HLSPlayerWrapper } from "../render/HLSPlayerWrapper"; import { DASHPlayerWrapper } from "../render/DASHPlayerWrapper"; +import { ProgressiveVideoWrapper } from "../render/ProgressiveVideoWrapper"; import { ThumbnailRenderer } from "../utils/ThumbnailRenderer"; +import { + isProgressiveNativeUrl, + resolvePresentationMode, + shouldPreferHlsJs, + shouldPreferNativeHls, +} from "./presentation"; // Any of the three adaptive-streaming engines (Shaka primary; hls.js / dash.js // as fallbacks). They share the same surface; the Shaka-only extras (isLive, @@ -50,7 +61,8 @@ import { ThumbnailRenderer } from "../utils/ThumbnailRenderer"; type StreamWrapper = | ShakaPlayerWrapper | HLSPlayerWrapper - | DASHPlayerWrapper; + | DASHPlayerWrapper + | ProgressiveVideoWrapper; const TAG = "MoviPlayer"; @@ -93,7 +105,7 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { // External subtitle tracks (VTT/SRT) private _subtitleTracks: SubtitleSourceEntry[] = []; - private _activeSubtitleLang: string = ""; + private _activeExternalSubtitleId: string = ""; private _externalSubCues: SubtitleCue[] = []; private _externalSubTimer: number | null = null; public trackManager: TrackManager; @@ -102,6 +114,7 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { private mediaInfo: MediaInfo | null = null; private fileSize: number = -1; // Cached file size for buffer calculations private lastBufferedTime: number = 0; + private streamTrackHandlerUnsubs: Array<() => void> = []; /** * Enable/disable seek-bar scrub previews on an already-constructed player. @@ -135,19 +148,22 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { return true; } - /** Proxy a stream wrapper's events + mirror its TrackManager onto the player. */ private wireStreamWrapper(wrapper: StreamWrapper): void { const events = [ - "loadStart", "loadEnd", "play", "pause", "ended", "timeUpdate", - "durationChange", "stateChange", "error", "buffering", "seeking", "seeked", + "loadStart", "loadEnd", "timeUpdate", + "durationChange", "stateChange", "error", "seeking", "seeked", + "waiting", "playing", ] as const; events.forEach((evt) => { - // @ts-ignore — event names line up across the wrapper and player maps - wrapper.on(evt, (arg) => this.emit(evt, arg)); - }); - wrapper.trackManager.on("tracksChange", (tracks) => { - this.trackManager.setTracks(tracks); + wrapper.on(evt, (arg: unknown) => { + this.emit(evt, arg as never); + }); }); + if ("trackManager" in wrapper && wrapper.trackManager) { + wrapper.trackManager.on("tracksChange", (tracks: Track[]) => { + this.trackManager.setTracks(tracks); + }); + } } // Decoders and Renderers @@ -161,16 +177,18 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { // the user nudges the delay value while the same track is active. private prefetchedSubtitleStream: number | null = null; private prefetchInFlight: boolean = false; + private _nativeSubtitleDelay = 0; // Embedded cover art (ID3v2 APIC, FLAC PICTURE, MP4 covr, MKV attachment). // Extracted once at load time when the demuxer reports an attached_pic // pseudo-stream; null for plain video files or audio without artwork. private coverArt: ImageBitmap | null = null; - // Active adaptive-streaming wrapper (Shaka primary, hls.js/dash.js fallback). - // Non-null only while a stream source is active; delegation throughout the - // player stays format-agnostic. + // Active adaptive-streaming / progressive-native wrapper. private streamWrapper: StreamWrapper | null = null; + private presentationMode: PresentationMode = "canvas"; + private externalQualities: ExternalQualityEntry[] = []; + private externalChapterMarkers: ChapterMarker[] = []; // Preview pipeline (C-based FFmpeg software decoding) private thumbnailBindings: ThumbnailBindings | null = null; @@ -281,6 +299,8 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { super(); this.config = config; + this.presentationMode = resolvePresentationMode(config); + this.config.presentation = this.presentationMode; this._audioOnly = !!config.audioOnly; this.cache = new LRUCache(config.cache?.maxSizeMB ?? 100); this.trackManager = new TrackManager(); @@ -567,6 +587,8 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { this.emit("loadStart", undefined); this.lastBufferedTime = 0; + this.applyConfiguredSubtitleTracks(); + // Drop the previous source's cover art so a soft-reload on the same // instance (no destroy) doesn't keep showing stale artwork when the // new source has none. @@ -595,63 +617,112 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { lowerUrl.includes(".mpd") || lowerUrl.includes(".ism")); + const wireStreamTrackHandlers = (): void => { + this.clearStreamTrackHandlers(); + this.streamTrackHandlerUnsubs.push( + this.trackManager.on("videoTrackChange", (track) => { + const w = this.streamWrapper; + if (w && "selectVideoTrack" in w) { + w.selectVideoTrack(track ? track.id : -1); + } + }), + this.trackManager.on("audioTrackChange", (track) => { + const w = this.streamWrapper; + if (track && w && "selectAudioTrack" in w) { + w.selectAudioTrack(track.id); + } + }), + this.trackManager.on("subtitleTrackChange", (track) => { + const w = this.streamWrapper; + if (w && "selectSubtitleTrack" in w) { + void w.selectSubtitleTrack(track ? track.id : null); + } + }), + ); + }; + + const finishStreamLoad = async ( + wrapper: StreamWrapper, + label: string, + ): Promise<boolean> => { + this.streamWrapper = wrapper; + this.wireStreamWrapper(wrapper); + Logger.info(TAG, label); + await wrapper.load(); + this.stateManager.setState("ready"); + return true; + }; + + if ( + streamUrl && + this.presentationMode === "native" && + isProgressiveNativeUrl(streamUrl) + ) { + wireStreamTrackHandlers(); + const progressive = new ProgressiveVideoWrapper(this.config); + await finishStreamLoad( + progressive, + "Detected progressive URL, using native <video>", + ); + return; + } + if (isStream) { const isHls = lowerUrl.includes(".m3u8"); const isDash = lowerUrl.includes(".mpd"); const kind = isHls ? "HLS" : lowerUrl.includes(".ism") ? "Smooth Streaming" : "DASH"; + wireStreamTrackHandlers(); - // Forward track selections from the main TrackManager to whichever stream - // wrapper is currently active (added once; resolves the live field). - this.trackManager.on("videoTrackChange", (track) => { - this.streamWrapper?.selectVideoTrack(track ? track.id : -1); - }); - this.trackManager.on("audioTrackChange", (track) => { - if (track) this.streamWrapper?.selectAudioTrack(track.id); - }); - this.trackManager.on("subtitleTrackChange", (track) => { - this.streamWrapper?.selectSubtitleTrack(track ? track.id : null); - }); + if (isHls && shouldPreferNativeHls(streamUrl)) { + try { + const hls = new HLSPlayerWrapper(this.config); + await finishStreamLoad(hls, `Detected ${kind} stream, using native HLS`); + return; + } catch (eNativeHls) { + Logger.warn(TAG, "Safari native HLS failed, falling back", eNativeHls); + try { this.streamWrapper?.destroy(); } catch {} + this.streamWrapper = null; + } + } + + if (isHls && shouldPreferHlsJs(streamUrl)) { + try { + const hls = new HLSPlayerWrapper(this.config); + await finishStreamLoad(hls, `Detected ${kind} stream, using hls.js (Firefox)`); + return; + } catch (eHlsJs) { + Logger.warn(TAG, "hls.js failed on Firefox, falling back to Shaka", eHlsJs); + try { this.streamWrapper?.destroy(); } catch {} + this.streamWrapper = null; + } + } - // --- Tier 1: Shaka (HLS + DASH + MSS + muxed). --- try { const shaka = new ShakaPlayerWrapper(this.config); - this.streamWrapper = shaka; - this.wireStreamWrapper(shaka); - Logger.info(TAG, `Detected ${kind} stream, using ShakaPlayerWrapper`); - await shaka.load(); - this.stateManager.setState("ready"); + await finishStreamLoad(shaka, `Detected ${kind} stream, using ShakaPlayerWrapper`); return; } catch (eShaka) { Logger.warn(TAG, `Shaka failed on ${kind} stream`, eShaka); try { this.streamWrapper?.destroy(); } catch {} this.streamWrapper = null; - // --- Tier 2: hls.js / dash.js. Their MSE engines play streams Shaka - // rejects (e.g. under-specified single-file DASH the browser demuxer - // handles but Shaka/FFmpeg won't). --- if (isHls || isDash) { try { const fb = isHls ? new HLSPlayerWrapper(this.config) : new DASHPlayerWrapper(this.config); - this.streamWrapper = fb; - this.wireStreamWrapper(fb); - Logger.info(TAG, `Shaka failed; retrying with ${isHls ? "hls.js" : "dash.js"}`); - await fb.load(); - this.stateManager.setState("ready"); - Logger.info(TAG, `Recovered via ${isHls ? "hls.js" : "dash.js"}`); + await finishStreamLoad( + fb, + `Shaka failed; retrying with ${isHls ? "hls.js" : "dash.js"}`, + ); return; } catch (eFallback) { Logger.warn(TAG, `${isHls ? "hls.js" : "dash.js"} fallback also failed`, eFallback); - try { this.streamWrapper?.destroy(); } catch {} + try { (this.streamWrapper as StreamWrapper | null)?.destroy(); } catch {} this.streamWrapper = null; } } - // --- Tier 3: FFmpeg demuxer for bare-<BaseURL> single-file DASH that - // even the MSE engines refuse (e.g. muxed single-file). Falls through - // to the demuxer path below with the video file as the source (+ the - // separate audio file as a native-audio source for demuxed content). --- let fellBack = false; if (isDash) { try { @@ -666,7 +737,7 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { if (plan.audioUrl) { this.config.audioSource = { type: "url", url: plan.audioUrl, headers: src?.headers }; } - fellBack = true; // fall through to the demuxer path below + fellBack = true; } } catch (eDemux) { Logger.warn(TAG, "FFmpeg DASH fallback failed", eDemux); @@ -674,7 +745,7 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { } if (!fellBack) { this.stateManager.setState("error"); - throw eShaka; // surface the original Shaka error + throw eShaka; } } } @@ -730,12 +801,6 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { this.setupNativeAudio(audioUrl); } - // Store external subtitle tracks - if (this.config.subtitleTracks && this.config.subtitleTracks.length > 0) { - this._subtitleTracks = [...this.config.subtitleTracks]; - Logger.info(TAG, `External subtitles: ${this._subtitleTracks.length} tracks`); - } - // Set tracks this.trackManager.setTracks(this.mediaInfo.tracks); @@ -1084,6 +1149,18 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { */ async play(): Promise<void> { if (this.streamWrapper) { + const streamState = this.streamWrapper.getState(); + if (streamState === "playing") { + return; + } + if (streamState === "buffering" || streamState === "seeking") { + this.wasPlayingBeforeRebuffer = true; + Logger.info( + TAG, + `Play requested during ${streamState} — will resume when ready`, + ); + return; + } return this.streamWrapper.play(); } @@ -3720,10 +3797,16 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { return result; } - // Adaptive streams: Shaka already applied the text-track selection (via the - // trackManager → streamWrapper wiring) and renders cues itself. There's no - // FFmpeg demuxer / subtitle decoder to configure, so stop here. + // Adaptive streams: Shaka/hls.js already applied the text-track selection + // (via the trackManager → streamWrapper wiring) and renders cues itself. if (this.streamWrapper) { + if (trackId !== null) { + this.stopExternalSubtitles(); + this._activeExternalSubtitleId = ""; + if (this.presentationMode === "native" && !this.videoRenderer) { + this.setNativeExternalTextTracks(null); + } + } return result; } @@ -3998,22 +4081,57 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { } /** - * Get HLS video element (DRM mode) for direct DOM insertion + * Get native stream/progressive video element for light-DOM presentation. */ getHLSVideoElement(): HTMLVideoElement | null { return this.streamWrapper?.getVideoElement() ?? null; } + getNativeVideoElement(): HTMLVideoElement | null { + return this.getHLSVideoElement(); + } + + getPresentationMode(): PresentationMode { + return this.presentationMode; + } + + canCast(): boolean { + return this.presentationMode === "native" && this.streamWrapper !== null; + } + + setExternalQualities(qualities: ExternalQualityEntry[]): void { + this.externalQualities = [...qualities]; + this.emit("tracksChange", this.trackManager.getTracks()); + } + + getExternalQualities(): ExternalQualityEntry[] { + return [...this.externalQualities]; + } + + setChapterMarkers(chapters: ChapterMarker[]): void { + this.externalChapterMarkers = [...chapters]; + } + + getChapterMarkers(): ChapterMarker[] { + return [...this.externalChapterMarkers]; + } /** * Get chapters from the media (empty array if none) */ getChapters(): Array<{ title: string; start: number; end: number }> { + if (this.externalChapterMarkers.length > 0) { + return this.externalChapterMarkers.map((ch, i, arr) => ({ + title: ch.title, + start: ch.start, + end: ch.end ?? (i < arr.length - 1 ? arr[i + 1].start : this.getDuration()), + })); + } return this.mediaInfo?.chapters ?? []; } resizeCanvas(width: number, height: number): void { - if (this.streamWrapper) { + if (this.streamWrapper && "resizeCanvas" in this.streamWrapper) { this.streamWrapper.resizeCanvas(width, height); } if (this.videoRenderer) { @@ -4553,64 +4671,87 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { /** * Get available external subtitle tracks */ - getSubtitleLangs(): { lang: string; label: string; active: boolean }[] { - return this._subtitleTracks.map((t) => ({ - lang: t.lang, - label: t.label, - active: t.lang === this._activeSubtitleLang, - })); + getSubtitleLangs(): { + id: string; + lang: string; + label: string; + active: boolean; + }[] { + return this._subtitleTracks.map((t) => { + const id = getExternalSubtitleId(t); + return { + id, + lang: t.lang, + label: t.label, + active: id === this._activeExternalSubtitleId, + }; + }); } /** - * Select an external subtitle track by language. + * Select an external subtitle track by stable id. * Fetches the VTT/SRT file, parses cues, and starts rendering. - * Pass empty string or null to disable. + * Pass null to disable. */ - async selectSubtitleLang(lang: string | null): Promise<boolean> { - // Disable current external subtitles + async selectExternalSubtitle(id: string | null): Promise<boolean> { this.stopExternalSubtitles(); - if (!lang) { - this._activeSubtitleLang = ""; - if (this.videoRenderer) this.videoRenderer.clearSubtitles(); + if (!id) { + this._activeExternalSubtitleId = ""; + if (this.presentationMode === "native" && !this.videoRenderer) { + this.setNativeExternalTextTracks(null); + } else if (this.videoRenderer) { + this.videoRenderer.clearSubtitles(); + } this.emit("subtitleTrackChange" as any, { lang: null, label: null }); return true; } - const track = this._subtitleTracks.find((t) => t.lang === lang); + const track = this._subtitleTracks.find( + (t) => getExternalSubtitleId(t) === id, + ); if (!track) { - Logger.warn(TAG, `Subtitle track not found for lang: ${lang}`); + Logger.warn(TAG, `Subtitle track not found for id: ${id}`); return false; } + if (this.presentationMode === "native" && !this.videoRenderer) { + const ok = this.setNativeExternalTextTracks(id); + if (!ok) return false; + this._activeExternalSubtitleId = id; + this.trackManager.selectSubtitleTrack(null); + this.emit("subtitleTrackChange" as any, { + lang: track.lang, + label: track.label, + }); + Logger.info(TAG, `Native subtitle track enabled: ${track.label}`); + return true; + } + try { - // Fetch subtitle file const res = await fetch(track.url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const text = await res.text(); - // Detect format const fmt = track.format || (track.url.includes(".srt") ? "srt" : "vtt"); - // Tell the renderer which format we're using so it can toggle the - // VTT-only backdrop styling. this.videoRenderer?.setSubtitleFormat(fmt); - // Parse into cues this._externalSubCues = fmt === "srt" ? this.parseSRT(text) : this.parseVTT(text); - this._activeSubtitleLang = lang; + this._activeExternalSubtitleId = id; - // Disable muxed subtitles if active this.selectSubtitleTrack(null); - // Start cue timer this.startExternalSubtitles(); Logger.info(TAG, `Subtitle loaded: ${track.label} (${this._externalSubCues.length} cues)`); - this.emit("subtitleTrackChange" as any, { lang, label: track.label }); + this.emit("subtitleTrackChange" as any, { + lang: track.lang, + label: track.label, + }); return true; } catch (e) { Logger.error(TAG, `Failed to load subtitle: ${track.url}`, e); @@ -4618,6 +4759,25 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { } } + /** + * Select an external subtitle track by language. + * When multiple tracks share a language, the first match is selected. + * Prefer selectExternalSubtitle when track ids are available. + */ + async selectSubtitleLang(lang: string | null): Promise<boolean> { + if (!lang) { + return this.selectExternalSubtitle(null); + } + + const track = this._subtitleTracks.find((t) => t.lang === lang); + if (!track) { + Logger.warn(TAG, `Subtitle track not found for lang: ${lang}`); + return false; + } + + return this.selectExternalSubtitle(getExternalSubtitleId(track)); + } + /** Parse VTT text into SubtitleCue[] */ private parseVTT(text: string): SubtitleCue[] { const cues: SubtitleCue[] = []; @@ -4668,7 +4828,35 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { }, 100); // 10Hz check — enough for subtitle timing } + setExternalSubtitleTracks(tracks: SubtitleSourceEntry[]): void { + this.stopExternalSubtitles(); + this._activeExternalSubtitleId = ""; + this._subtitleTracks = tracks.map((track, index) => ({ + ...track, + id: track.id ?? track.url ?? `external-sub-${index}`, + })); + } + + private clearStreamTrackHandlers(): void { + for (const unsub of this.streamTrackHandlerUnsubs) { + unsub(); + } + this.streamTrackHandlerUnsubs = []; + } + /** Stop external subtitle rendering */ + private applyConfiguredSubtitleTracks(): void { + if (this.config.subtitleTracks && this.config.subtitleTracks.length > 0) { + this._subtitleTracks = this.config.subtitleTracks.map((track, index) => ({ + ...track, + id: track.id ?? track.url ?? `external-sub-${index}`, + })); + Logger.info(TAG, `External subtitles: ${this._subtitleTracks.length} tracks`); + return; + } + this._subtitleTracks = []; + } + private stopExternalSubtitles(): void { if (this._externalSubTimer !== null) { clearInterval(this._externalSubTimer); @@ -4676,6 +4864,34 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { } } + /** Toggle external subtitle TextTracks on the native stream video element. */ + private setNativeExternalTextTracks(id: string | null): boolean { + const video = this.streamWrapper?.getVideoElement(); + if (!video) return false; + + const selected = id + ? this._subtitleTracks.find((track) => getExternalSubtitleId(track) === id) + : null; + + const tracks = video.textTracks; + let matched = false; + for (let i = 0; i < tracks.length; i++) { + const tt = tracks[i]; + if (!selected) { + tt.mode = "hidden"; + continue; + } + const isMatch = tt.label === selected.label; + if (isMatch) { + tt.mode = "showing"; + matched = true; + } else { + tt.mode = "hidden"; + } + } + return id === null || matched; + } + /** * Get playback rate */ @@ -4694,6 +4910,7 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { * frame-rate conversions. */ setSubtitleDelay(seconds: number): void { + this._nativeSubtitleDelay = seconds; if (this.videoRenderer) { this.videoRenderer.setSubtitleDelay(seconds); } @@ -4702,14 +4919,17 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { // (positive delay across a seek). Prefetch the full cue list once so // the renderer cache is authoritative regardless of demuxer position. // Zero delay falls back to the streaming path — no prefetch overhead. - if (seconds !== 0) { + if (seconds !== 0 && this.videoRenderer) { void this.prefetchActiveSubtitleStream(); } } /** Get current subtitle delay in seconds. */ getSubtitleDelay(): number { - return this.videoRenderer ? this.videoRenderer.getSubtitleDelay() : 0; + if (this.videoRenderer) { + return this.videoRenderer.getSubtitleDelay(); + } + return this._nativeSubtitleDelay; } /** @@ -4877,7 +5097,7 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { * Set muted state */ setMuted(muted: boolean): void { - if (this.muted === muted) return; // No change + if (this.muted === muted) return; this.muted = muted; if (this.streamWrapper) { @@ -4888,18 +5108,12 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { if (muted) { this.audioRenderer.mute(); } else { - // unmute() is async (initializes AudioContext on first unmute) - // but we don't await it to keep setMuted() synchronous this.audioRenderer.unmute().catch((err) => { Logger.error("MoviPlayer", "Failed to unmute", err); }); } if (this.nativeAudioEl) { this.nativeAudioEl.muted = muted; - // Unmuting resolves a native-audio autoplay block. The <audio> couldn't - // start without a gesture, so the video has been rolling on the wall clock - // with the audio paused — THIS unmute is the gesture. Sync the audio to - // the current playhead and start it; it then re-assumes clock-master duty. if (!muted && this._nativeAudioAutoplayBlocked) { this._nativeAudioAutoplayBlocked = false; if (this.nativeAudioEl.paused && this.stateManager.getState() === "playing") { @@ -6028,6 +6242,7 @@ export class MoviPlayer extends EventEmitter<PlayerEventMap> { this.cache.clear(); // Clear track manager + this.clearStreamTrackHandlers(); this.trackManager.clear(); // Release cover art bitmap. close() is a no-op on platforms that diff --git a/packages/player/src/core/TrackManager.ts b/packages/player/src/core/TrackManager.ts index 427b11ac..77d3d6e4 100644 --- a/packages/player/src/core/TrackManager.ts +++ b/packages/player/src/core/TrackManager.ts @@ -127,6 +127,33 @@ export class TrackManager extends EventEmitter<TrackManagerEvents> { return this.activeSubtitleTrack; } + /** + * Select the highest-resolution video rendition (ties break on bitrate). + * Falls back to Auto (-1) when no manual renditions exist. + */ + selectHighestVideoTrack(): boolean { + const renditions = this.getVideoTracks().filter((track) => track.id !== -1); + if (renditions.length === 0) { + return this.selectVideoTrack(-1); + } + + let best = renditions[0]; + for (const track of renditions) { + if (track.height > best.height) { + best = track; + continue; + } + if ( + track.height === best.height && + (track.bitRate ?? 0) > (best.bitRate ?? 0) + ) { + best = track; + } + } + + return this.selectVideoTrack(best.id); + } + /** * Select video track */ diff --git a/packages/player/src/core/presentation.ts b/packages/player/src/core/presentation.ts new file mode 100644 index 00000000..38dbeec8 --- /dev/null +++ b/packages/player/src/core/presentation.ts @@ -0,0 +1,115 @@ +import type { PlayerConfig, SourceConfig } from "../types"; + +export type PresentationMode = "native" | "canvas"; + +const ADAPTIVE_RE = /\.(m3u8|mpd|ism|isml)(\?|#|$)/i; +const PROGRESSIVE_NATIVE_RE = /\.(mp4|webm|m4v|mov)(\?|#|$)/i; + +export function isAdaptiveStreamUrl(url: string): boolean { + return ADAPTIVE_RE.test(url); +} + +export function isProgressiveNativeUrl(url: string): boolean { + return PROGRESSIVE_NATIVE_RE.test(url); +} + +export function isSafariBrowser(): boolean { + if (typeof navigator === "undefined") return false; + const ua = navigator.userAgent; + return /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR/i.test(ua); +} + +export function isFirefoxBrowser(): boolean { + if (typeof navigator === "undefined") return false; + return /Firefox/i.test(navigator.userAgent); +} + +export function castSupported(): boolean { + if (typeof window === "undefined") return false; + if (isFirefoxBrowser()) return false; + return !!(window as Window & { chrome?: { cast?: unknown } }).chrome?.cast; +} + +export function airPlaySupported(video?: HTMLVideoElement): boolean { + if (typeof video === "undefined") return false; + return typeof (video as HTMLVideoElement & { webkitShowPlaybackTargetPicker?: unknown }) + .webkitShowPlaybackTargetPicker === "function"; +} + +export function remotePlaybackSupported(video?: HTMLVideoElement): boolean { + if (typeof video === "undefined") return false; + return typeof (video as HTMLVideoElement & { remote?: RemotePlayback }).remote !== "undefined"; +} + +export function shouldPreferNativeHls(url: string): boolean { + return isSafariBrowser() && url.toLowerCase().includes(".m3u8"); +} + +export function shouldPreferHlsJs(url: string): boolean { + return isFirefoxBrowser() && url.toLowerCase().includes(".m3u8"); +} + +export function sourceUrl(config: Pick<PlayerConfig, "source">): string | null { + const src = config.source; + if (!src || src.type !== "url" || !src.url) return null; + return src.url; +} + +export function resolvePresentationMode( + config: Pick< + PlayerConfig, + "source" | "presentation" | "renderer" | "drm" | "lcevc" | "sourceAdapter" + >, +): PresentationMode { + if (config.presentation === "canvas") return "canvas"; + if (config.presentation === "native") return "native"; + if (config.lcevc) return "canvas"; + + if (config.sourceAdapter) return "canvas"; + + const url = sourceUrl(config); + if (url) { + if (config.drm || isAdaptiveStreamUrl(url) || isProgressiveNativeUrl(url)) { + return "native"; + } + } + + if (config.source?.type === "file") return "canvas"; + if (config.source?.type === "encrypted") return "native"; + + return "canvas"; +} + +export function usesCanvasCopyLoop(config: PlayerConfig): boolean { + if (config.presentation === "native") return false; + if (config.lcevc) return false; + if (config.drm) return false; + return !!(config.canvas && (config.renderer === "canvas" || !config.renderer)); +} + +export function applyStreamVideoVisibility( + video: HTMLVideoElement, + config: PlayerConfig, +): void { + if (usesCanvasCopyLoop(config)) { + video.style.display = "none"; + return; + } + video.style.display = "block"; + video.style.width = "100%"; + video.style.height = "100%"; + if (!video.style.objectFit) { + video.style.objectFit = "contain"; + } +} + +export function inferPresentationFromSource( + source: SourceConfig | undefined, + presentationOverride?: PresentationMode, +): PresentationMode { + return resolvePresentationMode({ + source, + presentation: presentationOverride, + drm: false, + }); +} diff --git a/packages/player/src/render/DASHPlayerWrapper.ts b/packages/player/src/render/DASHPlayerWrapper.ts index 28e9d303..2b96c36a 100644 --- a/packages/player/src/render/DASHPlayerWrapper.ts +++ b/packages/player/src/render/DASHPlayerWrapper.ts @@ -11,6 +11,11 @@ import { import { CanvasRenderer } from "./CanvasRenderer"; import { TrackManager } from "../core/TrackManager"; import { Logger } from "../utils/Logger"; +import { + applyStreamVideoVisibility, + usesCanvasCopyLoop, +} from "../core/presentation"; +import { VideoPlayGate } from "../utils/safeMediaPlay"; const TAG = "DASHPlayerWrapper"; @@ -29,6 +34,7 @@ export class DASHPlayerWrapper extends EventEmitter<PlayerEventMap> { private state: PlayerState = "idle"; public trackManager: TrackManager; private frameCallbackId: number | null = null; + private readonly videoPlayGate = new VideoPlayGate(); private _framesRendered: number = 0; // Representations from the manifest, indexed to match the VideoTrack ids we // hand the TrackManager (track id === array index; -1 is Auto/ABR). @@ -42,16 +48,14 @@ export class DASHPlayerWrapper extends EventEmitter<PlayerEventMap> { this.videoElement = document.createElement("video"); this.videoElement.crossOrigin = "anonymous"; this.videoElement.playsInline = true; - this.videoElement.style.display = "none"; // Hidden; canvas renderer draws frames + applyStreamVideoVisibility(this.videoElement, config); // Preserve pitch when changing playback speed (this.videoElement as any).preservesPitch = true; (this.videoElement as any).mozPreservesPitch = true; // Firefox (this.videoElement as any).webkitPreservesPitch = true; // Safari/older Chrome - // DRM mode: use native video element directly (no canvas) — canvas can't - // access DRM-protected frames (browser blocks VideoFrame copy). - if (!config.drm && config.renderer === "canvas" && config.canvas) { + if (usesCanvasCopyLoop(config) && config.canvas) { this.canvasRenderer = new CanvasRenderer(config.canvas); } @@ -91,23 +95,27 @@ export class DASHPlayerWrapper extends EventEmitter<PlayerEventMap> { private setupEventHandlers(): void { this.videoElement.addEventListener("play", () => this.setState("playing")); - this.videoElement.addEventListener("playing", () => - this.setState("playing"), - ); + this.videoElement.addEventListener("playing", () => { + this.setState("playing"); + this.emit("playing", undefined); + }); this.videoElement.addEventListener("pause", () => { if (this.state !== "ended") this.setState("paused"); }); this.videoElement.addEventListener("ended", () => this.setState("ended")); - this.videoElement.addEventListener("seeking", () => - this.setState("seeking"), - ); + this.videoElement.addEventListener("seeking", () => { + this.setState("seeking"); + this.emit("seeking", this.videoElement.currentTime); + }); this.videoElement.addEventListener("seeked", () => { + this.emit("seeked", this.videoElement.currentTime); if (this.videoElement.paused) this.setState("paused"); else this.setState("playing"); }); - this.videoElement.addEventListener("waiting", () => - this.setState("buffering"), - ); + this.videoElement.addEventListener("waiting", () => { + this.setState("buffering"); + this.emit("waiting", undefined); + }); this.videoElement.addEventListener("timeupdate", () => { this.emit("timeUpdate", this.videoElement.currentTime); }); @@ -300,7 +308,7 @@ export class DASHPlayerWrapper extends EventEmitter<PlayerEventMap> { }); this.trackManager.setTracks(tracks); - this.trackManager.selectVideoTrack(-1); // default Auto + this.trackManager.selectHighestVideoTrack(); if (this.canvasRenderer && reps.length > 0) { // Size the canvas from the highest rendition; fall back to the <video> @@ -337,10 +345,14 @@ export class DASHPlayerWrapper extends EventEmitter<PlayerEventMap> { } async play(): Promise<void> { - await this.videoElement.play(); + if (!this.videoElement.paused && this.state === "playing") { + return; + } + await this.videoPlayGate.play(this.videoElement); } pause(): void { + this.videoPlayGate.reset(); this.videoElement.pause(); } diff --git a/packages/player/src/render/HLSPlayerWrapper.ts b/packages/player/src/render/HLSPlayerWrapper.ts index 5510f48e..5538d9d1 100644 --- a/packages/player/src/render/HLSPlayerWrapper.ts +++ b/packages/player/src/render/HLSPlayerWrapper.ts @@ -6,13 +6,110 @@ import { PlayerConfig, Track, VideoTrack, + AudioTrack, + SubtitleTrack, } from "../types"; import { CanvasRenderer } from "./CanvasRenderer"; import { TrackManager } from "../core/TrackManager"; import { Logger } from "../utils/Logger"; +import { + applyStreamVideoVisibility, + shouldPreferNativeHls, + usesCanvasCopyLoop, +} from "../core/presentation"; +import { VideoPlayGate } from "../utils/safeMediaPlay"; const TAG = "HLSPlayerWrapper"; +export type HlsAudioRenditionLike = { + id?: number; + name?: string; + lang?: string; +}; + +type BrowserAudioTrack = { + id: string; + language: string; + label: string; + enabled: boolean; +}; + +type BrowserAudioTrackList = { + length: number; + [index: number]: BrowserAudioTrack | undefined; + addEventListener?: (type: string, listener: () => void) => void; + removeEventListener?: (type: string, listener: () => void) => void; +}; + +const isBrowserAudioTrackList = ( + value: unknown, +): value is BrowserAudioTrackList => { + if (typeof value !== "object" || value === null || !("length" in value)) { + return false; + } + return typeof (value as { length: unknown }).length === "number"; +}; + +const getBrowserAudioTrackList = ( + video: HTMLVideoElement, +): BrowserAudioTrackList | null => { + if (!("audioTracks" in video)) { + return null; + } + const list = Reflect.get(video, "audioTracks"); + return isBrowserAudioTrackList(list) && list.length > 0 ? list : null; +}; + +export const mapHlsAudioRenditions = ( + renditions: readonly HlsAudioRenditionLike[], +): AudioTrack[] => { + if (renditions.length <= 1) { + return []; + } + + return renditions.map((track, index) => { + const lang = track.lang && track.lang !== "und" ? track.lang : ""; + const label = track.name || lang || `Audio ${index + 1}`; + return { + id: typeof track.id === "number" ? track.id : index, + type: "audio", + codec: "", + language: lang, + label, + channels: 0, + sampleRate: 0, + }; + }); +}; + +export const mapNativeAudioTracks = ( + list: BrowserAudioTrackList, +): AudioTrack[] => { + if (list.length <= 1) { + return []; + } + + const tracks: AudioTrack[] = []; + for (let index = 0; index < list.length; index++) { + const track = list[index]; + if (!track) { + continue; + } + const lang = + track.language && track.language !== "und" ? track.language : ""; + tracks.push({ + id: index, + type: "audio", + codec: "", + language: lang, + label: track.label || lang || `Audio ${index + 1}`, + channels: 0, + sampleRate: 0, + }); + } + return tracks; +}; + export class HLSPlayerWrapper extends EventEmitter<PlayerEventMap> { private config: PlayerConfig; private hls: Hls | null = null; @@ -22,6 +119,7 @@ export class HLSPlayerWrapper extends EventEmitter<PlayerEventMap> { public trackManager: TrackManager; private frameCallbackId: number | null = null; private _framesRendered: number = 0; + private readonly videoPlayGate = new VideoPlayGate(); constructor(config: PlayerConfig) { super(); @@ -31,21 +129,49 @@ export class HLSPlayerWrapper extends EventEmitter<PlayerEventMap> { this.videoElement = document.createElement("video"); this.videoElement.crossOrigin = "anonymous"; this.videoElement.playsInline = true; - this.videoElement.style.display = "none"; // Hidden by default, canvas renderer will draw frames + applyStreamVideoVisibility(this.videoElement, config); // Preserve pitch when changing playback speed (this.videoElement as any).preservesPitch = true; (this.videoElement as any).mozPreservesPitch = true; // Firefox (this.videoElement as any).webkitPreservesPitch = true; // Safari/older Chrome - // DRM mode: use native video element directly (no canvas) - // Canvas can't access DRM-protected frames (browser blocks VideoFrame copy) - if (!config.drm && config.renderer === "canvas" && config.canvas) { + // Native presentation: light-DOM video, no canvas copy loop. + if (usesCanvasCopyLoop(config) && config.canvas) { this.canvasRenderer = new CanvasRenderer(config.canvas); } this.setupEventHandlers(); + this.trackManager.on("subtitleTrackChange", (track: SubtitleTrack | null) => { + if (this.hls) { + this.hls.subtitleTrack = track ? track.id : -1; + Logger.info( + TAG, + track + ? `Selected HLS subtitle track ${track.id} (${track.language || track.label || ""})` + : "HLS subtitles disabled", + ); + return; + } + this.applyNativeTextTrackSelection(track); + }); + + this.trackManager.on("audioTrackChange", (track: AudioTrack | null) => { + if (!track) { + return; + } + if (this.hls) { + this.hls.audioTrack = track.id; + Logger.info( + TAG, + `Selected HLS audio track ${track.id} (${track.language || track.label || ""})`, + ); + return; + } + this.applyNativeAudioTrackSelection(track.id); + }); + // Listen to track manager changes to update HLS level this.trackManager.on("videoTrackChange", (track: VideoTrack | null) => { if (this.hls) { @@ -88,23 +214,27 @@ export class HLSPlayerWrapper extends EventEmitter<PlayerEventMap> { private setupEventHandlers(): void { this.videoElement.addEventListener("play", () => this.setState("playing")); - this.videoElement.addEventListener("playing", () => - this.setState("playing"), - ); + this.videoElement.addEventListener("playing", () => { + this.setState("playing"); + this.emit("playing", undefined); + }); this.videoElement.addEventListener("pause", () => { if (this.state !== "ended") this.setState("paused"); }); this.videoElement.addEventListener("ended", () => this.setState("ended")); - this.videoElement.addEventListener("seeking", () => - this.setState("seeking"), - ); + this.videoElement.addEventListener("seeking", () => { + this.setState("seeking"); + this.emit("seeking", this.videoElement.currentTime); + }); this.videoElement.addEventListener("seeked", () => { + this.emit("seeked", this.videoElement.currentTime); if (this.videoElement.paused) this.setState("paused"); else this.setState("playing"); }); - this.videoElement.addEventListener("waiting", () => - this.setState("buffering"), - ); + this.videoElement.addEventListener("waiting", () => { + this.setState("buffering"); + this.emit("waiting", undefined); + }); this.videoElement.addEventListener("timeupdate", () => { this.emit("timeUpdate", this.videoElement.currentTime); }); @@ -181,17 +311,6 @@ export class HLSPlayerWrapper extends EventEmitter<PlayerEventMap> { this.setState("loading"); this.emit("loadStart", undefined); - if (!Hls.isSupported()) { - if (this.videoElement.canPlayType("application/vnd.apple.mpegurl")) { - return this.loadNative(); - } else { - const err = new Error("HLS not supported in this browser"); - this.emit("error", err); - this.setState("error"); - throw err; - } - } - const source = this.config.source; const url = source && source.type === "url" ? source.url : null; @@ -199,6 +318,22 @@ export class HLSPlayerWrapper extends EventEmitter<PlayerEventMap> { throw new Error("HLS source must be a URL"); } + if (shouldPreferNativeHls(url) || !Hls.isSupported()) { + if (this.videoElement.canPlayType("application/vnd.apple.mpegurl")) { + if (this.config.drm && this.config.licenseUrl) { + this.setupEME(this.config.licenseUrl, this.config.licenseHeaders); + } + return this.loadNative(); + } + } + + if (!Hls.isSupported()) { + const err = new Error("HLS not supported in this browser"); + this.emit("error", err); + this.setState("error"); + throw err; + } + if (this.config.drm) { Logger.info(TAG, "DRM mode enabled — using native video element (no canvas)"); if (this.config.licenseUrl) { @@ -215,6 +350,8 @@ export class HLSPlayerWrapper extends EventEmitter<PlayerEventMap> { backBufferLength: 90, maxBufferLength: 30, maxMaxBufferLength: 600, + enableWebVTT: true, + enableCEA708Captions: true, ...(mediaHeaders && { xhrSetup: (xhr: XMLHttpRequest) => { for (const [k, v] of Object.entries(mediaHeaders)) { @@ -262,6 +399,36 @@ export class HLSPlayerWrapper extends EventEmitter<PlayerEventMap> { ); }); + this.hls!.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, () => { + this.syncSubtitleTracksFromHls(); + }); + + this.hls!.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => { + this.syncAudioTracksFromHls(); + }); + + this.hls!.on(Hls.Events.AUDIO_TRACK_SWITCHED, (_event: string, data: { id: number }) => { + const active = this.trackManager + .getAudioTracks() + .find((track) => track.id === data.id); + if (!active || active.id === this.trackManager.getActiveAudioTrack()?.id) { + return; + } + this.trackManager.selectAudioTrack(data.id); + }); + + this.hls!.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (_event: string, data: { id: number }) => { + const active = + data.id >= 0 + ? (this.trackManager.getSubtitleTracks().find((t) => t.id === data.id) ?? + null) + : null; + if (active?.id === this.trackManager.getActiveSubtitleTrack()?.id) { + return; + } + this.trackManager.selectSubtitleTrack(data.id >= 0 ? data.id : null); + }); + let networkRetries = 0; let mediaRetries = 0; const MAX_NETWORK_RETRIES = 3; @@ -330,6 +497,8 @@ export class HLSPlayerWrapper extends EventEmitter<PlayerEventMap> { const onLoaded = () => { this.videoElement.removeEventListener("loadedmetadata", onLoaded); this.videoElement.removeEventListener("error", onError); + this.syncNativeAudioTracks(); + this.syncNativeManifestSubtitleTracks(); this.setState("ready"); this.emit("loadEnd", undefined); resolve(); @@ -390,8 +559,9 @@ export class HLSPlayerWrapper extends EventEmitter<PlayerEventMap> { this.trackManager.setTracks(tracks); - // Select Auto by default - this.trackManager.selectVideoTrack(-1); + this.trackManager.selectHighestVideoTrack(); + this.syncAudioTracksFromHls(); + this.syncSubtitleTracksFromHls(); if (this.canvasRenderer && data.levels.length > 0) { const level = data.levels[0]; @@ -433,10 +603,14 @@ export class HLSPlayerWrapper extends EventEmitter<PlayerEventMap> { } async play(): Promise<void> { - await this.videoElement.play(); + if (!this.videoElement.paused && this.state === "playing") { + return; + } + await this.videoPlayGate.play(this.videoElement); } pause(): void { + this.videoPlayGate.reset(); this.videoElement.pause(); } @@ -597,17 +771,146 @@ export class HLSPlayerWrapper extends EventEmitter<PlayerEventMap> { this.trackManager.selectVideoTrack(id); } - getAudioTracks() { - return []; + getAudioTracks(): AudioTrack[] { + return this.trackManager.getAudioTracks(); } - selectAudioTrack(_id: number): boolean { - return false; + selectAudioTrack(id: number): boolean { + return this.trackManager.selectAudioTrack(id); } - getSubtitleTracks() { - return []; + getSubtitleTracks(): SubtitleTrack[] { + return this.trackManager.getSubtitleTracks(); + } + + async selectSubtitleTrack(id: number | null): Promise<boolean> { + return this.trackManager.selectSubtitleTrack(id); + } + + private syncAudioTracksFromHls(): void { + if (!this.hls) return; + + const preserved = this.trackManager + .getTracks() + .filter((track) => track.type !== "audio"); + const audioTracks = mapHlsAudioRenditions(this.hls.audioTracks ?? []); + this.trackManager.setTracks([...preserved, ...audioTracks]); + + const activeId = this.hls.audioTrack; + if (audioTracks.some((track) => track.id === activeId)) { + this.trackManager.selectAudioTrack(activeId); + } + } + + private syncNativeAudioTracks(): void { + const list = getBrowserAudioTrackList(this.videoElement); + const preserved = this.trackManager + .getTracks() + .filter((track) => track.type !== "audio"); + if (!list) { + this.trackManager.setTracks(preserved); + return; + } + + const audioTracks = mapNativeAudioTracks(list); + this.trackManager.setTracks([...preserved, ...audioTracks]); + + let activeId = -1; + for (let index = 0; index < list.length; index++) { + if (list[index]?.enabled) { + activeId = index; + break; + } + } + if (audioTracks.some((track) => track.id === activeId)) { + this.trackManager.selectAudioTrack(activeId); + } + } + + private applyNativeAudioTrackSelection(trackId: number): void { + const list = getBrowserAudioTrackList(this.videoElement); + if (!list) return; + for (let index = 0; index < list.length; index++) { + const track = list[index]; + if (track) { + track.enabled = index === trackId; + } + } } - selectSubtitleTrack(_id: number | null): Promise<boolean> { - return Promise.resolve(false); + + private syncSubtitleTracksFromHls(): void { + if (!this.hls) return; + + const preserved = this.trackManager + .getTracks() + .filter((track) => track.type !== "subtitle"); + const subtitleTracks: SubtitleTrack[] = (this.hls.subtitleTracks ?? []).map( + (track, index) => { + const lang = + track.lang && track.lang !== "und" ? track.lang : track.name || ""; + const label = track.name || lang || `Subtitle ${index + 1}`; + return { + id: index, + type: "subtitle", + codec: "webvtt", + language: lang, + label, + subtitleType: "text", + }; + }, + ); + + this.trackManager.setTracks([...preserved, ...subtitleTracks]); + } + + private syncNativeManifestSubtitleTracks(): void { + const preserved = this.trackManager + .getTracks() + .filter((track) => track.type !== "subtitle"); + const subtitleTracks: SubtitleTrack[] = []; + let index = 0; + + for (let i = 0; i < this.videoElement.textTracks.length; i++) { + const textTrack = this.videoElement.textTracks[i]; + if (textTrack.kind !== "subtitles" && textTrack.kind !== "captions") { + continue; + } + const lang = + textTrack.language && textTrack.language !== "und" + ? textTrack.language + : ""; + subtitleTracks.push({ + id: index, + type: "subtitle", + codec: "webvtt", + language: lang, + label: textTrack.label || lang || `Subtitle ${index + 1}`, + subtitleType: "text", + }); + index++; + } + + if (subtitleTracks.length === 0) { + return; + } + + this.trackManager.setTracks([...preserved, ...subtitleTracks]); + } + + private applyNativeTextTrackSelection(track: SubtitleTrack | null): void { + let subtitleIndex = 0; + for (let i = 0; i < this.videoElement.textTracks.length; i++) { + const textTrack = this.videoElement.textTracks[i]; + if (textTrack.kind !== "subtitles" && textTrack.kind !== "captions") { + continue; + } + if (!track) { + textTrack.mode = "hidden"; + } else if (subtitleIndex === track.id) { + textTrack.mode = "showing"; + } else { + textTrack.mode = "hidden"; + } + subtitleIndex++; + } } setFitMode(mode: any) { diff --git a/packages/player/src/render/MoviElement.ts b/packages/player/src/render/MoviElement.ts index 2e3be929..47c2cf32 100644 --- a/packages/player/src/render/MoviElement.ts +++ b/packages/player/src/render/MoviElement.ts @@ -11,6 +11,7 @@ */ import { MoviPlayer } from "../core/MoviPlayer"; +import { resolvePresentationMode } from "../core/presentation"; import type { SourceConfig, RendererType, @@ -19,9 +20,23 @@ import type { SubtitleTrack, DecoderType, PlayerState, + PresentationMode, + ExternalQualityEntry, + ChapterMarker, } from "../types"; import { Logger, LogLevel } from "../utils/Logger"; +import { + chapterSegmentPercent, + findChapterAtTime, + introDbSegmentClassName, +} from "../utils/chapterMarkers"; import type { SourceAdapter } from "../source/SourceAdapter"; +import { + CanvasVideoPresenter, + NativeVideoPresenter, + type VideoPresenter, +} from "./VideoPresenter"; +import { RemotePlaybackController } from "./RemotePlaybackController"; import { SettingsStorage } from "../utils/SettingsStorage"; import { QoECollector, beaconSink, type QoESink, type QoESession } from "../utils/QoE"; @@ -201,7 +216,20 @@ export class MoviElement extends HTMLElement { // menu for plain MP4 sources (where there's no HLS manifest to enumerate). private _videoQualities: { src: string; type?: string; height: number; label: string; fps?: number; badge?: string }[] = []; private _audioTracks: { src: string; type?: string; lang: string; label: string }[] = []; // Multi-language audio - private _subtitleTracks: { src: string; lang: string; label: string; format?: string }[] = []; // External subtitles + private _subtitleTracks: { + src: string; + lang: string; + label: string; + format?: string; + default?: boolean; + id?: string; + }[] = []; + private _presentation: PresentationMode | null = null; + private _videoPresenter: VideoPresenter | null = null; + private _remotePlayback: RemotePlaybackController | null = null; + private _nativeCueStyle: HTMLStyleElement | null = null; + private _nativeSubDelayFrameId: number | null = null; + private _castFallbackSrc: string = ""; private _autoplay: boolean = false; // True from the moment an autoplay attempt is kicked off until playback // actually reaches "playing" or the browser blocks it. While set, the @@ -232,6 +260,8 @@ export class MoviElement extends HTMLElement { // once the player has settled into "playing" again. private _loopRestartInFlight: boolean = false; private _pendingPlay: boolean = false; + private _elementPlayPromise: Promise<void> | null = null; + private _autoplayInvoked: boolean = false; // True while loading spinner + play() must wait for FileSource preload to // complete (mobile only, height >= 2160). Released by the player's // 'preloadcomplete' event, which always fires once preload settles. @@ -438,6 +468,7 @@ export class MoviElement extends HTMLElement { "volume", "playbackrate", "subtitledelay", + "castfallbacksrc", "subtitlesize", "subtitlecolor", "subtitlebg", @@ -445,6 +476,7 @@ export class MoviElement extends HTMLElement { "ambientmode", "ambientwrapper", "renderer", + "presentation", "objectfit", "thumb", "hdr", @@ -1241,6 +1273,18 @@ export class MoviElement extends HTMLElement { </svg> </button> + <button class="movi-btn movi-cast-btn" aria-label="Cast" style="display:none"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M2 16.1A5 5 0 0 1 7 11h14"/><path d="M7 16h10"/><path d="M12 20h4"/> + </svg> + </button> + + <button class="movi-btn movi-airplay-btn" aria-label="AirPlay" style="display:none"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1"/><polygon points="12 15 17 21 7 21 12 15"/> + </svg> + </button> + <button class="movi-btn movi-pip-btn" aria-label="Picture in Picture" style="display:none"> <svg class="movi-icon-pip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <rect x="2" y="3" width="20" height="14" rx="2"/><rect x="12" y="9" width="8" height="6" rx="1" fill="currentColor" opacity="0.3"/> @@ -1812,10 +1856,7 @@ export class MoviElement extends HTMLElement { const chapterTitleEl = shadowRoot.querySelector(".movi-seek-chapter-title") as HTMLElement; if (chapterTitleEl) { const chapters = this.player?.getChapters() ?? []; - const chapter = chapters.find((ch, i) => { - const end = i < chapters.length - 1 ? chapters[i + 1].start : duration; - return time >= ch.start && time < end; - }); + const chapter = findChapterAtTime(chapters, time); if (chapter) { chapterTitleEl.textContent = chapter.title; chapterTitleEl.style.display = "block"; @@ -3749,11 +3790,7 @@ export class MoviElement extends HTMLElement { break; } case "ArrowLeft": - // Left Arrow: Seek backward 5 seconds or single frame (if Ctrl) - // Only enabled if fastseek is true. In linear mode the seek clamps to - // the buffered RAM window. - if (!this._fastSeek) break; - + // Left Arrow: Seek backward 10 seconds or single frame (if Ctrl) e.preventDefault(); if (e.ctrlKey || e.metaKey) { // Frame backward - only work if paused (auto-pause if playing) @@ -3773,11 +3810,7 @@ export class MoviElement extends HTMLElement { } break; case "ArrowRight": - // Right Arrow: Seek forward 5 seconds or single frame (if Ctrl) - // Only enabled if fastseek is true. In linear mode the seek clamps to - // the buffered RAM window. - if (!this._fastSeek) break; - + // Right Arrow: Seek forward 10 seconds or single frame (if Ctrl). e.preventDefault(); { if (e.ctrlKey || e.metaKey) { @@ -3925,16 +3958,16 @@ export class MoviElement extends HTMLElement { const activeIdx = extSubs.findIndex((t) => t.active); if (activeIdx === -1) { // Currently off → select first - this.player.selectSubtitleLang(extSubs[0].lang); + this.player.selectExternalSubtitle(extSubs[0].id); this.showOSD(subOsdOn, `${extSubs[0].label} [${extSubs[0].lang.toUpperCase()}] (1/${extSubs.length})`); } else if (activeIdx + 1 < extSubs.length) { // Next track const next = extSubs[activeIdx + 1]; - this.player.selectSubtitleLang(next.lang); + this.player.selectExternalSubtitle(next.id); this.showOSD(subOsdOn, `${next.label} [${next.lang.toUpperCase()}] (${activeIdx + 2}/${extSubs.length})`); } else { // Last → off - this.player.selectSubtitleLang(null); + this.player.selectExternalSubtitle(null); this.showOSD(subOsdOff, "Subtitles Off"); } this.updateSubtitleTrackMenu(); @@ -4738,7 +4771,7 @@ export class MoviElement extends HTMLElement { const audioOutputId = item.dataset.audioOutputId; const audioOutputUnlock = item.dataset.audioOutputUnlock; const subtitleTrackId = item.dataset.subtitleTrackId; - const subtitleLang = item.dataset.subtitleLang; + const subtitleId = item.dataset.subtitleId; if (action === "play-pause") { if (this.player) { @@ -4835,15 +4868,15 @@ export class MoviElement extends HTMLElement { contextMenu.scrollTop = 0; submenu.classList.add("movi-context-menu-submenu-visible"); } - } else if (subtitleLang !== undefined) { - // Select external subtitle language + } else if (subtitleId !== undefined) { + // Select external subtitle track if (this.player) { - const subTrack = this.player.getSubtitleLangs().find(t => t.lang === subtitleLang); - this.player.selectSubtitleLang(subtitleLang); + const subTrack = this.player.getSubtitleLangs().find((t) => t.id === subtitleId); + this.player.selectExternalSubtitle(subtitleId); this.updateSubtitleTrackMenu(); this.showOSD( OSD.subOn, - subTrack?.label || subtitleLang, + subTrack?.label || subtitleId, ); } hideContextMenu(); @@ -4854,13 +4887,13 @@ export class MoviElement extends HTMLElement { const subOsdIcon = OSD.subOn; if (trackId === -1) { this.player.selectSubtitleTrack(null); - this.player.selectSubtitleLang(null); + this.player.selectExternalSubtitle(null); this.showOSD( OSD.subOff, "Subtitles Off", ); } else { - this.player.selectSubtitleLang(null); + this.player.selectExternalSubtitle(null); this.player.selectSubtitleTrack(trackId); const trk = this.player.getSubtitleTracks().find(t => t.id === trackId); const ctxSubLang = trk?.language?.toUpperCase() || ""; @@ -5213,7 +5246,7 @@ export class MoviElement extends HTMLElement { html += ctxExternalSubs .map((t) => { const activeClass = t.active ? " movi-context-menu-active" : ""; - return `<div class="movi-context-menu-item${activeClass}" data-subtitle-lang="${t.lang}">${t.label} (${t.lang.toUpperCase()})</div>`; + return `<div class="movi-context-menu-item${activeClass}" data-subtitle-id="${t.id}">${t.label} (${t.lang.toUpperCase()})</div>`; }) .join(""); @@ -5456,6 +5489,15 @@ export class MoviElement extends HTMLElement { // API only works on <video>, and we render to a canvas — so fall back to a // CSS viewport-fill "pseudo fullscreen" instead of throwing on requestFullscreen. if (!this.nativeFullscreenSupported()) { + if (this._presentation === "native") { + const v = this.video as HTMLVideoElement & { + webkitEnterFullscreen?: () => void; + }; + if (typeof v.webkitEnterFullscreen === "function") { + v.webkitEnterFullscreen(); + return; + } + } this.setPseudoFullscreen(!this._pseudoFullscreen); return; } @@ -5833,6 +5875,12 @@ export class MoviElement extends HTMLElement { /** Whether the browser exposes the element Fullscreen API for this element * (false on iOS Safari, which only fullscreens <video>). */ private nativeFullscreenSupported(): boolean { + if (this._presentation === "native") { + const v = this.video as HTMLVideoElement & { + webkitEnterFullscreen?: () => void; + }; + if (typeof v.webkitEnterFullscreen === "function") return true; + } return ( typeof this.requestFullscreen === "function" && !!document.fullscreenEnabled ); @@ -6812,12 +6860,21 @@ export class MoviElement extends HTMLElement { * into the bitmap. * - null → no subtitle selected → no customize gear shown. */ + private resolveExternalSubtitleId(sub: { + src: string; + id?: string; + }): string { + return sub.id ?? sub.src; + } + private getActiveSubtitleKind(): "vtt" | "text" | "image" | null { if (!this.player) return null; // External (declared via <track> children) — has a `format` hint. const ext = this.player.getSubtitleLangs().find((t) => t.active); if (ext) { - const meta = this._subtitleTracks.find((t) => t.lang === ext.lang); + const meta = this._subtitleTracks.find( + (t) => this.resolveExternalSubtitleId(t) === ext.id, + ); const fmt = (meta?.format || "").toLowerCase(); return fmt === "vtt" ? "vtt" : "text"; } @@ -7765,7 +7822,7 @@ export class MoviElement extends HTMLElement { const subtitleTracks = this.player.getSubtitleTracks(); const activeTrack = this.player.trackManager.getActiveSubtitleTrack(); - const externalSubs = this.player.getSubtitleLangs(); + const externalSubs = this.getSubtitleLangs(); const hasExternalSubs = externalSubs.length > 0; // Hide container if no subtitle tracks (muxed or external) @@ -7882,7 +7939,7 @@ export class MoviElement extends HTMLElement { menuHTML += externalSubs .map((t) => ` <div class="movi-subtitle-track-item ${t.active ? "movi-subtitle-track-active" : ""}" - data-subtitle-lang="${t.lang}"> + data-subtitle-id="${t.id}"> ${ICON} <span class="movi-subtitle-track-label">${t.label}</span> <span class="movi-subtitle-track-info">${t.lang.toUpperCase()}</span> @@ -7913,28 +7970,28 @@ export class MoviElement extends HTMLElement { item.addEventListener("click", (e) => { e.stopPropagation(); const trackIdStr = (item as HTMLElement).dataset.trackId; - const subtitleLang = (item as HTMLElement).dataset.subtitleLang; + const subtitleId = (item as HTMLElement).dataset.subtitleId; if (this.player) { const subIconOn = OSD.subOn; const subIconOff = OSD.subOff; - if (subtitleLang !== undefined) { + if (subtitleId !== undefined) { // External subtitle track - const st = this.player.getSubtitleLangs().find(t => t.lang === subtitleLang); - this.player.selectSubtitleLang(subtitleLang); + const st = this.player.getSubtitleLangs().find((t) => t.id === subtitleId); + this.player.selectExternalSubtitle(subtitleId); this.updateSubtitleTrackMenu(); - const extSubOsd = st ? `${st.label} [${subtitleLang.toUpperCase()}]` : subtitleLang.toUpperCase(); + const extSubOsd = st ? `${st.label} [${st.lang.toUpperCase()}]` : subtitleId; this.showOSD(subIconOn, extSubOsd); } else if (trackIdStr === "null") { // Disable all subtitles (muxed + external) this.player.selectSubtitleTrack(null).catch(() => {}); - this.player.selectSubtitleLang(null); + this.player.selectExternalSubtitle(null); this.updateSubtitleTrackMenu(); this.showOSD(subIconOff, "Subtitles Off"); } else { // Muxed subtitle track const trackId = parseInt(trackIdStr || "0"); - this.player.selectSubtitleLang(null); + this.player.selectExternalSubtitle(null); this.player.selectSubtitleTrack(trackId).catch(() => {}); const trk = this.player.getSubtitleTracks().find(t => t.id === trackId); const muxSubLangC = trk?.language?.toUpperCase() || ""; @@ -10107,6 +10164,23 @@ export class MoviElement extends HTMLElement { top: 0; height: 100%; pointer-events: none; + border-radius: 100px; + } + + .movi-chapter-segment--intro { + background: rgb(217 70 239 / 0.42); + } + + .movi-chapter-segment--recap { + background: rgb(245 158 11 / 0.42); + } + + .movi-chapter-segment--credits { + background: rgb(99 102 241 / 0.42); + } + + .movi-chapter-segment--preview { + background: rgb(34 197 94 / 0.42); } .movi-progress-buffer { @@ -14591,6 +14665,7 @@ export class MoviElement extends HTMLElement { const parsed = parseFloat(subtitleDelayAttr); if (Number.isFinite(parsed)) this._subtitleDelay = parsed; } + this._castFallbackSrc = this.getAttribute("castfallbacksrc") || ""; this._ambientMode = this.hasAttribute("ambientmode"); this._ambientWrapper = this.getAttribute("ambientwrapper"); const objectFitAttr = this.getAttribute("objectfit"); @@ -15296,6 +15371,15 @@ export class MoviElement extends HTMLElement { } } break; + case "castfallbacksrc": + this._castFallbackSrc = newValue || ""; + if (this._remotePlayback) { + const canCast = + (this._remotePlayback.canCast && !!this.player?.canCast()) || + !!this._castFallbackSrc; + this.updateRemotePlaybackUi(canCast, this._remotePlayback.canAirPlay); + } + break; case "ambientmode": this._ambientMode = newValue !== null; this.updateAmbientMode(); @@ -15540,7 +15624,9 @@ export class MoviElement extends HTMLElement { const canvas = this.coverArtCanvas; if (!overlay || !canvas) return; - const hasVideoTrack = !!this.player?.trackManager?.getActiveVideoTrack?.(); + const hasVideoTrack = + !!this.player?.trackManager?.getActiveVideoTrack?.() || + (this._presentation === "native" && (this.video?.videoWidth ?? 0) > 0); const hasAudio = !!this.player?.hasAudibleSource?.(); // Two related-but-distinct host states for an audio source (audio @@ -15726,6 +15812,7 @@ export class MoviElement extends HTMLElement { */ private async _startAutoplay(): Promise<void> { if (!this.player || !this._autoplay || this._isUnsupported) return; + this._autoplayInvoked = true; // Suppress the center play overlay through the startup window so the big // play icon doesn't flash before playback begins on its own. this._autoplayStarting = true; @@ -15847,6 +15934,7 @@ export class MoviElement extends HTMLElement { if (this.player) { this.player.setSubtitleDelay(this._subtitleDelay); } + this.updateNativeSubtitleDelayRendering(); } private updatePlaybackRate() { @@ -16036,6 +16124,29 @@ export class MoviElement extends HTMLElement { ...(this._sourceAdapter && { sourceAdapter: this._sourceAdapter }), }; + // Native vs canvas presentation. Adaptive streams, progressive MP4/WebM, + // and DRM use the light-DOM <video> path unless presentation="canvas". + const licenseUrl = this.getAttribute("licenseurl") || ""; + const isDrm = this.hasAttribute("drm") || licenseUrl.length > 0; + const presentationAttr = this.getAttribute("presentation"); + const presentationOverride = + presentationAttr === "canvas" || presentationAttr === "native" + ? presentationAttr + : undefined; + const sourceForPresentation: SourceConfig | undefined = + source?.type === "url" || source?.type === "file" || source?.type === "encrypted" + ? source + : undefined; + const presentation = resolvePresentationMode({ + source: sourceForPresentation, + presentation: presentationOverride, + drm: isDrm, + lcevc: this.hasAttribute("lcevc"), + sourceAdapter: this._sourceAdapter ?? undefined, + }); + this._presentation = presentation; + playerConfig.presentation = presentation; + // Separate audio source — multi-language or single if (this._audioTracks.length > 0) { playerConfig.audioTracks = this._audioTracks.map((t) => ({ @@ -16055,26 +16166,13 @@ export class MoviElement extends HTMLElement { lang: t.lang, label: t.label, format: (t.format as "vtt" | "srt" | undefined), + id: t.id ?? t.src, })); } - // DRM mode: use the native video element for adaptive streams (EME - // requires <video>; protected frames can't be copied to a canvas) — - // applies to HLS (.m3u8) and DASH (.mpd) alike. Providing a `licenseurl` - // is enough to enable it; the bare `drm` boolean attribute still works. - const licenseUrl = this.getAttribute("licenseurl") || ""; - const isDrm = this.hasAttribute("drm") || licenseUrl.length > 0; - const isAdaptiveStream = typeof this._src === "string" && - (this._src.toLowerCase().includes(".m3u8") || - this._src.toLowerCase().includes(".mpd") || - this._src.toLowerCase().includes(".ism")); - - if (isDrm && isAdaptiveStream) { - playerConfig.renderer = "video"; + if (isDrm) { playerConfig.drm = true; if (licenseUrl) playerConfig.licenseUrl = licenseUrl; - // Optional auth headers for the license request, as a JSON object, - // e.g. licenseheaders='{"Authorization":"Bearer <token>"}'. const rawHeaders = this.getAttribute("licenseheaders"); if (rawHeaders) { try { @@ -16083,8 +16181,15 @@ export class MoviElement extends HTMLElement { Logger.warn(TAG, "Invalid licenseheaders JSON attribute; ignoring"); } } + } + + if (presentation === "native") { + playerConfig.renderer = "video"; this.canvas.style.display = "none"; this.video.style.display = "block"; + this.video.setAttribute("playsinline", ""); + this.video.setAttribute("webkit-playsinline", ""); + this.video.disableRemotePlayback = false; } else { playerConfig.renderer = "canvas"; playerConfig.canvas = this.canvas; @@ -16092,17 +16197,14 @@ export class MoviElement extends HTMLElement { this.video.style.display = "none"; } - // MPEG-5 LCEVC (opt-in): Shaka composites the enhanced layer onto the - // canvas. Needs the external lcevc_dec.js library — point `lcevcurl` at - // it to lazy-load, or load it yourself (global LCEVCdec). Without DRM. if (!playerConfig.drm && this.hasAttribute("lcevc")) { playerConfig.lcevc = true; const lcevcUrl = this.getAttribute("lcevcurl"); if (lcevcUrl) playerConfig.lcevcUrl = lcevcUrl; } - // Create Player instance - const mode = playerConfig.drm ? "DRM/Native Video" : "Canvas Renderer"; + const mode = + presentation === "native" ? "Native Video" : playerConfig.drm ? "DRM/Native Video" : "Canvas Renderer"; Logger.info(TAG, `Initializing MoviPlayer (${mode} Mode)`); this.player = new MoviPlayer(playerConfig); @@ -16120,22 +16222,6 @@ export class MoviElement extends HTMLElement { this._carryAudioEl = null; } - // In DRM mode, use the stream wrapper's native <video> element directly - // (Shaka manages EME on it; protected frames can't go through canvas). - if (playerConfig.drm) { - const streamVideo = this.player.getHLSVideoElement(); - if (streamVideo && this.video) { - // Replace our video element with the stream's native video element - streamVideo.style.width = "100%"; - streamVideo.style.height = "100%"; - streamVideo.style.display = "block"; - streamVideo.style.objectFit = "contain"; - this.video.replaceWith(streamVideo); - this.video = streamVideo; - Logger.info(TAG, "DRM: Swapped in native stream video element"); - } - } - // Reset unsupported state this._isUnsupported = false; if (this.brokenIndicator) this.brokenIndicator.style.display = "none"; @@ -16153,7 +16239,6 @@ export class MoviElement extends HTMLElement { this.player.setSubtitleOverlay(this.subtitleOverlay); } - // Load the video // Load the video if (this.player) { await this.player.load(); @@ -16165,6 +16250,29 @@ export class MoviElement extends HTMLElement { } } + if (presentation === "native") { + this.mountNativeStreamVideo(); + this._videoPresenter = new NativeVideoPresenter(this.video); + this.setupRemotePlaybackControls(); + this.applyNativeCueStyles(); + this.syncNativeSubtitleTracks(); + this.updateSubtitleTrackMenu(); + } else { + this._videoPresenter = new CanvasVideoPresenter( + this.canvas, + this.player, + this, + ); + if (this._castFallbackSrc) { + this.setupRemotePlaybackControls(); + } else { + this.updateRemotePlaybackUi(false); + this.dispatchEvent( + new CustomEvent("cancastchange", { detail: { canCast: false } }), + ); + } + } + // Check for software decoding fallback (only for MoviPlayer/Canvas mode) // Don't show broken icon if sw attribute is set (user explicitly wants software decoding) if ( @@ -16356,10 +16464,13 @@ export class MoviElement extends HTMLElement { this.handleUnsupportedVideo(title, message); } finally { this.isLoading = false; - // Flush any play() calls that were deferred while loading was in flight. + // Flush deferred host play() calls, but not when autoplay already started + // the internal player — that duplicates play() and races pause/buffer. if (this._pendingPlay && this.player && !this._isUnsupported) { this._pendingPlay = false; - this.player.play().catch(() => {}); + if (!this._autoplayInvoked) { + this.player.play().catch(() => {}); + } } else { this._pendingPlay = false; } @@ -16375,6 +16486,268 @@ export class MoviElement extends HTMLElement { /** * Set up event handlers for the player */ + get presenter(): VideoPresenter | null { + return this._videoPresenter; + } + + private mountNativeStreamVideo(): void { + if (!this.player) return; + const streamVideo = this.player.getNativeVideoElement(); + if (!streamVideo || streamVideo === this.video) return; + + streamVideo.style.width = "100%"; + streamVideo.style.height = "100%"; + streamVideo.style.display = "block"; + streamVideo.style.objectFit = this.video.style.objectFit || "contain"; + streamVideo.className = this.video.className; + this.video.replaceWith(streamVideo); + this.video = streamVideo; + Logger.info(TAG, "Mounted native stream video element in light DOM"); + } + + private setupRemotePlaybackControls(): void { + const castBtn = this.shadowRoot?.querySelector( + ".movi-cast-btn", + ) as HTMLButtonElement | null; + const airplayBtn = this.shadowRoot?.querySelector( + ".movi-airplay-btn", + ) as HTMLButtonElement | null; + + this._remotePlayback = new RemotePlaybackController({ + video: this.video, + getSourceUrl: () => + typeof this._src === "string" ? this._src : this.video.currentSrc || null, + getContentType: () => { + const src = typeof this._src === "string" ? this._src.toLowerCase() : ""; + if (src.includes(".m3u8")) return "application/vnd.apple.mpegurl"; + if (src.includes(".mpd")) return "application/dash+xml"; + if (src.includes(".webm")) return "video/webm"; + return "video/mp4"; + }, + onCanCastChange: (canCast) => { + this.updateRemotePlaybackUi(canCast); + this.dispatchEvent( + new CustomEvent("cancastchange", { detail: { canCast } }), + ); + }, + }); + + const canCast = + (this._remotePlayback.canCast && !!this.player?.canCast()) || + !!this._castFallbackSrc; + const canAirPlay = this._remotePlayback.canAirPlay; + this.updateRemotePlaybackUi(canCast, canAirPlay); + this.dispatchEvent( + new CustomEvent("cancastchange", { + detail: { canCast: !!this.player?.canCast() }, + }), + ); + + castBtn?.addEventListener("click", async (e) => { + e.stopPropagation(); + if (!this._remotePlayback) return; + if (!this.player?.canCast() && this._castFallbackSrc) { + this.dispatchEvent( + new CustomEvent("castfallback", { + detail: { src: this._castFallbackSrc }, + bubbles: true, + }), + ); + return; + } + try { + await this._remotePlayback.requestCast(); + } catch (err) { + Logger.warn(TAG, "Cast request failed", err); + this.dispatchEvent( + new CustomEvent("casterror", { + detail: { + message: err instanceof Error ? err.message : String(err), + }, + }), + ); + } + }); + + airplayBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this._remotePlayback?.showAirPlayPicker(); + }); + } + + private updateRemotePlaybackUi(canCast: boolean, canAirPlay?: boolean): void { + const castBtn = this.shadowRoot?.querySelector( + ".movi-cast-btn", + ) as HTMLElement | null; + const airplayBtn = this.shadowRoot?.querySelector( + ".movi-airplay-btn", + ) as HTMLElement | null; + const showCast = + canCast || + (!!this._castFallbackSrc && (this._remotePlayback?.canCast ?? false)); + if (castBtn) castBtn.style.display = showCast ? "" : "none"; + if (airplayBtn) { + airplayBtn.style.display = + canAirPlay ?? this._remotePlayback?.canAirPlay ?? false ? "" : "none"; + } + } + + private applyNativeCueStyles(): void { + if (!this.shadowRoot) return; + if (!this._nativeCueStyle) { + const style = document.createElement("style"); + style.textContent = + "video::cue{" + + "font-size:calc(clamp(20px,calc(var(--movi-player-width,100vw)*0.032),40px)*var(--movi-sub-size-mult,1));" + + "color:var(--movi-sub-color,#fff);" + + "background-color:rgba(var(--movi-sub-bg-rgb,0,0,0),var(--movi-sub-bg-alpha,0.75));" + + "text-shadow:var(--movi-sub-edge,none);" + + "line-height:1.3;}"; + this.shadowRoot.appendChild(style); + this._nativeCueStyle = style; + } + this.applySubtitleSettings(); + } + + private resolveNativeSubtitleSrc(src: string): string | null { + if (!src) return null; + if (src.startsWith("/")) return src; + try { + const parsed = new URL(src, window.location.origin); + if (parsed.origin === window.location.origin) { + return parsed.href; + } + } catch { + return null; + } + Logger.warn( + TAG, + `Skipping subtitle track with non-proxied URL (native <track> cannot send auth headers): ${src}`, + ); + return null; + } + + private syncNativeSubtitleTracks(): void { + if (this._presentation !== "native") return; + const video = this.video; + for (const track of [...video.querySelectorAll("track")]) { + track.remove(); + } + for (const sub of this._subtitleTracks) { + const src = this.resolveNativeSubtitleSrc(sub.src); + if (!src) continue; + const track = document.createElement("track"); + track.kind = "subtitles"; + track.label = sub.label; + track.srclang = sub.lang; + track.src = src; + if (sub.default) { + track.default = true; + } + video.appendChild(track); + } + const textTracks = video.textTracks; + let defaultShown = false; + for (let i = 0; i < textTracks.length; i++) { + if (textTracks[i].mode === "showing") { + defaultShown = true; + const matchingSub = this._subtitleTracks.find( + (sub) => sub.label === textTracks[i].label, + ); + if (matchingSub && this.player) { + void this.player.selectExternalSubtitle( + this.resolveExternalSubtitleId(matchingSub), + ); + } + continue; + } + textTracks[i].mode = "hidden"; + } + if (!defaultShown && this.player) { + const preferredSub = + this._subtitleTracks.find((sub) => sub.default) ?? + (this._subtitleTracks.length === 1 + ? this._subtitleTracks[0] + : undefined); + if (preferredSub) { + void this.player.selectExternalSubtitle( + this.resolveExternalSubtitleId(preferredSub), + ); + } + } + this.updateNativeSubtitleDelayRendering(); + } + + private stopNativeSubtitleDelayRendering(): void { + if (this._nativeSubDelayFrameId !== null) { + this.video.cancelVideoFrameCallback(this._nativeSubDelayFrameId); + this._nativeSubDelayFrameId = null; + } + if (this.subtitleOverlay) { + this.subtitleOverlay.innerHTML = ""; + } + } + + private updateNativeSubtitleDelayRendering(): void { + this.stopNativeSubtitleDelayRendering(); + if (this._presentation !== "native" || this._subtitleDelay === 0) { + return; + } + + const renderDelayedNativeSubtitles = () => { + if (this._presentation !== "native" || this._subtitleDelay === 0) { + return; + } + + const adjustedTime = this.video.currentTime - this._subtitleDelay; + let activeText = ""; + + for (let i = 0; i < this.video.textTracks.length; i++) { + const textTrack = this.video.textTracks[i]; + if (textTrack.kind !== "subtitles" && textTrack.kind !== "captions") { + continue; + } + if (textTrack.mode === "showing") { + textTrack.mode = "hidden"; + } + const cues = textTrack.cues; + if (!cues) continue; + for (let j = 0; j < cues.length; j++) { + const cue = cues[j]; + if ( + cue && + adjustedTime >= cue.startTime && + adjustedTime <= cue.endTime + ) { + activeText = (cue as VTTCue).text; + break; + } + } + if (activeText) break; + } + + if (this.subtitleOverlay) { + this.subtitleOverlay.innerHTML = ""; + if (activeText) { + const block = document.createElement("div"); + block.className = "movi-subtitle-block"; + const line = document.createElement("div"); + line.className = "movi-subtitle-line"; + line.textContent = activeText; + block.appendChild(line); + this.subtitleOverlay.appendChild(block); + } + } + + this._nativeSubDelayFrameId = this.video.requestVideoFrameCallback(() => { + this._nativeSubDelayFrameId = null; + renderDelayedNativeSubtitles(); + }); + }; + + renderDelayedNativeSubtitles(); + } + private setupEventHandlers(): void { if (!this.player) return; @@ -16429,6 +16802,7 @@ export class MoviElement extends HTMLElement { // Handle tracks change (when media loads) const tracksChangeHandler = () => { + this.updateCoverArtOverlay(); this.updateAudioTrackMenu(); this.updateSubtitleTrackMenu(); this.updateQualityMenu(); @@ -16447,6 +16821,35 @@ export class MoviElement extends HTMLElement { this.player?.trackManager.off("tracksChange", tracksChangeHandler), ); + const forwardDom = <K extends "waiting" | "playing" | "seeked">( + event: K, + mapper?: (value: unknown) => CustomEventInit, + ) => { + const handler = (detail: unknown) => { + const init = mapper ? mapper(detail) : { detail }; + this.dispatchEvent(new CustomEvent(event, init)); + }; + this.player!.on(event, handler as never); + this.eventHandlers.set(event, () => + this.player?.off(event, handler as never), + ); + }; + forwardDom("waiting", () => ({})); + forwardDom("playing", () => ({})); + forwardDom("seeked", (time) => ({ detail: time })); + + const playerErrorHandler = (info: { + message: string; + category: string; + severity: string; + }) => { + this.dispatchEvent(new CustomEvent("playererror", { detail: info })); + }; + this.player.on("playerError", playerErrorHandler); + this.eventHandlers.set("playerError", () => + this.player?.off("playerError", playerErrorHandler), + ); + // Forward player events to element const stateChangeHandler = (state: PlayerState) => { Logger.info(TAG, `stateChange: ${state}`); @@ -16864,6 +17267,16 @@ export class MoviElement extends HTMLElement { */ async play(): Promise<void> { if (this._isUnsupported) return; + if (this._elementPlayPromise) { + return this._elementPlayPromise; + } + this._elementPlayPromise = this.playInternal().finally(() => { + this._elementPlayPromise = null; + }); + return this._elementPlayPromise; + } + + private async playInternal(): Promise<void> { // If a load is in flight, defer the play. initializePlayer() flushes // this once loading settles, matching HTMLMediaElement.play() semantics. if (this.isLoading) { @@ -16891,6 +17304,7 @@ export class MoviElement extends HTMLElement { // Cancel any queued play() intent so a late load doesn't start playback // after the caller explicitly paused. this._pendingPlay = false; + this._elementPlayPromise = null; if (this.player && !this.isLoading && !this._isUnsupported) { this.player.pause(); } @@ -17124,6 +17538,8 @@ export class MoviElement extends HTMLElement { dispose(): void { // Cancel any queued play intent this._pendingPlay = false; + this._elementPlayPromise = null; + this._autoplayInvoked = false; this._preloadGateActive = false; this._resumeDialogPending = false; this._autoplayPendingVisible = false; @@ -17579,7 +17995,6 @@ export class MoviElement extends HTMLElement { const duration = this.player.getDuration(); if (chapters.length === 0 || duration <= 0) return; - // Add chapter dividers (gaps between chapters) for (let i = 1; i < chapters.length; i++) { const ch = chapters[i]; const percent = (ch.start / duration) * 100; @@ -17587,27 +18002,28 @@ export class MoviElement extends HTMLElement { const marker = document.createElement("div"); marker.className = "movi-chapter-marker"; marker.style.left = `${percent}%`; - - // Tooltip with chapter title marker.setAttribute("data-title", ch.title); container.appendChild(marker); } - // Add chapter segment hover labels - for (let i = 0; i < chapters.length; i++) { - const ch = chapters[i]; - const startPct = (ch.start / duration) * 100; - const endPct = i < chapters.length - 1 - ? (chapters[i + 1].start / duration) * 100 - : 100; + for (const ch of chapters) { + const { left, width } = chapterSegmentPercent(ch, duration); + if (width <= 0) { + continue; + } const segment = document.createElement("div"); segment.className = "movi-chapter-segment"; - segment.style.left = `${startPct}%`; - segment.style.width = `${endPct - startPct}%`; + segment.style.left = `${left}%`; + segment.style.width = `${width}%`; segment.setAttribute("data-title", ch.title); + const segmentClass = introDbSegmentClassName(ch.title); + if (segmentClass) { + segment.classList.add(segmentClass); + } + container.appendChild(segment); } } @@ -18402,15 +18818,70 @@ export class MoviElement extends HTMLElement { /** * Get available external subtitle tracks */ - getSubtitleLangs(): { lang: string; label: string; active: boolean }[] { - if (this.player) return this.player.getSubtitleLangs(); + getSubtitleLangs(): { + id: string; + lang: string; + label: string; + active: boolean; + }[] { + if (this.player) { + const fromPlayer = this.player.getSubtitleLangs(); + if (fromPlayer.length > 0) { + return fromPlayer; + } + } return this._subtitleTracks.map((t) => ({ + id: this.resolveExternalSubtitleId(t), lang: t.lang, label: t.label, active: false, })); } + /** + * Select an external subtitle track by stable id (null to disable) + */ + async selectExternalSubtitle(id: string | null): Promise<boolean> { + if (this.player) return this.player.selectExternalSubtitle(id); + return false; + } + + setExternalSubtitles( + subtitles: { + id?: string; + src: string; + lang: string; + label: string; + format?: string; + default?: boolean; + }[], + ): void { + this._subtitleTracks = subtitles.map((sub) => ({ + src: sub.src, + lang: sub.lang, + label: sub.label, + format: sub.format, + id: sub.id, + default: sub.default, + })); + + if (this.player) { + this.player.setExternalSubtitleTracks( + this._subtitleTracks.map((track) => ({ + url: track.src, + lang: track.lang, + label: track.label, + format: track.format as "vtt" | "srt" | undefined, + id: track.id ?? track.src, + })), + ); + } + + if (this._presentation === "native" && this.player) { + this.syncNativeSubtitleTracks(); + } + } + /** * Select an external subtitle track by language (null to disable) */ @@ -18419,6 +18890,67 @@ export class MoviElement extends HTMLElement { return false; } + /** + * Manifest / muxed subtitle tracks (HLS, MKV, etc.) + */ + getSubtitleTracks(): ReturnType<MoviPlayer["getSubtitleTracks"]> { + return this.player?.getSubtitleTracks() ?? []; + } + + /** + * Select a manifest / muxed subtitle track (null to disable) + */ + async selectSubtitleTrack(trackId: number | null): Promise<boolean> { + if (!this.player) return false; + return this.player.selectSubtitleTrack(trackId); + } + + /** + * Active subtitle language for preference persistence ("off" when disabled) + */ + getActiveSubtitlePreference(): string { + const external = this.player?.getSubtitleLangs().find((track) => track.active); + if (external) { + return external.lang || external.label || "unknown"; + } + + const muxed = this.player?.trackManager.getActiveSubtitleTrack(); + if (muxed) { + return muxed.language || muxed.label || "unknown"; + } + + return "off"; + } + + setExternalQualities(qualities: ExternalQualityEntry[]): void { + this.player?.setExternalQualities(qualities); + } + + getExternalQualities(): ExternalQualityEntry[] { + return this.player?.getExternalQualities() ?? []; + } + + setChapterMarkers(chapters: ChapterMarker[]): void { + this.player?.setChapterMarkers(chapters); + this.renderChapterMarkers(); + } + + getChapterMarkers(): ChapterMarker[] { + return this.player?.getChapterMarkers() ?? []; + } + + getPresentationMode(): PresentationMode { + return ( + this.player?.getPresentationMode() ?? + this._presentation ?? + "canvas" + ); + } + + canCast(): boolean { + return this.player?.canCast() ?? false; + } + set src(value: string | File | null) { // Save position before switching source, then stop saving if (this._resume) this.saveResumePosition(); @@ -18653,7 +19185,13 @@ export class MoviElement extends HTMLElement { audio?: | { src: string; type?: string } | { src: string; type?: string; lang: string; label: string }[]; - subtitles?: { src: string; lang: string; label: string; format?: string }[]; + subtitles?: { + src: string; + lang: string; + label: string; + format?: string; + id?: string; + }[]; } ): { src: string | File | null; type: string; audioSrc?: string | null } | void { // Getter @@ -20891,6 +21429,9 @@ export class MoviElement extends HTMLElement { // Register the custom element // Note: Custom element names must contain a hyphen per HTML spec -if (typeof customElements !== "undefined") { +if ( + typeof customElements !== "undefined" && + !customElements.get("movi-player") +) { customElements.define("movi-player", MoviElement); } diff --git a/packages/player/src/render/ProgressiveVideoWrapper.ts b/packages/player/src/render/ProgressiveVideoWrapper.ts new file mode 100644 index 00000000..1625cffa --- /dev/null +++ b/packages/player/src/render/ProgressiveVideoWrapper.ts @@ -0,0 +1,258 @@ +import { EventEmitter } from "../events/EventEmitter"; +import { + AudioTrack, + PlayerConfig, + PlayerEventMap, + PlayerState, + Track, + VideoTrack, +} from "../types"; +import { TrackManager } from "../core/TrackManager"; +import { applyStreamVideoVisibility } from "../core/presentation"; +import { Logger } from "../utils/Logger"; +import { VideoPlayGate } from "../utils/safeMediaPlay"; + +const TAG = "ProgressiveVideoWrapper"; + +export type ProgressiveNativeDims = { + videoWidth: number; + videoHeight: number; +}; + +export const buildProgressiveNativeTracks = ( + dims: ProgressiveNativeDims, +): Track[] => { + const tracks: Track[] = []; + if (dims.videoWidth > 0 && dims.videoHeight > 0) { + const video: VideoTrack = { + id: 0, + type: "video", + codec: "", + width: dims.videoWidth, + height: dims.videoHeight, + frameRate: 0, + label: `${dims.videoHeight}p`, + }; + tracks.push(video); + } + const audio: AudioTrack = { + id: 1, + type: "audio", + codec: "", + channels: 0, + sampleRate: 0, + label: "Audio", + }; + tracks.push(audio); + return tracks; +}; + +export class ProgressiveVideoWrapper extends EventEmitter<PlayerEventMap> { + private config: PlayerConfig; + private videoElement: HTMLVideoElement; + private state: PlayerState = "idle"; + private readonly videoPlayGate = new VideoPlayGate(); + public trackManager: TrackManager; + private publishedWidth = -1; + private publishedHeight = -1; + + constructor(config: PlayerConfig) { + super(); + this.config = config; + this.trackManager = new TrackManager(); + this.videoElement = document.createElement("video"); + this.videoElement.crossOrigin = "anonymous"; + this.videoElement.playsInline = true; + this.videoElement.preload = "auto"; + applyStreamVideoVisibility(this.videoElement, config); + this.setupEventHandlers(); + } + + private publishNativeTracks(): void { + const width = this.videoElement.videoWidth; + const height = this.videoElement.videoHeight; + if (width === this.publishedWidth && height === this.publishedHeight) { + return; + } + this.publishedWidth = width; + this.publishedHeight = height; + this.trackManager.setTracks( + buildProgressiveNativeTracks({ videoWidth: width, videoHeight: height }), + ); + } + + private handleResize = (): void => { + this.publishNativeTracks(); + }; + + private setupEventHandlers(): void { + this.videoElement.addEventListener("resize", this.handleResize); + this.videoElement.addEventListener("play", () => this.setState("playing")); + this.videoElement.addEventListener("playing", () => { + this.setState("playing"); + this.emit("playing", undefined); + }); + this.videoElement.addEventListener("pause", () => { + if (this.state !== "ended") this.setState("paused"); + }); + this.videoElement.addEventListener("ended", () => this.setState("ended")); + this.videoElement.addEventListener("seeking", () => { + this.setState("seeking"); + this.emit("seeking", this.videoElement.currentTime); + }); + this.videoElement.addEventListener("seeked", () => { + this.emit("seeked", this.videoElement.currentTime); + if (this.videoElement.paused) this.setState("paused"); + else this.setState("playing"); + }); + this.videoElement.addEventListener("waiting", () => { + this.setState("buffering"); + this.emit("waiting", undefined); + }); + this.videoElement.addEventListener("timeupdate", () => { + this.emit("timeUpdate", this.videoElement.currentTime); + }); + this.videoElement.addEventListener("durationchange", () => { + this.emit("durationChange", this.videoElement.duration); + }); + this.videoElement.addEventListener("error", () => { + const error = this.videoElement.error; + this.emit("error", new Error(error?.message || "Video element error")); + this.setState("error"); + }); + } + + private setState(newState: PlayerState): void { + if (this.state === newState) return; + this.state = newState; + this.emit("stateChange", newState); + } + + async load(): Promise<void> { + const url = + this.config.source?.type === "url" ? this.config.source.url : null; + if (!url) throw new Error("Progressive source must be a URL"); + + this.setState("loading"); + this.emit("loadStart", undefined); + + return new Promise<void>((resolve, reject) => { + const onLoaded = () => { + cleanup(); + this.publishNativeTracks(); + this.setState("ready"); + this.emit("loadEnd", undefined); + resolve(); + }; + const onError = () => { + cleanup(); + reject(new Error("Progressive video load failed")); + }; + const cleanup = () => { + this.videoElement.removeEventListener("loadedmetadata", onLoaded); + this.videoElement.removeEventListener("error", onError); + }; + + this.videoElement.addEventListener("loadedmetadata", onLoaded); + this.videoElement.addEventListener("error", onError); + this.videoElement.src = url; + this.videoElement.load(); + Logger.info(TAG, `Loading progressive native video: ${url}`); + }); + } + + async play(): Promise<void> { + if (!this.videoElement.paused && this.state === "playing") { + return; + } + await this.videoPlayGate.play(this.videoElement); + } + + pause(): void { + this.videoPlayGate.reset(); + this.videoElement.pause(); + } + + async seek(time: number): Promise<void> { + this.videoElement.currentTime = time; + } + + getState(): PlayerState { + return this.state; + } + + getDuration(): number { + return this.videoElement.duration; + } + + getCurrentTime(): number { + return this.videoElement.currentTime; + } + + setVolume(volume: number): void { + this.videoElement.volume = Math.min(1, Math.max(0, volume)); + } + + setMuted(muted: boolean): void { + this.videoElement.muted = muted; + } + + setPlaybackRate(rate: number): void { + this.videoElement.playbackRate = rate; + } + + getVolume(): number { + return this.videoElement.volume; + } + + isMuted(): boolean { + return this.videoElement.muted; + } + + getPlaybackRate(): number { + return this.videoElement.playbackRate; + } + + getVideoElement(): HTMLVideoElement { + return this.videoElement; + } + + getBufferEndTime(): number { + if (this.videoElement.buffered.length) { + return this.videoElement.buffered.end(this.videoElement.buffered.length - 1); + } + return 0; + } + + setFitMode(mode: "contain" | "cover" | "fill" | "zoom" | "control"): void { + if (mode === "contain") this.videoElement.style.objectFit = "contain"; + else if (mode === "cover") this.videoElement.style.objectFit = "cover"; + else if (mode === "fill") this.videoElement.style.objectFit = "fill"; + } + + getStats(): Record<string, string | number | boolean> { + return { + "Playback State": this.state, + Renderer: "HTML5 Video", + Resolution: `${this.videoElement.videoWidth}x${this.videoElement.videoHeight}`, + }; + } + + getNetworkSpeed(): number { + return 0; + } + + isFileSource(): boolean { + return false; + } + + destroy(): void { + this.videoElement.removeEventListener("resize", this.handleResize); + this.videoElement.removeAttribute("src"); + this.videoElement.load(); + if (this.videoElement.parentNode) { + this.videoElement.parentNode.removeChild(this.videoElement); + } + this.removeAllListeners(); + } +} diff --git a/packages/player/src/render/RemotePlaybackController.ts b/packages/player/src/render/RemotePlaybackController.ts new file mode 100644 index 00000000..ee1fc12d --- /dev/null +++ b/packages/player/src/render/RemotePlaybackController.ts @@ -0,0 +1,183 @@ +import { castSupported } from "../core/presentation"; +import { Logger } from "../utils/Logger"; + +const TAG = "RemotePlaybackController"; + +type CastSession = { + loadMedia: ( + request: { media: { contentId: string; contentType: string; streamType: string } }, + success: () => void, + error: (err: unknown) => void, + ) => void; +}; + +type CastApi = { + initialize: ( + apiConfig: { receiverApplicationId: string; autoJoinPolicy: string }, + onSuccess: () => void, + onError: (err: unknown) => void, + ) => void; + requestSession: ( + onSuccess: (session: CastSession) => void, + onError: (err: unknown) => void, + ) => void; +}; + +declare global { + interface Window { + __moviCastInitialized?: boolean; + cast?: { + framework?: { + CastContext?: { + getInstance: () => { + setOptions: (opts: { + receiverApplicationId: string; + autoJoinPolicy: string; + }) => void; + requestSession: () => Promise<unknown>; + }; + }; + }; + }; + chrome?: { + cast?: { + media?: { DEFAULT_MEDIA_RECEIVER_APP_ID: string }; + AutoJoinPolicy?: { ORIGIN_SCOPED: string }; + isAvailable?: boolean; + initialize: CastApi["initialize"]; + requestSession: CastApi["requestSession"]; + }; + }; + } +} + +export interface RemotePlaybackOptions { + video: HTMLVideoElement; + getSourceUrl: () => string | null; + getContentType: () => string; + onCanCastChange?: (canCast: boolean) => void; +} + +export class RemotePlaybackController { + private options: RemotePlaybackOptions; + private castReady = false; + + constructor(options: RemotePlaybackOptions) { + this.options = options; + } + + get canCast(): boolean { + return castSupported(); + } + + get canAirPlay(): boolean { + const v = this.options.video as HTMLVideoElement & { + webkitShowPlaybackTargetPicker?: unknown; + }; + return typeof v.webkitShowPlaybackTargetPicker === "function"; + } + + get canRemotePlayback(): boolean { + return typeof this.options.video.remote !== "undefined"; + } + + notifyCapability(): void { + this.options.onCanCastChange?.(this.canCast); + } + + showAirPlayPicker(): void { + const v = this.options.video as HTMLVideoElement & { + webkitShowPlaybackTargetPicker?: () => void; + }; + v.webkitShowPlaybackTargetPicker?.(); + } + + async requestCast(): Promise<void> { + if (!this.canCast) { + throw new Error("Cast is not supported in this browser"); + } + const url = this.options.getSourceUrl(); + if (!url) { + throw new Error("No castable source URL"); + } + + await this.ensureCastApi(); + const chromeCast = window.chrome?.cast; + if (!chromeCast?.requestSession) { + throw new Error("Cast API unavailable"); + } + + return new Promise<void>((resolve, reject) => { + chromeCast.requestSession!( + (session) => { + const media = chromeCast.media; + if (!media || !session.loadMedia) { + reject(new Error("Cast media API unavailable")); + return; + } + session.loadMedia( + { + media: { + contentId: url, + contentType: this.options.getContentType(), + streamType: "BUFFERED", + }, + }, + () => resolve(), + (err) => reject(err instanceof Error ? err : new Error(String(err))), + ); + }, + (err) => reject(err instanceof Error ? err : new Error(String(err))), + ); + }); + } + + private async ensureCastApi(): Promise<void> { + if (this.castReady) return; + if (window.__moviCastInitialized) { + this.castReady = true; + return; + } + + await this.loadCastFramework(); + const chromeCast = window.chrome?.cast; + if (!chromeCast?.initialize) { + throw new Error("Cast framework failed to load"); + } + + return new Promise<void>((resolve, reject) => { + const appId = + chromeCast.media?.DEFAULT_MEDIA_RECEIVER_APP_ID ?? "CC1AD845"; + const policy = + chromeCast.AutoJoinPolicy?.ORIGIN_SCOPED ?? "origin_scoped"; + chromeCast.initialize( + { receiverApplicationId: appId, autoJoinPolicy: policy }, + () => { + window.__moviCastInitialized = true; + this.castReady = true; + resolve(); + }, + (err) => { + Logger.warn(TAG, "Cast initialize failed", err); + reject(err instanceof Error ? err : new Error(String(err))); + }, + ); + }); + } + + private loadCastFramework(): Promise<void> { + if (document.querySelector('script[data-movi-cast="true"]')) { + return Promise.resolve(); + } + return new Promise<void>((resolve, reject) => { + const script = document.createElement("script"); + script.src = + "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"; + script.async = true; + script.dataset.moviCast = "true"; + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Failed to load Cast sender SDK")); + document.head.appendChild(script); + }); + } +} diff --git a/packages/player/src/render/ShakaPlayerWrapper.ts b/packages/player/src/render/ShakaPlayerWrapper.ts index 5b49561c..b763f02a 100644 --- a/packages/player/src/render/ShakaPlayerWrapper.ts +++ b/packages/player/src/render/ShakaPlayerWrapper.ts @@ -12,6 +12,11 @@ import { import { CanvasRenderer } from "./CanvasRenderer"; import { TrackManager } from "../core/TrackManager"; import { Logger } from "../utils/Logger"; +import { + applyStreamVideoVisibility, + usesCanvasCopyLoop, +} from "../core/presentation"; +import { VideoPlayGate } from "../utils/safeMediaPlay"; const TAG = "ShakaPlayerWrapper"; @@ -44,6 +49,7 @@ export class ShakaPlayerWrapper extends EventEmitter<PlayerEventMap> { public trackManager: TrackManager; private frameCallbackId: number | null = null; private _framesRendered: number = 0; + private readonly videoPlayGate = new VideoPlayGate(); // All Shaka variant tracks for the loaded manifest, plus the per-type // representative arrays whose array index === the Track.id we hand the @@ -78,18 +84,14 @@ export class ShakaPlayerWrapper extends EventEmitter<PlayerEventMap> { this.videoElement = document.createElement("video"); this.videoElement.crossOrigin = "anonymous"; this.videoElement.playsInline = true; - this.videoElement.style.display = "none"; // Hidden; canvas renderer draws frames + applyStreamVideoVisibility(this.videoElement, config); // Preserve pitch when changing playback speed (this.videoElement as any).preservesPitch = true; (this.videoElement as any).mozPreservesPitch = true; // Firefox (this.videoElement as any).webkitPreservesPitch = true; // Safari/older Chrome - // DRM mode: use native video element directly (no canvas) — canvas can't - // access DRM-protected frames (browser blocks VideoFrame copy). - // LCEVC mode: Shaka composites the enhanced frames onto the canvas itself - // (via attachCanvas), so our rVFC→CanvasRenderer path stays out of the way. - if (!config.drm && !config.lcevc && config.renderer === "canvas" && config.canvas) { + if (usesCanvasCopyLoop(config) && config.canvas) { this.canvasRenderer = new CanvasRenderer(config.canvas); } @@ -227,23 +229,27 @@ export class ShakaPlayerWrapper extends EventEmitter<PlayerEventMap> { private setupEventHandlers(): void { this.videoElement.addEventListener("play", () => this.setState("playing")); - this.videoElement.addEventListener("playing", () => - this.setState("playing"), - ); + this.videoElement.addEventListener("playing", () => { + this.setState("playing"); + this.emit("playing", undefined); + }); this.videoElement.addEventListener("pause", () => { if (this.state !== "ended") this.setState("paused"); }); this.videoElement.addEventListener("ended", () => this.setState("ended")); - this.videoElement.addEventListener("seeking", () => - this.setState("seeking"), - ); + this.videoElement.addEventListener("seeking", () => { + this.setState("seeking"); + this.emit("seeking", this.videoElement.currentTime); + }); this.videoElement.addEventListener("seeked", () => { + this.emit("seeked", this.videoElement.currentTime); if (this.videoElement.paused) this.setState("paused"); else this.setState("playing"); }); - this.videoElement.addEventListener("waiting", () => - this.setState("buffering"), - ); + this.videoElement.addEventListener("waiting", () => { + this.setState("buffering"); + this.emit("waiting", undefined); + }); this.videoElement.addEventListener("timeupdate", () => { this.emit("timeUpdate", this.videoElement.currentTime); }); @@ -473,6 +479,10 @@ export class ShakaPlayerWrapper extends EventEmitter<PlayerEventMap> { // (post-load) errors arrive via this event without rejecting. this.player.addEventListener("error", (event: any) => { const detail = event?.detail; + // Code 7003 is OBJECT_DESTROYED — emitted naturally when player.destroy() + // runs (e.g. on unmount or engine fallback). Ignore it completely. + if (detail?.code === 7003) return; + // Log the technical detail for developers; surface plain text to viewers. Logger.error( TAG, @@ -713,7 +723,7 @@ export class ShakaPlayerWrapper extends EventEmitter<PlayerEventMap> { this.trackManager.setTracks(tracks); if (this.videoRenditions.length > 0) { - this.trackManager.selectVideoTrack(-1); // default Auto (video streams only) + this.trackManager.selectHighestVideoTrack(); } this.sizeCanvasToTopRendition(); @@ -787,10 +797,14 @@ export class ShakaPlayerWrapper extends EventEmitter<PlayerEventMap> { } async play(): Promise<void> { - await this.videoElement.play(); + if (!this.videoElement.paused && this.state === "playing") { + return; + } + await this.videoPlayGate.play(this.videoElement); } pause(): void { + this.videoPlayGate.reset(); this.videoElement.pause(); } @@ -1092,10 +1106,12 @@ export class ShakaPlayerWrapper extends EventEmitter<PlayerEventMap> { if (this.player) { // shaka destroy is async; fire-and-forget (we drop the reference below). - this.player.destroy().catch(() => { - /* Shaka can throw if already torn down */ - }); + // Catch any rejected promise or internal 7003 OBJECT_DESTROYED errors. + const p = this.player; this.player = null; + p.destroy().catch(() => { + /* Shaka can throw/reject if already torn down or during destroy transition */ + }); } if (this.textContainer?.parentNode) { diff --git a/packages/player/src/render/VideoPresenter.ts b/packages/player/src/render/VideoPresenter.ts new file mode 100644 index 00000000..32614ae7 --- /dev/null +++ b/packages/player/src/render/VideoPresenter.ts @@ -0,0 +1,254 @@ +import type { PlayerState } from "../types"; + +export interface BufferedRange { + start: number; + end: number; +} + +export interface VideoPresenter { + readonly mode: "native" | "canvas"; + getVideoElement(): HTMLVideoElement | null; + getCanvasElement(): HTMLCanvasElement | null; + getCurrentTime(): number; + getDuration(): number; + getBuffered(): BufferedRange[]; + getState(): PlayerState; + play(): Promise<void>; + pause(): void; + seek(seconds: number): Promise<void>; + setVolume(volume: number): void; + getVolume(): number; + setMuted(muted: boolean): void; + isMuted(): boolean; + setPlaybackRate(rate: number): void; + getPlaybackRate(): number; + requestFullscreen(): Promise<void>; + requestPictureInPicture(): Promise<void>; + canCast(): boolean; + canAirPlay(): boolean; + showAirPlayPicker(): void; +} + +export class NativeVideoPresenter implements VideoPresenter { + readonly mode = "native" as const; + + constructor(private readonly video: HTMLVideoElement) {} + + getVideoElement(): HTMLVideoElement { + return this.video; + } + + getCanvasElement(): null { + return null; + } + + getCurrentTime(): number { + return this.video.currentTime; + } + + getDuration(): number { + return this.video.duration; + } + + getBuffered(): BufferedRange[] { + const ranges: BufferedRange[] = []; + const buffered = this.video.buffered; + for (let i = 0; i < buffered.length; i++) { + ranges.push({ start: buffered.start(i), end: buffered.end(i) }); + } + return ranges; + } + + getState(): PlayerState { + if (this.video.ended) return "ended"; + if (this.video.seeking) return "seeking"; + if (this.video.paused) return "paused"; + if (this.video.readyState < 3) return "buffering"; + return "playing"; + } + + async play(): Promise<void> { + await this.video.play(); + } + + pause(): void { + this.video.pause(); + } + + async seek(seconds: number): Promise<void> { + this.video.currentTime = seconds; + } + + setVolume(volume: number): void { + this.video.volume = Math.min(1, Math.max(0, volume)); + } + + getVolume(): number { + return this.video.volume; + } + + setMuted(muted: boolean): void { + this.video.muted = muted; + } + + isMuted(): boolean { + return this.video.muted; + } + + setPlaybackRate(rate: number): void { + this.video.playbackRate = rate; + } + + getPlaybackRate(): number { + return this.video.playbackRate; + } + + async requestFullscreen(): Promise<void> { + const v = this.video as HTMLVideoElement & { + webkitEnterFullscreen?: () => void; + }; + if (typeof v.webkitEnterFullscreen === "function") { + v.webkitEnterFullscreen(); + return; + } + if (this.video.requestFullscreen) { + await this.video.requestFullscreen(); + return; + } + const host = this.video.parentElement; + if (host?.requestFullscreen) { + await host.requestFullscreen(); + } + } + + async requestPictureInPicture(): Promise<void> { + if (document.pictureInPictureEnabled && this.video.requestPictureInPicture) { + await this.video.requestPictureInPicture(); + } + } + + canCast(): boolean { + return true; + } + + canAirPlay(): boolean { + return ( + typeof (this.video as HTMLVideoElement & { + webkitShowPlaybackTargetPicker?: unknown; + }).webkitShowPlaybackTargetPicker === "function" + ); + } + + showAirPlayPicker(): void { + const v = this.video as HTMLVideoElement & { + webkitShowPlaybackTargetPicker?: () => void; + }; + v.webkitShowPlaybackTargetPicker?.(); + } +} + +export class CanvasVideoPresenter implements VideoPresenter { + readonly mode = "canvas" as const; + + constructor( + private readonly canvas: HTMLCanvasElement, + private readonly playerApi: { + getCurrentTime(): number; + getDuration(): number; + getState(): PlayerState; + play(): Promise<void>; + pause(): void; + seek(seconds: number): Promise<void>; + setVolume(volume: number): void; + getVolume(): number; + setMuted(muted: boolean): void; + getMuted(): boolean; + setPlaybackRate(rate: number): void; + getPlaybackRate(): number; + getBufferEndTime(): number; + }, + private readonly host: HTMLElement, + ) {} + + getVideoElement(): null { + return null; + } + + getCanvasElement(): HTMLCanvasElement { + return this.canvas; + } + + getCurrentTime(): number { + return this.playerApi.getCurrentTime(); + } + + getDuration(): number { + return this.playerApi.getDuration(); + } + + getBuffered(): BufferedRange[] { + const end = this.playerApi.getBufferEndTime(); + if (end <= 0) return []; + return [{ start: 0, end }]; + } + + getState(): PlayerState { + return this.playerApi.getState(); + } + + play(): Promise<void> { + return this.playerApi.play(); + } + + pause(): void { + this.playerApi.pause(); + } + + seek(seconds: number): Promise<void> { + return this.playerApi.seek(seconds); + } + + setVolume(volume: number): void { + this.playerApi.setVolume(volume); + } + + getVolume(): number { + return this.playerApi.getVolume(); + } + + setMuted(muted: boolean): void { + this.playerApi.setMuted(muted); + } + + isMuted(): boolean { + return this.playerApi.getMuted(); + } + + setPlaybackRate(rate: number): void { + this.playerApi.setPlaybackRate(rate); + } + + getPlaybackRate(): number { + return this.playerApi.getPlaybackRate(); + } + + async requestFullscreen(): Promise<void> { + if (this.host.requestFullscreen) { + await this.host.requestFullscreen(); + } + } + + async requestPictureInPicture(): Promise<void> { + // Document PiP for canvas is handled by MoviElement. + } + + canCast(): boolean { + return false; + } + + canAirPlay(): boolean { + return false; + } + + showAirPlayPicker(): void {} +} diff --git a/packages/player/src/types.ts b/packages/player/src/types.ts index 97479095..f101ac8c 100644 --- a/packages/player/src/types.ts +++ b/packages/player/src/types.ts @@ -118,16 +118,51 @@ export interface SubtitleSourceEntry { lang: string; // BCP 47 language code label: string; // Display name format?: "vtt" | "srt"; // Auto-detected from URL extension if omitted + /** Stable track id — required when multiple tracks share the same lang */ + id?: string; } +export const getExternalSubtitleId = (track: SubtitleSourceEntry): string => + track.id ?? track.url; + export interface CacheConfig { type: "lru"; maxSizeMB: number; } -export type RendererType = "canvas"; +export type RendererType = "canvas" | "video"; +export type PresentationMode = "native" | "canvas"; export type DecoderType = "auto" | "software"; +export type PlayerErrorCategory = + | "network" + | "decode" + | "drm" + | "manifest" + | "unknown"; + +export type PlayerErrorSeverity = "fatal" | "recoverable"; + +export interface PlayerErrorInfo { + message: string; + category: PlayerErrorCategory; + severity: PlayerErrorSeverity; + cause?: Error; +} + +export interface ExternalQualityEntry { + label: string; + url: string; + height?: number; + type?: string; +} + +export interface ChapterMarker { + title: string; + start: number; + end?: number; +} + export interface PlayerConfig { /** * Standard source descriptor (url / file / encrypted). Optional when a @@ -148,6 +183,8 @@ export interface PlayerConfig { /** External subtitle tracks (VTT/SRT) with language metadata */ subtitleTracks?: SubtitleSourceEntry[]; renderer?: RendererType; + /** Auto-selected from source when omitted. Canvas forces WASM/WebCodecs path. */ + presentation?: PresentationMode; decoder?: DecoderType; cache?: CacheConfig; canvas?: HTMLCanvasElement | OffscreenCanvas; @@ -287,6 +324,9 @@ export interface PlayerEventMap { durationChange: number; tracksChange: Track[]; error: Error; + playerError: PlayerErrorInfo; + waiting: void; + playing: void; filerevoked: { offset: number; length: number; reason: string }; loadStart: void; loadEnd: void; diff --git a/packages/player/src/utils/chapterMarkers.ts b/packages/player/src/utils/chapterMarkers.ts new file mode 100644 index 00000000..d893e63c --- /dev/null +++ b/packages/player/src/utils/chapterMarkers.ts @@ -0,0 +1,47 @@ +export type ResolvedChapter = { + title: string; + start: number; + end: number; +}; + +export const findChapterAtTime = ( + chapters: ResolvedChapter[], + time: number, +): ResolvedChapter | null => { + if (!Number.isFinite(time) || time < 0) { + return null; + } + + return ( + chapters.find((chapter) => time >= chapter.start && time < chapter.end) ?? + null + ); +}; + +export const chapterSegmentPercent = ( + chapter: ResolvedChapter, + duration: number, +): { left: number; width: number } => { + if (!Number.isFinite(duration) || duration <= 0) { + return { left: 0, width: 0 }; + } + + const left = (chapter.start / duration) * 100; + const width = ((chapter.end - chapter.start) / duration) * 100; + return { left, width }; +}; + +const INTRO_DB_SEGMENT_TYPES = new Set([ + "intro", + "recap", + "credits", + "preview", +]); + +export const introDbSegmentClassName = (title: string): string | null => { + const normalized = title.trim().toLowerCase(); + if (!INTRO_DB_SEGMENT_TYPES.has(normalized)) { + return null; + } + return `movi-chapter-segment--${normalized}`; +}; diff --git a/packages/player/src/utils/safeMediaPlay.ts b/packages/player/src/utils/safeMediaPlay.ts new file mode 100644 index 00000000..7c8bf1bf --- /dev/null +++ b/packages/player/src/utils/safeMediaPlay.ts @@ -0,0 +1,38 @@ +const isPlayAbortError = (error: unknown): boolean => + (error instanceof DOMException || error instanceof Error) && + error.name === "AbortError"; + +export const safeVideoPlay = async ( + video: HTMLVideoElement, +): Promise<void> => { + try { + await video.play(); + } catch (error) { + if (isPlayAbortError(error)) { + return; + } + throw error; + } +}; + +export class VideoPlayGate { + private inFlight: Promise<void> | null = null; + + async play(video: HTMLVideoElement): Promise<void> { + if (this.inFlight) { + return this.inFlight; + } + + const request = safeVideoPlay(video).finally(() => { + if (this.inFlight === request) { + this.inFlight = null; + } + }); + this.inFlight = request; + return request; + } + + reset(): void { + this.inFlight = null; + } +} diff --git a/packages/player/src/wasm/FFmpegLoader.ts b/packages/player/src/wasm/FFmpegLoader.ts index 9b462574..0cb4a966 100644 --- a/packages/player/src/wasm/FFmpegLoader.ts +++ b/packages/player/src/wasm/FFmpegLoader.ts @@ -4,10 +4,23 @@ import type { MoviWasmModule } from './types'; import { Logger } from '../utils/Logger'; -// Static import of the generated module (bundled into index.js) -// @ts-ignore - movi.js is Emscripten-generated, no types available -// eslint-disable-next-line @typescript-eslint/no-explicit-any -import createMoviModule from '../../dist/wasm/movi.js'; +// Static import replaced with dynamic import so the full bundle can lazy-load WASM. +type CreateMoviModule = (opts: Record<string, unknown>) => Promise<import("./types").MoviWasmModule>; + +let createMoviModulePromise: Promise<CreateMoviModule> | null = null; + +async function loadCreateMoviModule(): Promise<CreateMoviModule> { + // @ts-expect-error emscripten output has no declaration file + const mod = await import("../../dist/wasm/movi.js"); + return mod.default as CreateMoviModule; +} + +async function getCreateMoviModule(): Promise<CreateMoviModule> { + if (!createMoviModulePromise) { + createMoviModulePromise = loadCreateMoviModule(); + } + return createMoviModulePromise; +} const TAG = 'FFmpegLoader'; @@ -41,8 +54,7 @@ export async function loadWasmModule(options: LoaderOptions = {}): Promise<MoviW const wasmBinary = options.wasmBinary || embeddedWasmBinary; try { - // Static import - movi.js is bundled into index.js - const createModule = createMoviModule; + const createModule = await getCreateMoviModule(); // Create module - with SINGLE_FILE, WASM is embedded, so wasmBinary is optional const moduleOptions: any = { @@ -95,7 +107,7 @@ export async function loadWasmModuleNew(options: LoaderOptions = {}): Promise<Mo const wasmBinary = options.wasmBinary || embeddedWasmBinary; try { - const createModule = createMoviModule; + const createModule = await getCreateMoviModule(); const moduleOptions: any = { print: (text: string) => { diff --git a/scripts/bench-scrape-latency.mts b/scripts/bench-scrape-latency.mts index 10b20c36..9a3158a2 100644 --- a/scripts/bench-scrape-latency.mts +++ b/scripts/bench-scrape-latency.mts @@ -177,11 +177,6 @@ const benchAnime = async () => { input: { anilistId: 21, episodeNumber: 1 }, label: "OP ep1", }, - { - providerId: "animestream", - input: { anilistId: 21, episodeNumber: 1, query: "One Piece" }, - label: "OP ep1", - }, { providerId: "animeonsen", input: { anilistId: 154587, episodeNumber: 1 }, diff --git a/scripts/bootstrap-scrape-vpn.sh b/scripts/bootstrap-scrape-vpn.sh index 7a030406..5b9b8e29 100755 --- a/scripts/bootstrap-scrape-vpn.sh +++ b/scripts/bootstrap-scrape-vpn.sh @@ -254,9 +254,6 @@ ensure_flipt_env() { upsert_env_var "$LOCAL_ENV_FILE" FLIPT_URL "http://127.0.0.1:8090" upsert_env_var "$LOCAL_ENV_FILE" FLIPT_ENVIRONMENT "default" upsert_env_var "$LOCAL_ENV_FILE" FLIPT_NAMESPACE "default" - if [[ -z "$(read_env_value "$LOCAL_ENV_FILE" FLIPT_FLAG_CACHE_TTL_MS || true)" ]]; then - upsert_env_var "$LOCAL_ENV_FILE" FLIPT_FLAG_CACHE_TTL_MS "600000" - fi } flipt_is_healthy() { diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 793953bd..daa07a3f 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -7,6 +7,7 @@ # Override defaults: # DOCKER_IMAGE=whotypes/nyumatflix:latest CONTAINER_NAME=nyumatflix \ # ENV_FILE="$HOME/apps/nyumatflix/.env" \ +# DEPLOY_FROM_GIT=1 \ # ./scripts/deploy.sh serve set -euo pipefail @@ -62,27 +63,128 @@ load_build_env() { set +a } +player_wasm_path() { + printf '%s/packages/player/dist/wasm/movi.js' "$ROOT" +} + +player_vendor_element_path() { + printf '%s/apps/web/public/vendor/player/element.js' "$ROOT" +} + +player_artifacts_ready() { + [[ -f "$(player_wasm_path)" && -f "$(player_vendor_element_path)" ]] +} + +maybe_build_player_artifacts() { + if [[ "${FORCE_PLAYER_BUILD:-}" == "1" ]]; then + echo "FORCE_PLAYER_BUILD=1 — rebuilding player + wasm" + bunx turbo build --filter=@nyumatflix/player + return + fi + + if player_artifacts_ready; then + echo "player artifacts present — skipping wasm/player rebuild" + return + fi + + if [[ ! -f "$(player_wasm_path)" ]]; then + echo "wasm missing — building @nyumatflix/player wasm (needs docker)" + bunx turbo build:wasm --filter=@nyumatflix/player + fi + + if [[ ! -f "$(player_vendor_element_path)" ]]; then + echo "vendor player missing — building @nyumatflix/player" + bunx turbo build --filter=@nyumatflix/player + fi +} + +copy_player_artifacts_into() { + local dest="$1" + + if [[ -f "$(player_wasm_path)" ]]; then + mkdir -p "$dest/packages/player/dist/wasm" + cp "$(player_wasm_path)" "$dest/packages/player/dist/wasm/movi.js" + fi + + if [[ -f "$ROOT/packages/player/dist/element.js" ]]; then + mkdir -p "$dest/packages/player/dist" + cp "$ROOT/packages/player/dist/element.js" "$dest/packages/player/dist/element.js" + fi + + if [[ -d "$ROOT/apps/web/public/vendor/player" ]]; then + mkdir -p "$dest/apps/web/public/vendor/player" + cp -R "$ROOT/apps/web/public/vendor/player/." "$dest/apps/web/public/vendor/player/" + fi +} + +prepare_git_archive_context() { + local context_dir + context_dir="$(mktemp -d "${TMPDIR:-/tmp}/nyumatflix-build.XXXXXX")" + git -C "$ROOT" archive HEAD | tar -x -C "$context_dir" + copy_player_artifacts_into "$context_dir" + printf '%s' "$context_dir" +} + +resolve_build_context() { + if [[ "${DEPLOY_FROM_GIT:-}" == "1" ]]; then + echo "DEPLOY_FROM_GIT=1 — docker build uses committed tree only" + prepare_git_archive_context + return + fi + + printf '%s' "$ROOT" +} + build_push() { resolve_deploy_git_meta || true if [[ -n "${DEPLOY_SHA:-}" ]]; then DOCKER_IMAGE="$DOCKER_REPO:$DEPLOY_SHA" fi - "$ROOT/scripts/bootstrap-scrape-vpn.sh" ensure-local + if [[ "${SKIP_SCRAPE_STACK:-}" != "1" ]]; then + "$ROOT/scripts/bootstrap-scrape-vpn.sh" ensure-local + else + echo "SKIP_SCRAPE_STACK=1 — scrape bootstrap skipped" + fi cd "$ROOT" load_build_env - bunx turbo build:wasm --filter=@nyumatflix/player + maybe_build_player_artifacts local -a tags=(-t "$DOCKER_IMAGE") if [[ -n "${DEPLOY_SHA:-}" && "$DOCKER_IMAGE" != "$DOCKER_REPO:latest" ]]; then tags+=(-t "$DOCKER_REPO:latest") fi - docker build --platform linux/amd64 \ + local skip_player_build=0 + if [[ "${SKIP_PLAYER_BUILD:-}" == "1" ]] || player_artifacts_ready; then + skip_player_build=1 + fi + + docker pull "$DOCKER_REPO:latest" >/dev/null 2>&1 || true + + local build_context="" + local cleanup_context=0 + build_context="$(resolve_build_context)" + if [[ "${DEPLOY_FROM_GIT:-}" == "1" ]]; then + cleanup_context=1 + fi + + if ! DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \ --build-arg TMDB_API_KEY="${TMDB_API_KEY:-}" \ --build-arg CAP_API_ENDPOINT="${CAP_API_ENDPOINT:-}" \ - "${tags[@]}" . + --build-arg SKIP_PLAYER_BUILD="$skip_player_build" \ + --cache-from "$DOCKER_REPO:latest" \ + "${tags[@]}" "$build_context"; then + if [[ "$cleanup_context" == "1" ]]; then + rm -rf "$build_context" + fi + exit 1 + fi + + if [[ "$cleanup_context" == "1" ]]; then + rm -rf "$build_context" + fi docker push "$DOCKER_IMAGE" if [[ -n "${DEPLOY_SHA:-}" && "$DOCKER_IMAGE" != "$DOCKER_REPO:latest" ]]; then diff --git a/scripts/diagnose-op-film-red.mts b/scripts/diagnose-op-film-red.mts index 86c3550e..0524faac 100644 --- a/scripts/diagnose-op-film-red.mts +++ b/scripts/diagnose-op-film-red.mts @@ -8,7 +8,7 @@ const TMDB_ID = 900667; const ANILIST_ID = 141902; const QUERY = "One Piece Film Red"; -const { scrapeFetch, scrapeFetchText, cancelResponseBody } = await import( +const { scrapeFetch, scrapeFetchText } = await import( "../lib/scrape/fetch.ts" ); const { scrapeVidKing } = await import("../lib/scrape/providers/vidking.ts"); @@ -17,9 +17,6 @@ const { scrapeKyren } = await import("../lib/scrape/anime/providers/kyren.ts"); const { scrapeAllmanga } = await import( "../lib/scrape/anime/providers/allmanga.ts" ); -const { scrapeAnimestream } = await import( - "../lib/scrape/anime/providers/animestream.ts" -); const { scrapeAnimepahe } = await import( "../lib/scrape/anime/providers/animepahe.ts" ); @@ -122,36 +119,12 @@ const am = await scrapeAllmanga({ }); console.log("scrapeAllmanga:", am.ok ? "OK" : am.error); -// --- animestream --- -console.log("\n=== ANIMESTREAM ==="); const expected = await resolveAnimeSearchQueries({ anilistId: ANILIST_ID, episodeNumber: 1, query: QUERY, }); console.log("expected titles:", expected); -const home = await scrapeFetch("https://animestream.my.id/", { - headers: { Referer: "https://animestream.my.id/" }, -}); -const cookie = home.headers.get("set-cookie") ?? ""; -await cancelResponseBody(home); -for (const q of expected) { - const search = await scrapeFetchText( - `https://animestream.my.id/ajax.php?action=ajax-proxy&endpoint=/ajax-search&q=${encodeURIComponent(q)}`, - { - Referer: "https://animestream.my.id/", - Cookie: cookie, - "X-Requested-With": "XMLHttpRequest", - }, - ); - console.log(`search "${q}":`, search.status, search.text.slice(0, 300)); -} -const as = await scrapeAnimestream({ - anilistId: ANILIST_ID, - episodeNumber: 1, - query: QUERY, -}); -console.log("scrapeAnimestream:", as.ok ? "OK" : as.error); // --- animepahe --- console.log("\n=== ANIMEPAHE ==="); diff --git a/scripts/nyumatflix.ts b/scripts/nyumatflix.ts index bbc5f7f4..4137da8a 100755 --- a/scripts/nyumatflix.ts +++ b/scripts/nyumatflix.ts @@ -355,7 +355,8 @@ const cmdList = async () => { console.log(""); }; -const cmdDeploy = async () => { +const cmdDeploy = async (options: { fast?: boolean } = {}) => { + const fast = options.fast === true; printHeader(); const meta = gitMeta(); const deploy = resolveDeployIdentity(meta); @@ -379,10 +380,19 @@ const cmdDeploy = async () => { `${c.yellow} local changes will ship · image ${DOCKER_REPO}:${deploy.sha}${c.reset}`, ); } + if (fast) { + console.log( + `${c.cyan}fast deploy${c.reset} ${c.dim}(skip movi/wasm rebuild when artifacts exist)${c.reset}`, + ); + } console.log(""); console.log(`${c.bold}steps${c.reset}`); - for (const step of ["build & push", "sync prod", "roll container"]) { + for (const step of [ + fast ? "build & push (fast)" : "build & push", + "sync prod", + "roll container", + ]) { console.log(` ${c.dim}○${c.reset} ${step}`); } console.log(""); @@ -394,7 +404,7 @@ const cmdDeploy = async () => { } console.log(""); - const env = { + const env: Record<string, string> = { DEPLOY_SHA: deploy.sha, DEPLOY_SHORT_SHA: deploy.shortSha, DEPLOY_MESSAGE: deploy.message, @@ -402,6 +412,10 @@ const cmdDeploy = async () => { DEPLOY_SOURCE: deploy.source, DOCKER_IMAGE: `${DOCKER_REPO}:${deploy.sha}`, }; + if (fast) { + env.SKIP_PLAYER_BUILD = "1"; + env.SKIP_SCRAPE_STACK = "1"; + } await runStep("build & push", async () => { const result = runLocal(`${ROOT}/scripts/deploy.sh`, ["bp"], env); @@ -535,6 +549,7 @@ const printHelp = () => { ${c.bold}nyumatflix deploy${c.reset} ${c.cyan}bun run deploy${c.reset} ship HEAD to ${SSH_HOST} + ${c.cyan}bun run deploy:fast${c.reset} ship without movi/wasm rebuild ${c.cyan}bun run deploy status${c.reset} live deploy + service health ${c.cyan}bun run deploy ls${c.reset} recent deployments ${c.cyan}bun run deploy rollback${c.reset} pick a previous deploy @@ -554,6 +569,9 @@ const main = async () => { case "deploy": await cmdDeploy(); break; + case "fast": + await cmdDeploy({ fast: true }); + break; case "status": await cmdStatus(); break; diff --git a/scripts/probe-flake-vs-real.mts b/scripts/probe-flake-vs-real.mts index 42487297..808a6a19 100644 --- a/scripts/probe-flake-vs-real.mts +++ b/scripts/probe-flake-vs-real.mts @@ -95,7 +95,6 @@ const TMDB_PROVIDERS = TMDB_SCRAPE_PROVIDER_ORDER; const ANIME_PROVIDERS = [ "kyren", "allmanga", - "animestream", "animepahe", "justanime", ] as const; diff --git a/scripts/prod-env-managed-keys.txt b/scripts/prod-env-managed-keys.txt index 0d87987c..2d4d52bf 100644 --- a/scripts/prod-env-managed-keys.txt +++ b/scripts/prod-env-managed-keys.txt @@ -15,6 +15,5 @@ FLIPT_URL FLIPT_ENVIRONMENT FLIPT_NAMESPACE FLIPT_API_TOKEN -FLIPT_FLAG_CACHE_TTL_MS MAL_CLIENT_ID MAL_CLIENT_SECRET diff --git a/scripts/reconcile-prod-infra.sh b/scripts/reconcile-prod-infra.sh index 4a3894a3..a3bd3807 100755 --- a/scripts/reconcile-prod-infra.sh +++ b/scripts/reconcile-prod-infra.sh @@ -170,10 +170,25 @@ scrape_compose() { } flipt_compose() { - sudo docker compose --env-file "$APP_ENV_FILE" \ + sudo env "FLIPT_VOLUME_NAME=${FLIPT_VOLUME_NAME:-nyumatflix_flipt-data}" \ + docker compose --env-file "$APP_ENV_FILE" \ -p "$FLIPT_PROJECT" -f "$FLIPT_COMPOSE_FILE" "$@" } +mounted_flipt_volume() { + sudo docker inspect flipt --format '{{range .Mounts}}{{if eq .Destination "/var/opt/flipt"}}{{.Name}}{{end}}{{end}}' 2>/dev/null || true +} + +ensure_flipt_volume_name() { + local mounted + mounted="$(mounted_flipt_volume)" + if [[ -n "$mounted" ]]; then + export FLIPT_VOLUME_NAME="$mounted" + return + fi + export FLIPT_VOLUME_NAME="${FLIPT_VOLUME_NAME:-nyumatflix_flipt-data}" +} + imgproxy_compose() { sudo docker compose -p "$IMGPROXY_PROJECT" -f "$IMGPROXY_COMPOSE_FILE" "$@" } @@ -267,6 +282,7 @@ reconcile() { acquire_lock ensure_env_files sudo docker network create "$DOCKER_NETWORK" 2>/dev/null || true + ensure_flipt_volume_name validate_compose reconcile_container_owner gluetun gluetun reconcile_container_owner flaresolverr flaresolverr diff --git a/scripts/test-direct-playback-browser.mjs b/scripts/test-direct-playback-browser.mjs index d5130dd4..e6dc8c4b 100644 --- a/scripts/test-direct-playback-browser.mjs +++ b/scripts/test-direct-playback-browser.mjs @@ -5,6 +5,20 @@ * Usage: * node scripts/test-direct-playback-browser.mjs nyumatflix * node scripts/test-direct-playback-browser.mjs calluspirates + * node scripts/test-direct-playback-browser.mjs nyumatflix movi + * + * movi mode sets nyumat:playerEngine=movi and asserts + * movi-player mounts with native presentation on HLS (no full-size canvas copy). + * + * Manual parity checklist (Vidstack vs Movi harness, same title): + * - Scrape HLS: start, seek, quality, subs, offset, intro skip + * - Direct MP4: fast start + * - Direct light MKV: canvas decode, audio/sub tracks + * - Direct dual-audio MKV: stays on canvas + * - Direct heavy remux: transcode HLS on native + * - Live TV: play, channel switch, Cast + * - Chrome / Safari / Firefox / iOS Safari + * - PiP, fullscreen, Cast (where supported) */ import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; @@ -32,6 +46,7 @@ if (!playwrightPath) { const { chromium } = require(playwrightPath); const app = process.argv[2] ?? "nyumatflix"; +const moviPreviewMode = process.argv[3] === "movi"; const NYUMAT_TITLES = [ ["Shrek", 808], @@ -50,8 +65,48 @@ const CUP_TITLES = [ ["Matrix", 603], ]; -async function videoState(page) { +async function moviHarnessState(page) { return page.evaluate(() => { + const movi = document.querySelector("movi-player"); + if (!movi) { + return { + hasMovi: false, + presentation: null, + hasFullSizeCanvas: false, + hasVideo: false, + }; + } + + const shadow = movi.shadowRoot; + const video = shadow?.querySelector("video") ?? document.querySelector("video"); + const canvas = shadow?.querySelector("canvas"); + const rect = canvas?.getBoundingClientRect(); + const hostRect = movi.getBoundingClientRect(); + const hasFullSizeCanvas = Boolean( + canvas && + hostRect.width > 0 && + hostRect.height > 0 && + rect && + rect.width >= hostRect.width * 0.9 && + rect.height >= hostRect.height * 0.9, + ); + + const presentation = + typeof movi.getPresentationMode === "function" + ? movi.getPresentationMode() + : movi.getAttribute("presentation"); + + return { + hasMovi: true, + presentation, + hasFullSizeCanvas, + hasVideo: video instanceof HTMLVideoElement, + }; + }); +} + +async function videoState(page) { + const base = await page.evaluate(() => { const video = document.querySelector("video"); const movi = document.querySelector("movi-player"); const failed = @@ -78,6 +133,13 @@ async function videoState(page) { duration: video.duration, }; }); + + if (!moviPreviewMode) { + return base; + } + + const harness = await moviHarnessState(page); + return { ...base, harness }; } async function seekVideo(page, seconds) { @@ -139,10 +201,18 @@ async function testNyumatFlix(page, name, tmdbId) { state = await videoState(page); } + if (moviPreviewMode && !state.harness?.hasMovi) { + return { name, tmdbId, ok: false, phase: "movi-missing", state }; + } + if (state.failed || !state.hasVideo) { return { name, tmdbId, ok: false, phase: "load", state }; } + if (moviPreviewMode && state.harness?.hasFullSizeCanvas) { + return { name, tmdbId, ok: false, phase: "canvas-copy-loop", state }; + } + if (state.paused) { await page.evaluate(() => { const video = document.querySelector("video"); @@ -266,13 +336,21 @@ async function testCalluspirates(page, name, tmdbId) { } const browser = await chromium.launch({ headless: true }); -const page = await browser.newPage(); +const context = await browser.newContext(); +if (moviPreviewMode) { + await context.addInitScript(() => { + localStorage.setItem("nyumat:playerEngine", "movi"); + }); +} +const page = await context.newPage(); const titles = app === "calluspirates" ? CUP_TITLES : NYUMAT_TITLES; const tester = app === "calluspirates" ? testCalluspirates : testNyumatFlix; let passed = 0; -console.log(`Direct browser playback test (${app})\n`); +console.log( + `Direct browser playback test (${app}${moviPreviewMode ? ", movi" : ""})\n`, +); for (const [name, tmdbId] of titles) { const result = await tester(page, name, tmdbId); diff --git a/scripts/verify-anime-scrape-direct.mts b/scripts/verify-anime-scrape-direct.mts index f923472b..d20629f6 100644 --- a/scripts/verify-anime-scrape-direct.mts +++ b/scripts/verify-anime-scrape-direct.mts @@ -61,11 +61,6 @@ const CASES: Array<{ providerId: "allmanga", input: { anilistId: 21, episodeNumber: 1168 }, }, - { - label: "AnimeStream / One Piece ep1", - providerId: "animestream", - input: { anilistId: 21, episodeNumber: 1, query: "One Piece" }, - }, { label: "AnimeGG / Naruto ep1", providerId: "animegg", diff --git a/scripts/verify-anime-scrape.mjs b/scripts/verify-anime-scrape.mjs index afbd449c..a1270551 100644 --- a/scripts/verify-anime-scrape.mjs +++ b/scripts/verify-anime-scrape.mjs @@ -19,15 +19,6 @@ const CASES = [ label: "One Piece ep1 / AllManga", body: { providerId: "allmanga", anilistId: 21, episodeNumber: 1 }, }, - { - label: "One Piece ep1 / AnimeStream", - body: { - providerId: "animestream", - anilistId: 21, - episodeNumber: 1, - query: "One Piece", - }, - }, { label: "Naruto ep1 / AnimeGG", body: { diff --git a/scripts/verify-jjk-parity.mts b/scripts/verify-jjk-parity.mts index 00281ddc..29fe59d7 100644 --- a/scripts/verify-jjk-parity.mts +++ b/scripts/verify-jjk-parity.mts @@ -18,7 +18,6 @@ const PROVIDERS: AnimeScrapeProviderId[] = [ "kickassanime", "animeonsen", "allmanga", - "animestream", "animegg", "animepahe", ]; diff --git a/scripts/verify-proxy-medium-matrix.mts b/scripts/verify-proxy-medium-matrix.mts index 8a3325a6..21d180e0 100644 --- a/scripts/verify-proxy-medium-matrix.mts +++ b/scripts/verify-proxy-medium-matrix.mts @@ -132,9 +132,6 @@ const { scrapeXPass } = await import("../lib/scrape/providers/xpass.ts"); const { scrapeVixsrc } = await import("../lib/scrape/providers/vixsrc.ts"); const { scrapeVidrock } = await import("../lib/scrape/providers/vidrock.ts"); const { scrapeBingr } = await import("../lib/scrape/providers/bingr.ts"); -const { scrapeAnimestream } = await import( - "../lib/scrape/anime/providers/animestream.ts" -); const { scrapeAnimegg } = await import( "../lib/scrape/anime/providers/animegg.ts" ); @@ -189,7 +186,6 @@ const ANIME_SCRAPERS = { animegg: scrapeAnimegg, kickassanime: scrapeKickassanime, anizone: scrapeAnizone, - animestream: scrapeAnimestream, animepahe: scrapeAnimepahe, anipm: scrapeAnipm, hentaigasm: scrapeHentaigasm,