Upload 45 files
Browse files- .dockerignore +9 -0
- .env +48 -0
- .gitignore +17 -65
- Dockerfile +23 -14
- app/api/cinesu/route.ts +75 -0
- app/api/dseg/[...slug]/route.ts +54 -0
- app/api/introdb/route.ts +44 -0
- app/api/sources/list/route.ts +14 -0
- app/api/sources/route.ts +96 -0
- app/api/stream/route.ts +159 -0
- app/api/sub/route.ts +117 -0
- app/api/tmdb/route.ts +47 -0
- app/globals.css +224 -0
- app/layout.tsx +34 -0
- app/movie/[id]/page.tsx +40 -0
- app/not-found.tsx +15 -0
- app/tv/[id]/[season]/[episode]/page.tsx +46 -0
- components/player/PlayerScreen.tsx +95 -0
- components/player/VideoPlayer.tsx +2598 -0
- components/player/icons.tsx +246 -0
- docker-compose.yml +10 -0
- lib/config.ts +18 -0
- lib/constants.ts +9 -0
- lib/scraper/castle.ts +480 -0
- lib/scraper/cinesu.ts +276 -0
- lib/scraper/flixhq.ts +692 -0
- lib/scraper/index.ts +80 -0
- lib/scraper/insertunit.ts +216 -0
- lib/scraper/oneroom.ts +151 -0
- lib/scraper/types.ts +76 -0
- lib/scraper/vidnest.ts +230 -0
- lib/scraper/vidrock.ts +179 -0
- lib/scraper/vixsrc.ts +124 -0
- lib/security/tokens.ts +75 -0
- lib/subtitles/flags.ts +32 -0
- lib/subtitles/opensubtitles.ts +88 -0
- lib/subtitles/vdrk.ts +83 -0
- lib/tmdb.ts +146 -0
- next-env.d.ts +6 -0
- next.config.mjs +11 -0
- package.json +30 -0
- postcss.config.mjs +9 -0
- public/logo.svg +3 -0
- tailwind.config.ts +59 -0
- tsconfig.json +43 -0
.dockerignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
.next
|
| 3 |
+
.git
|
| 4 |
+
.env
|
| 5 |
+
.env.local
|
| 6 |
+
*.log
|
| 7 |
+
Dockerfile
|
| 8 |
+
docker-compose.yml
|
| 9 |
+
README.md
|
.env
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 2 |
+
# Copy this file to ".env" and fill it in: cp .env.example .env
|
| 3 |
+
# Only TMDB_API_KEY and APP_SECRET are required. Everything else is optional.
|
| 4 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 5 |
+
|
| 6 |
+
# REQUIRED — your own TMDB (The Movie Database) API key.
|
| 7 |
+
# Get one free at https://www.themoviedb.org/settings/api (v3 auth "API Key")
|
| 8 |
+
TMDB_API_KEY=1c9a2f152e729aecfec1c47b46aa9607
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# REQUIRED — a long random secret used to sign the internal stream-proxy URLs
|
| 12 |
+
# so your server can't be abused as an open proxy. Generate one with:
|
| 13 |
+
# openssl rand -hex 32
|
| 14 |
+
APP_SECRET=
|
| 15 |
+
|
| 16 |
+
# Port the server listens on (default 3000).
|
| 17 |
+
PORT=3000
|
| 18 |
+
|
| 19 |
+
# ── Branding (safe to expose to the browser) ────────────────────────────────
|
| 20 |
+
# Visible player / brand name (window title + metadata).
|
| 21 |
+
PLAYER_NAME=Vidsuper
|
| 22 |
+
# Short tagline shown alongside the name in the browser tab.
|
| 23 |
+
PLAYER_TAGLINE=Video streaming player
|
| 24 |
+
# Default accent colour (hex, with or without #). Override per-embed with ?color=
|
| 25 |
+
ACCENT_COLOR=ef4444
|
| 26 |
+
# Logo path served from /public. To rebrand, replace public/logo.svg (keep the
|
| 27 |
+
# name) OR drop a new file in public/ and point this at it, e.g. /brand.png
|
| 28 |
+
LOGO_SRC=/logo.svg
|
| 29 |
+
|
| 30 |
+
# ── Source (server) display names — optional rename per source ───────────────
|
| 31 |
+
# The player shows these in its "Server" menu. Uppercase the source id.
|
| 32 |
+
# SOURCE_ONEROOM_NAME=Server 1
|
| 33 |
+
# SOURCE_INSERTUNIT_NAME=Server 2
|
| 34 |
+
# SOURCE_FLIXHQ_NAME=Server 3
|
| 35 |
+
# SOURCE_CASTLE_NAME=Server 4
|
| 36 |
+
# SOURCE_CINESU_NAME=Server 5
|
| 37 |
+
# SOURCE_VIDNEST_NAME=Server 6
|
| 38 |
+
# SOURCE_VIDROCK_NAME=Server 7
|
| 39 |
+
# SOURCE_VIXSRC_NAME=Server 8
|
| 40 |
+
|
| 41 |
+
# ── Subtitles — set to "false" to disable a provider (default: enabled) ──────
|
| 42 |
+
SUBTITLES_OPENSUBTITLES=true
|
| 43 |
+
SUBTITLES_VDRK=true
|
| 44 |
+
|
| 45 |
+
# ── Skip intro/credits ───────────────────────────────────────────────────────
|
| 46 |
+
# Skip-intro works out of the box via theintrodb.org (no key needed). This is
|
| 47 |
+
# only here in case you have a key you want forwarded; leave it blank otherwise.
|
| 48 |
+
INTRODB_API_KEY=
|
.gitignore
CHANGED
|
@@ -1,72 +1,24 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
.
|
| 5 |
-
venv/
|
| 6 |
-
__pycache__/
|
| 7 |
-
.mypy_cache/
|
| 8 |
-
.pytest_cache/
|
| 9 |
-
target/
|
| 10 |
-
.gradle/
|
| 11 |
|
| 12 |
-
#
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
*.class
|
| 17 |
-
*.o
|
| 18 |
-
*.exe
|
| 19 |
-
*.dll
|
| 20 |
-
*.so
|
| 21 |
-
*.a
|
| 22 |
-
*.obj
|
| 23 |
-
*.out
|
| 24 |
|
| 25 |
-
#
|
|
|
|
|
|
|
|
|
|
| 26 |
.env
|
| 27 |
.env.local
|
| 28 |
-
|
| 29 |
|
| 30 |
-
#
|
| 31 |
-
.vscode/
|
| 32 |
-
.idea/
|
| 33 |
-
*.swp
|
| 34 |
-
*.swo
|
| 35 |
-
*.tmp
|
| 36 |
-
|
| 37 |
-
# System files
|
| 38 |
.DS_Store
|
| 39 |
-
Thumbs.db
|
| 40 |
-
|
| 41 |
-
# Logs
|
| 42 |
*.log
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
htmlcov/
|
| 47 |
-
.coverage
|
| 48 |
-
|
| 49 |
-
# Compressed files
|
| 50 |
-
*.zip
|
| 51 |
-
*.gz
|
| 52 |
-
*.tar
|
| 53 |
-
*.tgz
|
| 54 |
-
*.bz2
|
| 55 |
-
*.xz
|
| 56 |
-
*.7z
|
| 57 |
-
*.rar
|
| 58 |
-
*.zst
|
| 59 |
-
*.lz4
|
| 60 |
-
*.lzh
|
| 61 |
-
*.cab
|
| 62 |
-
*.arj
|
| 63 |
-
*.rpm
|
| 64 |
-
*.deb
|
| 65 |
-
*.Z
|
| 66 |
-
*.lz
|
| 67 |
-
*.lzo
|
| 68 |
-
*.tar.gz
|
| 69 |
-
*.tar.bz2
|
| 70 |
-
*.tar.xz
|
| 71 |
-
*.tar.zst
|
| 72 |
-
```
|
|
|
|
| 1 |
+
# dependencies
|
| 2 |
+
/node_modules
|
| 3 |
+
/.pnp
|
| 4 |
+
.pnp.js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
+
# next.js
|
| 7 |
+
/.next/
|
| 8 |
+
/out/
|
| 9 |
+
next-env.d.ts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
# production
|
| 12 |
+
/build
|
| 13 |
+
|
| 14 |
+
# env — never commit real secrets
|
| 15 |
.env
|
| 16 |
.env.local
|
| 17 |
+
.env*.local
|
| 18 |
|
| 19 |
+
# misc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
.DS_Store
|
|
|
|
|
|
|
|
|
|
| 21 |
*.log
|
| 22 |
+
npm-debug.log*
|
| 23 |
+
.vscode/
|
| 24 |
+
.idea/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Dockerfile
CHANGED
|
@@ -1,17 +1,26 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
WORKDIR /app
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
#
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
# Expose the default FastAPI port
|
| 13 |
-
EXPOSE 8000
|
| 14 |
|
| 15 |
-
#
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ---- deps ----
|
| 2 |
+
FROM node:20-alpine AS deps
|
| 3 |
WORKDIR /app
|
| 4 |
+
COPY package.json package-lock.json* ./
|
| 5 |
+
RUN npm ci 2>/dev/null || npm install
|
| 6 |
|
| 7 |
+
# ---- build ----
|
| 8 |
+
FROM node:20-alpine AS builder
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 11 |
+
COPY . .
|
| 12 |
+
# Branding + keys are read at RUNTIME (not baked in), so no build args needed.
|
| 13 |
+
RUN npm run build
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
# ---- run ----
|
| 16 |
+
FROM node:20-alpine AS runner
|
| 17 |
+
WORKDIR /app
|
| 18 |
+
ENV NODE_ENV=production
|
| 19 |
+
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
|
| 20 |
+
COPY --from=builder /app/public ./public
|
| 21 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
| 22 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
| 23 |
+
USER nextjs
|
| 24 |
+
EXPOSE 3000
|
| 25 |
+
ENV PORT=3000 HOSTNAME=0.0.0.0
|
| 26 |
+
CMD ["node", "server.js"]
|
app/api/cinesu/route.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import {
|
| 3 |
+
mintStreamToken,
|
| 4 |
+
verify,
|
| 5 |
+
StreamClaim,
|
| 6 |
+
} from "@/lib/security/tokens";
|
| 7 |
+
import {
|
| 8 |
+
cineSuVariants,
|
| 9 |
+
buildVariantM3u8,
|
| 10 |
+
stripPngWrapper,
|
| 11 |
+
} from "@/lib/scraper/cinesu";
|
| 12 |
+
|
| 13 |
+
export const runtime = "nodejs";
|
| 14 |
+
export const dynamic = "force-dynamic";
|
| 15 |
+
|
| 16 |
+
const UA =
|
| 17 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36";
|
| 18 |
+
|
| 19 |
+
// Two modes:
|
| 20 |
+
// ?seg=<token> → de-wrap a PNG-polyglot segment into raw MPEG-TS
|
| 21 |
+
// ?m&t&s&e&v=<variant> → build the variant's M3U8 (segments routed back here)
|
| 22 |
+
export async function GET(req: NextRequest) {
|
| 23 |
+
const { searchParams } = new URL(req.url);
|
| 24 |
+
const origin = new URL(req.url).origin;
|
| 25 |
+
|
| 26 |
+
// ---- segment mode: fetch the PNG-wrapped .ts and strip the wrapper ----
|
| 27 |
+
const segToken = searchParams.get("seg");
|
| 28 |
+
if (segToken) {
|
| 29 |
+
const claim = verify<StreamClaim>(segToken);
|
| 30 |
+
if (!claim || claim.k !== "stream") {
|
| 31 |
+
return new NextResponse("forbidden", { status: 403 });
|
| 32 |
+
}
|
| 33 |
+
try {
|
| 34 |
+
const r = await fetch(claim.url, {
|
| 35 |
+
headers: { "User-Agent": UA, Referer: "https://cine.su/" },
|
| 36 |
+
cache: "no-store",
|
| 37 |
+
});
|
| 38 |
+
if (!r.ok) return new NextResponse("not found", { status: 404 });
|
| 39 |
+
const ts = stripPngWrapper(Buffer.from(await r.arrayBuffer()));
|
| 40 |
+
return new NextResponse(new Uint8Array(ts), {
|
| 41 |
+
headers: {
|
| 42 |
+
"Content-Type": "video/mp2t",
|
| 43 |
+
"Cache-Control": "public, max-age=86400",
|
| 44 |
+
"Access-Control-Allow-Origin": "*",
|
| 45 |
+
},
|
| 46 |
+
});
|
| 47 |
+
} catch {
|
| 48 |
+
return new NextResponse("bad gateway", { status: 502 });
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// ---- playlist mode ----
|
| 53 |
+
const isTv = searchParams.get("m") === "tv";
|
| 54 |
+
const t = parseInt(searchParams.get("t") || "0", 10);
|
| 55 |
+
const s = isTv ? parseInt(searchParams.get("s") || "0", 10) : 0;
|
| 56 |
+
const e = isTv ? parseInt(searchParams.get("e") || "0", 10) : 0;
|
| 57 |
+
const v = parseInt(searchParams.get("v") || "0", 10);
|
| 58 |
+
if (!t) return new NextResponse("bad request", { status: 400 });
|
| 59 |
+
|
| 60 |
+
const variants = await cineSuVariants(t, s, e).catch(() => []);
|
| 61 |
+
const variant = variants[v];
|
| 62 |
+
if (!variant) return new NextResponse("not found", { status: 404 });
|
| 63 |
+
|
| 64 |
+
const proxySeg = (url: string) =>
|
| 65 |
+
`${origin}/api/cinesu?seg=${encodeURIComponent(mintStreamToken(url))}`;
|
| 66 |
+
const m3u8 = buildVariantM3u8(variant, proxySeg);
|
| 67 |
+
|
| 68 |
+
return new NextResponse(m3u8, {
|
| 69 |
+
headers: {
|
| 70 |
+
"Content-Type": "application/vnd.apple.mpegurl",
|
| 71 |
+
"Cache-Control": "no-store",
|
| 72 |
+
"Access-Control-Allow-Origin": "*",
|
| 73 |
+
},
|
| 74 |
+
});
|
| 75 |
+
}
|
app/api/dseg/[...slug]/route.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { verify, DashBaseClaim } from "@/lib/security/tokens";
|
| 3 |
+
|
| 4 |
+
export const runtime = "nodejs";
|
| 5 |
+
export const dynamic = "force-dynamic";
|
| 6 |
+
|
| 7 |
+
const UA =
|
| 8 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
| 9 |
+
|
| 10 |
+
// DASH segment proxy. The .mpd's <BaseURL> is rewritten to
|
| 11 |
+
// /api/dseg/<base-token>/ so dash.js appends the segment path here; we
|
| 12 |
+
// reconstruct the real URL (real CDN host + referer) and stream the bytes.
|
| 13 |
+
export async function GET(
|
| 14 |
+
req: NextRequest,
|
| 15 |
+
{ params }: { params: Promise<{ slug: string[] }> }
|
| 16 |
+
) {
|
| 17 |
+
const { slug } = await params;
|
| 18 |
+
const [token, ...rest] = slug;
|
| 19 |
+
const claim = verify<DashBaseClaim>(token);
|
| 20 |
+
if (!claim || claim.k !== "dbase") {
|
| 21 |
+
return new NextResponse("forbidden", { status: 403 });
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const search = new URL(req.url).search; // preserves segment query/tokens
|
| 25 |
+
const target = claim.url + rest.join("/") + search;
|
| 26 |
+
|
| 27 |
+
const headers: Record<string, string> = { "User-Agent": UA };
|
| 28 |
+
if (claim.ref) {
|
| 29 |
+
headers["Referer"] = claim.ref;
|
| 30 |
+
headers["Origin"] = new URL(claim.ref).origin;
|
| 31 |
+
}
|
| 32 |
+
const range = req.headers.get("range");
|
| 33 |
+
if (range) headers["Range"] = range;
|
| 34 |
+
|
| 35 |
+
let up: Response;
|
| 36 |
+
try {
|
| 37 |
+
up = await fetch(target, { headers, redirect: "follow", cache: "no-store" });
|
| 38 |
+
} catch {
|
| 39 |
+
return new NextResponse("bad gateway", { status: 502 });
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const out = new Headers();
|
| 43 |
+
for (const h of [
|
| 44 |
+
"content-type",
|
| 45 |
+
"content-length",
|
| 46 |
+
"accept-ranges",
|
| 47 |
+
"content-range",
|
| 48 |
+
]) {
|
| 49 |
+
const v = up.headers.get(h);
|
| 50 |
+
if (v) out.set(h, v);
|
| 51 |
+
}
|
| 52 |
+
out.set("Cache-Control", "no-store");
|
| 53 |
+
return new NextResponse(up.body, { status: up.status, headers: out });
|
| 54 |
+
}
|
app/api/introdb/route.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
export const runtime = "nodejs";
|
| 4 |
+
export const dynamic = "force-dynamic";
|
| 5 |
+
|
| 6 |
+
// Skip intro / credits timestamps via theintrodb.org. No API key required —
|
| 7 |
+
// works out of the box. An optional INTRODB_API_KEY is forwarded if you set
|
| 8 |
+
// one, but it isn't needed. The player only requests this when a title is
|
| 9 |
+
// played with ?skip_intro=true.
|
| 10 |
+
const INTRODB_KEY = process.env.INTRODB_API_KEY || "";
|
| 11 |
+
|
| 12 |
+
export async function GET(req: NextRequest) {
|
| 13 |
+
const { searchParams } = new URL(req.url);
|
| 14 |
+
const id = searchParams.get("id") || "";
|
| 15 |
+
const season = searchParams.get("season") || "";
|
| 16 |
+
const episode = searchParams.get("episode") || "";
|
| 17 |
+
const durationMs = searchParams.get("duration_ms") || "";
|
| 18 |
+
if (!id) return NextResponse.json({ error: "bad request" }, { status: 400 });
|
| 19 |
+
|
| 20 |
+
const qs = new URLSearchParams({ tmdb_id: id });
|
| 21 |
+
if (season) qs.set("season", season);
|
| 22 |
+
if (episode) qs.set("episode", episode);
|
| 23 |
+
if (durationMs) qs.set("duration_ms", durationMs);
|
| 24 |
+
|
| 25 |
+
try {
|
| 26 |
+
const headers: Record<string, string> = {};
|
| 27 |
+
if (INTRODB_KEY) {
|
| 28 |
+
headers["Authorization"] = INTRODB_KEY;
|
| 29 |
+
headers["X-API-Key"] = INTRODB_KEY;
|
| 30 |
+
}
|
| 31 |
+
const res = await fetch(`https://api.theintrodb.org/v3/media?${qs.toString()}`, {
|
| 32 |
+
headers,
|
| 33 |
+
cache: "no-store",
|
| 34 |
+
});
|
| 35 |
+
if (!res.ok) return NextResponse.json({ intro: [], recap: [], credits: [] });
|
| 36 |
+
const data = await res.json();
|
| 37 |
+
return NextResponse.json(
|
| 38 |
+
{ intro: data.intro || [], recap: data.recap || [], credits: data.credits || [] },
|
| 39 |
+
{ headers: { "Cache-Control": "no-store" } }
|
| 40 |
+
);
|
| 41 |
+
} catch {
|
| 42 |
+
return NextResponse.json({ intro: [], recap: [], credits: [] });
|
| 43 |
+
}
|
| 44 |
+
}
|
app/api/sources/list/route.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
import { sourceList } from "@/lib/scraper";
|
| 3 |
+
|
| 4 |
+
export const runtime = "nodejs";
|
| 5 |
+
export const dynamic = "force-dynamic";
|
| 6 |
+
|
| 7 |
+
// Active source metadata (id/name/label) so the player builds its server list
|
| 8 |
+
// from whatever's registered in lib/scraper.
|
| 9 |
+
export async function GET() {
|
| 10 |
+
return NextResponse.json(
|
| 11 |
+
{ sources: sourceList() },
|
| 12 |
+
{ headers: { "Cache-Control": "no-store" } }
|
| 13 |
+
);
|
| 14 |
+
}
|
app/api/sources/route.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { getSource } from "@/lib/scraper";
|
| 3 |
+
import type { SourceContext } from "@/lib/scraper/types";
|
| 4 |
+
import { mintStreamToken } from "@/lib/security/tokens";
|
| 5 |
+
import { fetchOpenSubtitles } from "@/lib/subtitles/opensubtitles";
|
| 6 |
+
import { fetchVdrkSubs } from "@/lib/subtitles/vdrk";
|
| 7 |
+
|
| 8 |
+
// Subtitle sources are toggled via env (all enabled by default). Set
|
| 9 |
+
// SUBTITLES_OPENSUBTITLES / SUBTITLES_VDRK to "false" (or "0"/"off"/"no").
|
| 10 |
+
function subEnabled(name: string): boolean {
|
| 11 |
+
const v = (process.env[name] || "").toLowerCase().trim();
|
| 12 |
+
return v !== "false" && v !== "0" && v !== "off" && v !== "no";
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export const runtime = "nodejs";
|
| 16 |
+
export const dynamic = "force-dynamic";
|
| 17 |
+
|
| 18 |
+
// Resolves playable sources for a title. Upstream stream + subtitle URLs are
|
| 19 |
+
// wrapped in signed /api/stream + /api/sub proxy links so every byte flows
|
| 20 |
+
// through this server (never a third-party CDN or worker), and the real URLs
|
| 21 |
+
// stay server-side.
|
| 22 |
+
export async function GET(req: NextRequest) {
|
| 23 |
+
const { searchParams } = new URL(req.url);
|
| 24 |
+
const type = searchParams.get("type") || "";
|
| 25 |
+
const id = searchParams.get("id") || "";
|
| 26 |
+
const season = parseInt(searchParams.get("season") || "0", 10) || 0;
|
| 27 |
+
const episode = parseInt(searchParams.get("episode") || "0", 10) || 0;
|
| 28 |
+
const server = searchParams.get("server") || "";
|
| 29 |
+
const tmdbId = parseInt(id, 10);
|
| 30 |
+
if (!tmdbId) return NextResponse.json({ error: "bad id" }, { status: 400 });
|
| 31 |
+
|
| 32 |
+
const origin = new URL(req.url).origin;
|
| 33 |
+
const proxied = (url: string, ref?: string) =>
|
| 34 |
+
`${origin}/api/stream?t=${encodeURIComponent(mintStreamToken(url, ref))}`;
|
| 35 |
+
const proxiedSub = (url: string, ref?: string) =>
|
| 36 |
+
`${origin}/api/sub?t=${encodeURIComponent(mintStreamToken(url, ref))}`;
|
| 37 |
+
|
| 38 |
+
// Resolve the requested source from the registry (drop a file in lib/scraper
|
| 39 |
+
// to add a new one). Falls back to the first registered source.
|
| 40 |
+
const source = getSource(server);
|
| 41 |
+
if (!source) {
|
| 42 |
+
return NextResponse.json({ error: "bad server" }, { status: 400 });
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const isEnglish = (lang?: string) =>
|
| 46 |
+
(lang || "").toLowerCase().startsWith("en");
|
| 47 |
+
const openSubtitlesOn = subEnabled("SUBTITLES_OPENSUBTITLES");
|
| 48 |
+
const vdrkOn = subEnabled("SUBTITLES_VDRK");
|
| 49 |
+
const subType = type === "tv" ? "tv" : "movie";
|
| 50 |
+
const [osRaw, vdrkRaw] = await Promise.all([
|
| 51 |
+
openSubtitlesOn
|
| 52 |
+
? fetchOpenSubtitles(tmdbId, season, episode).catch(() => [])
|
| 53 |
+
: [],
|
| 54 |
+
vdrkOn ? fetchVdrkSubs(subType, tmdbId, season, episode).catch(() => []) : [],
|
| 55 |
+
]);
|
| 56 |
+
// English first, then the rest — tracks proxied for same-origin WebVTT.
|
| 57 |
+
const openSubs = osRaw
|
| 58 |
+
.sort((a, b) => Number(isEnglish(b.language)) - Number(isEnglish(a.language)))
|
| 59 |
+
.map((s) => ({
|
| 60 |
+
url: proxiedSub(s.url),
|
| 61 |
+
display: s.display,
|
| 62 |
+
language: s.language,
|
| 63 |
+
source: "OpenSubtitles",
|
| 64 |
+
}));
|
| 65 |
+
const vdrkSubs = vdrkRaw
|
| 66 |
+
.sort((a, b) => Number(isEnglish(b.language)) - Number(isEnglish(a.language)))
|
| 67 |
+
.map((s) => ({
|
| 68 |
+
url: proxiedSub(s.file),
|
| 69 |
+
display: s.label,
|
| 70 |
+
language: s.language,
|
| 71 |
+
source: "vdrk",
|
| 72 |
+
}));
|
| 73 |
+
|
| 74 |
+
const ctx: SourceContext = {
|
| 75 |
+
tmdbId,
|
| 76 |
+
season,
|
| 77 |
+
episode,
|
| 78 |
+
origin,
|
| 79 |
+
proxyStream: proxied,
|
| 80 |
+
proxySub: proxiedSub,
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const result = await source.fetch(ctx).catch(() => null);
|
| 84 |
+
if (!result || !result.streams.length) {
|
| 85 |
+
return NextResponse.json({ error: "no sources" }, { status: 404 });
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
return NextResponse.json(
|
| 89 |
+
{
|
| 90 |
+
sources: result.streams,
|
| 91 |
+
provider: source.name,
|
| 92 |
+
subtitles: [...openSubs, ...vdrkSubs, ...(result.subtitles || [])],
|
| 93 |
+
},
|
| 94 |
+
{ headers: { "Cache-Control": "no-store" } }
|
| 95 |
+
);
|
| 96 |
+
}
|
app/api/stream/route.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import {
|
| 3 |
+
verify,
|
| 4 |
+
mintStreamToken,
|
| 5 |
+
mintDashBase,
|
| 6 |
+
StreamClaim,
|
| 7 |
+
} from "@/lib/security/tokens";
|
| 8 |
+
|
| 9 |
+
export const runtime = "nodejs";
|
| 10 |
+
export const dynamic = "force-dynamic";
|
| 11 |
+
|
| 12 |
+
const UA =
|
| 13 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
| 14 |
+
|
| 15 |
+
// Rewrite every URI in an HLS manifest so child playlists, keys and segments
|
| 16 |
+
// also flow back through this proxy (the real hosts are never exposed).
|
| 17 |
+
function rewriteManifest(
|
| 18 |
+
text: string,
|
| 19 |
+
baseUrl: string,
|
| 20 |
+
origin: string,
|
| 21 |
+
ref?: string
|
| 22 |
+
): string {
|
| 23 |
+
const base = new URL(baseUrl);
|
| 24 |
+
const wrap = (u: string) => {
|
| 25 |
+
let abs: string;
|
| 26 |
+
try {
|
| 27 |
+
abs = new URL(u, base).toString();
|
| 28 |
+
} catch {
|
| 29 |
+
return u;
|
| 30 |
+
}
|
| 31 |
+
return `${origin}/api/stream?t=${encodeURIComponent(
|
| 32 |
+
mintStreamToken(abs, ref)
|
| 33 |
+
)}`;
|
| 34 |
+
};
|
| 35 |
+
return text
|
| 36 |
+
.split("\n")
|
| 37 |
+
.map((line) => {
|
| 38 |
+
const t = line.trim();
|
| 39 |
+
if (!t) return line;
|
| 40 |
+
if (t.startsWith("#")) {
|
| 41 |
+
// rewrite URI="..." attributes (EXT-X-KEY / MAP / MEDIA / I-FRAME)
|
| 42 |
+
return line.replace(/URI="([^"]+)"/g, (_m, u) => `URI="${wrap(u)}"`);
|
| 43 |
+
}
|
| 44 |
+
return wrap(t); // bare segment / sub-playlist line
|
| 45 |
+
})
|
| 46 |
+
.join("\n");
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// Rewrite a DASH .mpd so its <BaseURL> points at the segment proxy. The real
|
| 50 |
+
// host is BaseURL[0]; "x-bc.interkh.com" placeholder bases are dropped.
|
| 51 |
+
function rewriteMpd(
|
| 52 |
+
xml: string,
|
| 53 |
+
mpdUrl: string,
|
| 54 |
+
origin: string,
|
| 55 |
+
ref?: string
|
| 56 |
+
): string {
|
| 57 |
+
const bases = [...xml.matchAll(/<BaseURL>([^<]+)<\/BaseURL>/g)].map((m) =>
|
| 58 |
+
m[1].trim()
|
| 59 |
+
);
|
| 60 |
+
let real = bases.find((u) => !u.includes("x-bc")) || bases[0];
|
| 61 |
+
if (!real) {
|
| 62 |
+
// no explicit BaseURL — derive from the manifest directory
|
| 63 |
+
real = mpdUrl.split("?")[0].replace(/[^/]*$/, "");
|
| 64 |
+
} else {
|
| 65 |
+
real = new URL(real, mpdUrl).toString();
|
| 66 |
+
}
|
| 67 |
+
if (!real.endsWith("/")) real += "/";
|
| 68 |
+
|
| 69 |
+
const proxied = `${origin}/api/dseg/${mintDashBase(real, ref)}/`;
|
| 70 |
+
|
| 71 |
+
let first = true;
|
| 72 |
+
let out = xml.replace(/<BaseURL>[^<]*<\/BaseURL>/g, () =>
|
| 73 |
+
first ? ((first = false), `<BaseURL>${proxied}</BaseURL>`) : ""
|
| 74 |
+
);
|
| 75 |
+
// if the manifest had no BaseURL at all, inject one after <MPD ...>
|
| 76 |
+
if (first) {
|
| 77 |
+
out = out.replace(/(<MPD\b[^>]*>)/, `$1<BaseURL>${proxied}</BaseURL>`);
|
| 78 |
+
}
|
| 79 |
+
return out;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export async function GET(req: NextRequest) {
|
| 83 |
+
const { searchParams, origin } = new URL(req.url);
|
| 84 |
+
const claim = verify<StreamClaim>(searchParams.get("t"));
|
| 85 |
+
if (!claim || claim.k !== "stream") {
|
| 86 |
+
return new NextResponse("forbidden", { status: 403 });
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
const target = claim.url;
|
| 90 |
+
const upstreamHeaders: Record<string, string> = { "User-Agent": UA };
|
| 91 |
+
if (claim.ref) {
|
| 92 |
+
upstreamHeaders["Referer"] = claim.ref;
|
| 93 |
+
upstreamHeaders["Origin"] = new URL(claim.ref).origin;
|
| 94 |
+
}
|
| 95 |
+
const range = req.headers.get("range");
|
| 96 |
+
if (range) upstreamHeaders["Range"] = range;
|
| 97 |
+
|
| 98 |
+
let upstream: Response;
|
| 99 |
+
try {
|
| 100 |
+
upstream = await fetch(target, {
|
| 101 |
+
headers: upstreamHeaders,
|
| 102 |
+
redirect: "follow",
|
| 103 |
+
cache: "no-store", // never serve a stale manifest (segment tokens rotate)
|
| 104 |
+
});
|
| 105 |
+
} catch {
|
| 106 |
+
return new NextResponse("bad gateway", { status: 502 });
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const ct = (upstream.headers.get("content-type") || "").toLowerCase();
|
| 110 |
+
const path0 = target.split("?")[0].toLowerCase();
|
| 111 |
+
const isManifest = path0.endsWith(".m3u8") || ct.includes("mpegurl");
|
| 112 |
+
const isMpd = path0.endsWith(".mpd") || ct.includes("dash+xml");
|
| 113 |
+
|
| 114 |
+
if (isMpd) {
|
| 115 |
+
const xml = await upstream.text();
|
| 116 |
+
const finalUrl = (upstream as any).url || target;
|
| 117 |
+
const body = rewriteMpd(xml, finalUrl, origin, claim.ref);
|
| 118 |
+
return new NextResponse(body, {
|
| 119 |
+
status: upstream.status,
|
| 120 |
+
headers: {
|
| 121 |
+
"Content-Type": "application/dash+xml",
|
| 122 |
+
"Cache-Control": "no-store",
|
| 123 |
+
},
|
| 124 |
+
});
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
if (isManifest) {
|
| 128 |
+
const text = await upstream.text();
|
| 129 |
+
// upstream.url reflects any redirects, giving the correct base for relatives
|
| 130 |
+
const finalUrl = (upstream as any).url || target;
|
| 131 |
+
const body = rewriteManifest(text, finalUrl, origin, claim.ref);
|
| 132 |
+
return new NextResponse(body, {
|
| 133 |
+
status: upstream.status,
|
| 134 |
+
headers: {
|
| 135 |
+
"Content-Type": "application/vnd.apple.mpegurl",
|
| 136 |
+
"Cache-Control": "no-store",
|
| 137 |
+
},
|
| 138 |
+
});
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// Binary passthrough for segments / mp4 (preserve range/seek headers)
|
| 142 |
+
const headers = new Headers();
|
| 143 |
+
for (const h of [
|
| 144 |
+
"content-type",
|
| 145 |
+
"content-length",
|
| 146 |
+
"accept-ranges",
|
| 147 |
+
"content-range",
|
| 148 |
+
"cache-control",
|
| 149 |
+
]) {
|
| 150 |
+
const v = upstream.headers.get(h);
|
| 151 |
+
if (v) headers.set(h, v);
|
| 152 |
+
}
|
| 153 |
+
if (!headers.has("cache-control")) headers.set("Cache-Control", "no-store");
|
| 154 |
+
|
| 155 |
+
return new NextResponse(upstream.body, {
|
| 156 |
+
status: upstream.status,
|
| 157 |
+
headers,
|
| 158 |
+
});
|
| 159 |
+
}
|
app/api/sub/route.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import zlib from "zlib";
|
| 3 |
+
import { verify, StreamClaim } from "@/lib/security/tokens";
|
| 4 |
+
|
| 5 |
+
export const runtime = "nodejs";
|
| 6 |
+
export const dynamic = "force-dynamic";
|
| 7 |
+
|
| 8 |
+
const UA =
|
| 9 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
| 10 |
+
|
| 11 |
+
// In-memory cache of converted WebVTT, keyed by upstream URL. Lets a track that
|
| 12 |
+
// fetched successfully stay available without re-hitting rate-limited upstreams
|
| 13 |
+
// (notably OpenSubtitles' throttled free download endpoint).
|
| 14 |
+
const SUB_CACHE = new Map<string, { vtt: string; ts: number }>();
|
| 15 |
+
const SUB_CACHE_TTL = 6 * 60 * 60 * 1000; // 6h
|
| 16 |
+
const SUB_CACHE_MAX = 300;
|
| 17 |
+
|
| 18 |
+
function cacheGet(url: string): string | null {
|
| 19 |
+
const hit = SUB_CACHE.get(url);
|
| 20 |
+
if (!hit) return null;
|
| 21 |
+
if (Date.now() - hit.ts > SUB_CACHE_TTL) {
|
| 22 |
+
SUB_CACHE.delete(url);
|
| 23 |
+
return null;
|
| 24 |
+
}
|
| 25 |
+
return hit.vtt;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function cacheSet(url: string, vtt: string) {
|
| 29 |
+
if (SUB_CACHE.size >= SUB_CACHE_MAX) {
|
| 30 |
+
const oldest = SUB_CACHE.keys().next().value;
|
| 31 |
+
if (oldest) SUB_CACHE.delete(oldest);
|
| 32 |
+
}
|
| 33 |
+
SUB_CACHE.set(url, { vtt, ts: Date.now() });
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function vttResponse(out: string) {
|
| 37 |
+
return new NextResponse(out, {
|
| 38 |
+
headers: {
|
| 39 |
+
"Content-Type": "text/vtt; charset=utf-8",
|
| 40 |
+
"Cache-Control": "public, max-age=3600",
|
| 41 |
+
"Access-Control-Allow-Origin": "*",
|
| 42 |
+
},
|
| 43 |
+
});
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Minimal SRT → WebVTT conversion (comma decimals → dot, add header).
|
| 47 |
+
function srtToVtt(input: string): string {
|
| 48 |
+
const body = input
|
| 49 |
+
.replace(/\r+/g, "")
|
| 50 |
+
.replace(/^/, "")
|
| 51 |
+
.replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, "$1.$2");
|
| 52 |
+
return "WEBVTT\n\n" + body;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Proxies a signed subtitle URL and always serves WebVTT, same-origin.
|
| 56 |
+
export async function GET(req: NextRequest) {
|
| 57 |
+
const { searchParams } = new URL(req.url);
|
| 58 |
+
const claim = verify<StreamClaim>(searchParams.get("t"));
|
| 59 |
+
if (!claim || claim.k !== "stream") {
|
| 60 |
+
return new NextResponse("forbidden", { status: 403 });
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// Serve from cache when we've already fetched + converted this track.
|
| 64 |
+
const cached = cacheGet(claim.url);
|
| 65 |
+
if (cached) return vttResponse(cached);
|
| 66 |
+
|
| 67 |
+
const headers: Record<string, string> = { "User-Agent": UA };
|
| 68 |
+
if (claim.ref) headers["Referer"] = claim.ref;
|
| 69 |
+
|
| 70 |
+
const target = claim.url;
|
| 71 |
+
|
| 72 |
+
// Fail fast instead of hanging on slow/rate-limited upstreams (OpenSubtitles
|
| 73 |
+
// can stall for 10s+ when throttled).
|
| 74 |
+
const controller = new AbortController();
|
| 75 |
+
const timer = setTimeout(() => controller.abort(), 8000);
|
| 76 |
+
|
| 77 |
+
let text: string;
|
| 78 |
+
try {
|
| 79 |
+
const res = await fetch(target, {
|
| 80 |
+
headers,
|
| 81 |
+
cache: "no-store",
|
| 82 |
+
redirect: "follow",
|
| 83 |
+
signal: controller.signal,
|
| 84 |
+
});
|
| 85 |
+
if (!res.ok) return new NextResponse("not found", { status: 404 });
|
| 86 |
+
let buf = Buffer.from(await res.arrayBuffer());
|
| 87 |
+
// OpenSubtitles (and some others) serve a gzip-compressed body without a
|
| 88 |
+
// Content-Encoding header, so fetch doesn't auto-decompress. Detect the
|
| 89 |
+
// gzip magic bytes (1f 8b) and inflate ourselves.
|
| 90 |
+
if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) {
|
| 91 |
+
try {
|
| 92 |
+
buf = zlib.gunzipSync(buf);
|
| 93 |
+
} catch {}
|
| 94 |
+
}
|
| 95 |
+
text = buf.toString("utf8");
|
| 96 |
+
} catch {
|
| 97 |
+
return new NextResponse("bad gateway", { status: 502 });
|
| 98 |
+
} finally {
|
| 99 |
+
clearTimeout(timer);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// If we got an HTML error page (e.g. download-limit notice) it's not a
|
| 103 |
+
// subtitle — fail so the player can fall back to another track.
|
| 104 |
+
if (/^\s*<(?:!doctype|html)/i.test(text)) {
|
| 105 |
+
return new NextResponse("not a subtitle", { status: 502 });
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const isVtt = text.trimStart().startsWith("WEBVTT");
|
| 109 |
+
let out = isVtt ? text : srtToVtt(text);
|
| 110 |
+
|
| 111 |
+
// Rebrand any source watermark/credit lines onto the serving domain.
|
| 112 |
+
const host = req.headers.get("host");
|
| 113 |
+
if (host) out = out.replace(/hoofoot\.ru/gi, host);
|
| 114 |
+
|
| 115 |
+
cacheSet(claim.url, out);
|
| 116 |
+
return vttResponse(out);
|
| 117 |
+
}
|
app/api/tmdb/route.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import {
|
| 3 |
+
search,
|
| 4 |
+
getById,
|
| 5 |
+
getPopular,
|
| 6 |
+
getTvSeasons,
|
| 7 |
+
getSeasonEpisodes,
|
| 8 |
+
} from "@/lib/tmdb";
|
| 9 |
+
|
| 10 |
+
// Lightweight proxy so the player page can search / look up titles without
|
| 11 |
+
// shipping the API key to the browser.
|
| 12 |
+
export async function GET(req: NextRequest) {
|
| 13 |
+
const { searchParams } = new URL(req.url);
|
| 14 |
+
const action = searchParams.get("action");
|
| 15 |
+
const type = (searchParams.get("type") || "movie") as "movie" | "tv";
|
| 16 |
+
|
| 17 |
+
try {
|
| 18 |
+
if (action === "popular") {
|
| 19 |
+
const data = await getPopular(type);
|
| 20 |
+
return NextResponse.json({ results: data.slice(0, 12) });
|
| 21 |
+
}
|
| 22 |
+
if (action === "search") {
|
| 23 |
+
const q = searchParams.get("q") || "";
|
| 24 |
+
const data = await search(type, q);
|
| 25 |
+
return NextResponse.json({ results: data.slice(0, 8) });
|
| 26 |
+
}
|
| 27 |
+
if (action === "lookup") {
|
| 28 |
+
const id = searchParams.get("id") || "";
|
| 29 |
+
const data = await getById(type, id);
|
| 30 |
+
return NextResponse.json({ result: data });
|
| 31 |
+
}
|
| 32 |
+
if (action === "seasons") {
|
| 33 |
+
const id = searchParams.get("id") || "";
|
| 34 |
+
const data = await getTvSeasons(id);
|
| 35 |
+
return NextResponse.json({ seasons: data });
|
| 36 |
+
}
|
| 37 |
+
if (action === "episodes") {
|
| 38 |
+
const id = searchParams.get("id") || "";
|
| 39 |
+
const season = parseInt(searchParams.get("season") || "1", 10) || 1;
|
| 40 |
+
const data = await getSeasonEpisodes(id, season);
|
| 41 |
+
return NextResponse.json({ episodes: data });
|
| 42 |
+
}
|
| 43 |
+
return NextResponse.json({ error: "unknown action" }, { status: 400 });
|
| 44 |
+
} catch (e) {
|
| 45 |
+
return NextResponse.json({ error: "tmdb error" }, { status: 502 });
|
| 46 |
+
}
|
| 47 |
+
}
|
app/globals.css
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--bg: #050208;
|
| 7 |
+
--brand: #f59ff1;
|
| 8 |
+
--brand-2: #c084fc;
|
| 9 |
+
--brand-3: #a855f7;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
* {
|
| 13 |
+
box-sizing: border-box;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
html,
|
| 17 |
+
body {
|
| 18 |
+
padding: 0;
|
| 19 |
+
margin: 0;
|
| 20 |
+
background-color: var(--bg);
|
| 21 |
+
color: #fff;
|
| 22 |
+
min-height: 100%;
|
| 23 |
+
-webkit-font-smoothing: antialiased;
|
| 24 |
+
-moz-osx-font-smoothing: grayscale;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
body {
|
| 28 |
+
font-family: var(--font-montserrat), var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
| 29 |
+
position: relative;
|
| 30 |
+
overflow-x: hidden;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/* Custom scrollbar */
|
| 34 |
+
::-webkit-scrollbar {
|
| 35 |
+
width: 10px;
|
| 36 |
+
height: 10px;
|
| 37 |
+
}
|
| 38 |
+
::-webkit-scrollbar-track {
|
| 39 |
+
background: transparent;
|
| 40 |
+
}
|
| 41 |
+
::-webkit-scrollbar-thumb {
|
| 42 |
+
background: rgba(139, 92, 246, 0.35);
|
| 43 |
+
border-radius: 999px;
|
| 44 |
+
border: 2px solid transparent;
|
| 45 |
+
background-clip: padding-box;
|
| 46 |
+
}
|
| 47 |
+
::-webkit-scrollbar-thumb:hover {
|
| 48 |
+
background: rgba(139, 92, 246, 0.6);
|
| 49 |
+
background-clip: padding-box;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.text-gradient-purple {
|
| 53 |
+
background-image: linear-gradient(to right, #c084fc, #d8b4fe, #e9d5ff);
|
| 54 |
+
-webkit-background-clip: text;
|
| 55 |
+
background-clip: text;
|
| 56 |
+
color: transparent;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/* Matches the logo "V" colour (#f59ff1), lightening toward the right */
|
| 60 |
+
.text-gradient-logo {
|
| 61 |
+
background-image: linear-gradient(110deg, #f59ff1 0%, #f4a9f0 38%, #e9c7ff 100%);
|
| 62 |
+
-webkit-background-clip: text;
|
| 63 |
+
background-clip: text;
|
| 64 |
+
color: transparent;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/* ───────────────────────────────────────────────────────────────────────
|
| 68 |
+
Scrolling movie-poster wall — cinematic page background. Columns drift up
|
| 69 |
+
and down at varied speeds behind a dark, brand-tinted veil for legibility.
|
| 70 |
+
─────────────────────────────────────────────────────────────────────── */
|
| 71 |
+
.movie-wall {
|
| 72 |
+
position: fixed;
|
| 73 |
+
inset: 0;
|
| 74 |
+
z-index: -1;
|
| 75 |
+
overflow: hidden;
|
| 76 |
+
background: var(--bg);
|
| 77 |
+
}
|
| 78 |
+
.movie-wall-track {
|
| 79 |
+
display: flex;
|
| 80 |
+
gap: 14px;
|
| 81 |
+
height: 100%;
|
| 82 |
+
padding: 14px;
|
| 83 |
+
/* size columns to their content (not stretched) so translateY(-50%) maps to
|
| 84 |
+
exactly one duplicated set — a seamless loop. */
|
| 85 |
+
align-items: flex-start;
|
| 86 |
+
opacity: 0.2;
|
| 87 |
+
}
|
| 88 |
+
.movie-wall-col {
|
| 89 |
+
flex: 0 0 clamp(120px, 13vw, 190px);
|
| 90 |
+
display: flex;
|
| 91 |
+
flex-direction: column;
|
| 92 |
+
gap: 14px;
|
| 93 |
+
will-change: transform;
|
| 94 |
+
/* longhand props so a missing duration in a shorthand can't reset it to 0s */
|
| 95 |
+
animation-name: wall-up;
|
| 96 |
+
animation-duration: var(--dur, 60s);
|
| 97 |
+
animation-timing-function: linear;
|
| 98 |
+
animation-iteration-count: infinite;
|
| 99 |
+
}
|
| 100 |
+
.movie-wall-col.is-down {
|
| 101 |
+
animation-name: wall-down;
|
| 102 |
+
}
|
| 103 |
+
.movie-wall-col img {
|
| 104 |
+
width: 100%;
|
| 105 |
+
aspect-ratio: 2 / 3;
|
| 106 |
+
object-fit: cover;
|
| 107 |
+
border-radius: 12px;
|
| 108 |
+
display: block;
|
| 109 |
+
}
|
| 110 |
+
@keyframes wall-up {
|
| 111 |
+
from { transform: translateY(0); }
|
| 112 |
+
to { transform: translateY(-50%); }
|
| 113 |
+
}
|
| 114 |
+
@keyframes wall-down {
|
| 115 |
+
from { transform: translateY(-50%); }
|
| 116 |
+
to { transform: translateY(0); }
|
| 117 |
+
}
|
| 118 |
+
/* Dark, brand-tinted wash so foreground text stays readable over the wall. */
|
| 119 |
+
.movie-wall-veil {
|
| 120 |
+
position: absolute;
|
| 121 |
+
inset: 0;
|
| 122 |
+
background:
|
| 123 |
+
radial-gradient(ellipse 60% 55% at 50% 42%, rgba(5, 2, 8, 0.55), rgba(5, 2, 8, 0.93) 78%),
|
| 124 |
+
radial-gradient(circle at 12% 18%, rgba(168, 85, 247, 0.22), transparent 42%),
|
| 125 |
+
radial-gradient(circle at 88% 80%, rgba(245, 159, 241, 0.16), transparent 46%),
|
| 126 |
+
linear-gradient(to bottom, rgba(5, 2, 8, 0.86), rgba(5, 2, 8, 0.55) 32%, rgba(5, 2, 8, 0.92));
|
| 127 |
+
}
|
| 128 |
+
@media (prefers-reduced-motion: reduce) {
|
| 129 |
+
.movie-wall-col { animation: none !important; }
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/* Glass card used across the marketing pages */
|
| 133 |
+
.glass {
|
| 134 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 135 |
+
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.015));
|
| 136 |
+
border-radius: 20px;
|
| 137 |
+
backdrop-filter: blur(10px);
|
| 138 |
+
box-shadow:
|
| 139 |
+
0 1px 0 0 rgba(255, 255, 255, 0.05) inset,
|
| 140 |
+
0 24px 60px -28px rgba(0, 0, 0, 0.8);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/* Glass frame with corner brackets */
|
| 144 |
+
.frame {
|
| 145 |
+
position: relative;
|
| 146 |
+
border-radius: 24px;
|
| 147 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 148 |
+
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
|
| 149 |
+
box-shadow:
|
| 150 |
+
0 0 0 1px rgba(139, 92, 246, 0.06),
|
| 151 |
+
0 30px 80px -20px rgba(0, 0, 0, 0.7),
|
| 152 |
+
inset 0 1px 0 0 rgba(255, 255, 255, 0.04);
|
| 153 |
+
backdrop-filter: blur(2px);
|
| 154 |
+
}
|
| 155 |
+
.frame::before,
|
| 156 |
+
.frame::after {
|
| 157 |
+
content: "";
|
| 158 |
+
position: absolute;
|
| 159 |
+
width: 28px;
|
| 160 |
+
height: 28px;
|
| 161 |
+
border-color: rgba(216, 180, 254, 0.55);
|
| 162 |
+
pointer-events: none;
|
| 163 |
+
}
|
| 164 |
+
.frame-corner {
|
| 165 |
+
position: absolute;
|
| 166 |
+
width: 26px;
|
| 167 |
+
height: 26px;
|
| 168 |
+
pointer-events: none;
|
| 169 |
+
}
|
| 170 |
+
.frame-corner::before,
|
| 171 |
+
.frame-corner::after {
|
| 172 |
+
content: "";
|
| 173 |
+
position: absolute;
|
| 174 |
+
background: rgba(216, 180, 254, 0.5);
|
| 175 |
+
}
|
| 176 |
+
.frame-corner::before {
|
| 177 |
+
width: 100%;
|
| 178 |
+
height: 1.5px;
|
| 179 |
+
}
|
| 180 |
+
.frame-corner::after {
|
| 181 |
+
width: 1.5px;
|
| 182 |
+
height: 100%;
|
| 183 |
+
}
|
| 184 |
+
.frame-corner.tl {
|
| 185 |
+
top: 18px;
|
| 186 |
+
left: 18px;
|
| 187 |
+
}
|
| 188 |
+
.frame-corner.tr {
|
| 189 |
+
top: 18px;
|
| 190 |
+
right: 18px;
|
| 191 |
+
}
|
| 192 |
+
.frame-corner.tr::before {
|
| 193 |
+
right: 0;
|
| 194 |
+
}
|
| 195 |
+
.frame-corner.tr::after {
|
| 196 |
+
right: 0;
|
| 197 |
+
}
|
| 198 |
+
.frame-corner.bl {
|
| 199 |
+
bottom: 18px;
|
| 200 |
+
left: 18px;
|
| 201 |
+
}
|
| 202 |
+
.frame-corner.bl::before {
|
| 203 |
+
bottom: 0;
|
| 204 |
+
}
|
| 205 |
+
.frame-corner.br {
|
| 206 |
+
bottom: 18px;
|
| 207 |
+
right: 18px;
|
| 208 |
+
}
|
| 209 |
+
.frame-corner.br::before {
|
| 210 |
+
bottom: 0;
|
| 211 |
+
right: 0;
|
| 212 |
+
}
|
| 213 |
+
.frame-corner.br::after {
|
| 214 |
+
bottom: 0;
|
| 215 |
+
right: 0;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.no-scrollbar::-webkit-scrollbar {
|
| 219 |
+
display: none;
|
| 220 |
+
}
|
| 221 |
+
.no-scrollbar {
|
| 222 |
+
-ms-overflow-style: none;
|
| 223 |
+
scrollbar-width: none;
|
| 224 |
+
}
|
app/layout.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Inter, Montserrat } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
import { PLAYER_NAME, PLAYER_TAGLINE, LOGO_SRC } from "@/lib/config";
|
| 5 |
+
|
| 6 |
+
const inter = Inter({
|
| 7 |
+
subsets: ["latin"],
|
| 8 |
+
variable: "--font-inter",
|
| 9 |
+
display: "swap",
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
const montserrat = Montserrat({
|
| 13 |
+
subsets: ["latin"],
|
| 14 |
+
variable: "--font-montserrat",
|
| 15 |
+
display: "swap",
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
export const metadata: Metadata = {
|
| 19 |
+
title: `${PLAYER_NAME} — ${PLAYER_TAGLINE}`,
|
| 20 |
+
description: PLAYER_TAGLINE,
|
| 21 |
+
icons: { icon: LOGO_SRC, shortcut: LOGO_SRC, apple: LOGO_SRC },
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
export default function RootLayout({
|
| 25 |
+
children,
|
| 26 |
+
}: {
|
| 27 |
+
children: React.ReactNode;
|
| 28 |
+
}) {
|
| 29 |
+
return (
|
| 30 |
+
<html lang="en" className={`${inter.variable} ${montserrat.variable}`}>
|
| 31 |
+
<body>{children}</body>
|
| 32 |
+
</html>
|
| 33 |
+
);
|
| 34 |
+
}
|
app/movie/[id]/page.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import PlayerScreen from "@/components/player/PlayerScreen";
|
| 3 |
+
import { getById } from "@/lib/tmdb";
|
| 4 |
+
import { PLAYER_NAME } from "@/lib/config";
|
| 5 |
+
|
| 6 |
+
export const revalidate = 3600;
|
| 7 |
+
|
| 8 |
+
export async function generateMetadata({
|
| 9 |
+
params,
|
| 10 |
+
}: {
|
| 11 |
+
params: Promise<{ id: string }>;
|
| 12 |
+
}): Promise<Metadata> {
|
| 13 |
+
const { id } = await params;
|
| 14 |
+
const item = await getById("movie", id).catch(() => null);
|
| 15 |
+
return { title: item?.title ? `${item.title} — ${PLAYER_NAME}` : PLAYER_NAME };
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// /movie/{tmdbId}
|
| 19 |
+
export default async function MoviePlayerPage({
|
| 20 |
+
params,
|
| 21 |
+
searchParams,
|
| 22 |
+
}: {
|
| 23 |
+
params: Promise<{ id: string }>;
|
| 24 |
+
searchParams: Promise<Record<string, string | undefined>>;
|
| 25 |
+
}) {
|
| 26 |
+
const { id } = await params;
|
| 27 |
+
const sp = await searchParams;
|
| 28 |
+
return (
|
| 29 |
+
<PlayerScreen
|
| 30 |
+
type="movie"
|
| 31 |
+
id={id}
|
| 32 |
+
color={sp.color}
|
| 33 |
+
src={sp.src}
|
| 34 |
+
server={sp.server}
|
| 35 |
+
progress={sp.progress}
|
| 36 |
+
overlay={sp.overlay}
|
| 37 |
+
skipIntro={sp.skip_intro}
|
| 38 |
+
/>
|
| 39 |
+
);
|
| 40 |
+
}
|
app/not-found.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function NotFound() {
|
| 2 |
+
return (
|
| 3 |
+
<div
|
| 4 |
+
style={{
|
| 5 |
+
position: "fixed",
|
| 6 |
+
inset: 0,
|
| 7 |
+
zIndex: 9999,
|
| 8 |
+
background: "#000",
|
| 9 |
+
color: "#fff",
|
| 10 |
+
}}
|
| 11 |
+
>
|
| 12 |
+
404 not found
|
| 13 |
+
</div>
|
| 14 |
+
);
|
| 15 |
+
}
|
app/tv/[id]/[season]/[episode]/page.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import PlayerScreen from "@/components/player/PlayerScreen";
|
| 3 |
+
import { getById } from "@/lib/tmdb";
|
| 4 |
+
import { PLAYER_NAME } from "@/lib/config";
|
| 5 |
+
|
| 6 |
+
export const revalidate = 3600;
|
| 7 |
+
|
| 8 |
+
export async function generateMetadata({
|
| 9 |
+
params,
|
| 10 |
+
}: {
|
| 11 |
+
params: Promise<{ id: string; season: string; episode: string }>;
|
| 12 |
+
}): Promise<Metadata> {
|
| 13 |
+
const { id, season, episode } = await params;
|
| 14 |
+
const item = await getById("tv", id).catch(() => null);
|
| 15 |
+
if (!item?.title) return { title: PLAYER_NAME };
|
| 16 |
+
return { title: `${item.title} · S${season} E${episode} — ${PLAYER_NAME}` };
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
// /tv/{tmdbId}/{season}/{episode}
|
| 20 |
+
export default async function TvPlayerPage({
|
| 21 |
+
params,
|
| 22 |
+
searchParams,
|
| 23 |
+
}: {
|
| 24 |
+
params: Promise<{ id: string; season: string; episode: string }>;
|
| 25 |
+
searchParams: Promise<Record<string, string | undefined>>;
|
| 26 |
+
}) {
|
| 27 |
+
const { id, season, episode } = await params;
|
| 28 |
+
const sp = await searchParams;
|
| 29 |
+
return (
|
| 30 |
+
<PlayerScreen
|
| 31 |
+
type="tv"
|
| 32 |
+
id={id}
|
| 33 |
+
season={season}
|
| 34 |
+
episode={episode}
|
| 35 |
+
color={sp.color}
|
| 36 |
+
src={sp.src}
|
| 37 |
+
server={sp.server}
|
| 38 |
+
progress={sp.progress}
|
| 39 |
+
nextEpisode={sp.nextEpisode}
|
| 40 |
+
episodeSelector={sp.episodeSelector}
|
| 41 |
+
autoplayNextEpisode={sp.autoplayNextEpisode}
|
| 42 |
+
overlay={sp.overlay}
|
| 43 |
+
skipIntro={sp.skip_intro}
|
| 44 |
+
/>
|
| 45 |
+
);
|
| 46 |
+
}
|
components/player/PlayerScreen.tsx
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import VideoPlayer from "@/components/player/VideoPlayer";
|
| 2 |
+
import { getById, getLogo, getSeasonEpisodes, img } from "@/lib/tmdb";
|
| 3 |
+
import { DEFAULT_ACCENT } from "@/lib/config";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Full-screen player screen — /{type}/{id}. Renders TMDB metadata and plays the
|
| 7 |
+
* resolved stream (HLS / DASH / MP4). Supports `?src=` to play an arbitrary
|
| 8 |
+
* URL and `?color=` for the accent hex.
|
| 9 |
+
*/
|
| 10 |
+
export default async function PlayerScreen({
|
| 11 |
+
type,
|
| 12 |
+
id,
|
| 13 |
+
season,
|
| 14 |
+
episode,
|
| 15 |
+
color,
|
| 16 |
+
src,
|
| 17 |
+
server,
|
| 18 |
+
progress,
|
| 19 |
+
nextEpisode,
|
| 20 |
+
episodeSelector,
|
| 21 |
+
autoplayNextEpisode,
|
| 22 |
+
overlay,
|
| 23 |
+
skipIntro,
|
| 24 |
+
}: {
|
| 25 |
+
type: "movie" | "tv";
|
| 26 |
+
id: string;
|
| 27 |
+
season?: string;
|
| 28 |
+
episode?: string;
|
| 29 |
+
color?: string;
|
| 30 |
+
src?: string;
|
| 31 |
+
server?: string;
|
| 32 |
+
progress?: string;
|
| 33 |
+
nextEpisode?: string;
|
| 34 |
+
episodeSelector?: string;
|
| 35 |
+
autoplayNextEpisode?: string;
|
| 36 |
+
overlay?: string;
|
| 37 |
+
skipIntro?: string;
|
| 38 |
+
}) {
|
| 39 |
+
const truthy = (v?: string) => v === "true" || v === "1" || v === "yes";
|
| 40 |
+
const item = await getById(type === "tv" ? "tv" : "movie", id).catch(
|
| 41 |
+
() => null
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
const accent = (color || DEFAULT_ACCENT).replace("#", "");
|
| 45 |
+
|
| 46 |
+
const title = item?.title || `${type === "tv" ? "Show" : "Movie"} #${id}`;
|
| 47 |
+
const subtitle =
|
| 48 |
+
type === "tv" && season && episode ? `S${season} · E${episode}` : item?.year;
|
| 49 |
+
|
| 50 |
+
// Extra metadata for the idle overlay.
|
| 51 |
+
const logo = item ? await getLogo(type, id).catch(() => null) : null;
|
| 52 |
+
let runtime: number | undefined = item?.runtime ?? undefined;
|
| 53 |
+
let episodeTitle: string | undefined;
|
| 54 |
+
let overview = item?.overview;
|
| 55 |
+
if (type === "tv" && season && episode) {
|
| 56 |
+
const eps = await getSeasonEpisodes(id, parseInt(season, 10)).catch(() => []);
|
| 57 |
+
const ep = eps.find((e) => e.episode === parseInt(episode, 10));
|
| 58 |
+
if (ep) {
|
| 59 |
+
episodeTitle = ep.name;
|
| 60 |
+
runtime = ep.runtime ?? runtime;
|
| 61 |
+
if (ep.overview) overview = ep.overview;
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
<div className="fixed inset-0 z-[100] bg-black">
|
| 67 |
+
<VideoPlayer
|
| 68 |
+
src={src}
|
| 69 |
+
initialServer={server}
|
| 70 |
+
season={season}
|
| 71 |
+
episode={episode}
|
| 72 |
+
title={title}
|
| 73 |
+
year={subtitle}
|
| 74 |
+
overview={overview}
|
| 75 |
+
rating={item?.rating}
|
| 76 |
+
logo={logo}
|
| 77 |
+
runtime={runtime}
|
| 78 |
+
episodeTitle={episodeTitle}
|
| 79 |
+
poster={
|
| 80 |
+
img.backdrop(item?.backdrop ?? null, "original") ||
|
| 81 |
+
img.poster(item?.poster ?? null, "original")
|
| 82 |
+
}
|
| 83 |
+
color={accent}
|
| 84 |
+
type={type}
|
| 85 |
+
contentId={id}
|
| 86 |
+
startTime={progress ? parseInt(progress, 10) || 0 : 0}
|
| 87 |
+
nextEpisode={truthy(nextEpisode)}
|
| 88 |
+
episodeSelector={truthy(episodeSelector)}
|
| 89 |
+
autoplayNext={truthy(autoplayNextEpisode)}
|
| 90 |
+
overlay={truthy(overlay)}
|
| 91 |
+
skipIntro={truthy(skipIntro)}
|
| 92 |
+
/>
|
| 93 |
+
</div>
|
| 94 |
+
);
|
| 95 |
+
}
|
components/player/VideoPlayer.tsx
ADDED
|
@@ -0,0 +1,2598 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import {
|
| 4 |
+
useCallback,
|
| 5 |
+
useEffect,
|
| 6 |
+
useMemo,
|
| 7 |
+
useRef,
|
| 8 |
+
useState,
|
| 9 |
+
} from "react";
|
| 10 |
+
import { detectStreamKind } from "@/lib/constants";
|
| 11 |
+
import { flagUrl } from "@/lib/subtitles/flags";
|
| 12 |
+
import {
|
| 13 |
+
PlayIcon,
|
| 14 |
+
PauseIcon,
|
| 15 |
+
Rewind10Icon,
|
| 16 |
+
Forward10Icon,
|
| 17 |
+
VolumeHighIcon,
|
| 18 |
+
VolumeMutedIcon,
|
| 19 |
+
SubtitlesIcon,
|
| 20 |
+
SettingsIcon,
|
| 21 |
+
CloudIcon,
|
| 22 |
+
FullscreenIcon,
|
| 23 |
+
FullscreenExitIcon,
|
| 24 |
+
HeartIcon,
|
| 25 |
+
CheckIcon,
|
| 26 |
+
UploadIcon,
|
| 27 |
+
SlidersIcon,
|
| 28 |
+
ChevronRightIcon,
|
| 29 |
+
CloseIcon,
|
| 30 |
+
NextEpisodeIcon,
|
| 31 |
+
ListIcon,
|
| 32 |
+
WarnIcon,
|
| 33 |
+
StarIcon,
|
| 34 |
+
} from "./icons";
|
| 35 |
+
|
| 36 |
+
export interface PlayerProps {
|
| 37 |
+
/** Direct stream URL (demo mode). Omit to resolve via the protected API. */
|
| 38 |
+
src?: string;
|
| 39 |
+
title?: string;
|
| 40 |
+
year?: string;
|
| 41 |
+
poster?: string | null;
|
| 42 |
+
/** hex without the leading # — matches the vidsuper ?color= param */
|
| 43 |
+
color?: string;
|
| 44 |
+
type?: "movie" | "tv";
|
| 45 |
+
contentId?: string | number;
|
| 46 |
+
autoPlay?: boolean;
|
| 47 |
+
season?: string | number;
|
| 48 |
+
episode?: string | number;
|
| 49 |
+
/** Provider to resolve first (defaults to "oneroom"). */
|
| 50 |
+
initialServer?: string;
|
| 51 |
+
overview?: string;
|
| 52 |
+
/** Start playback at this many seconds (?progress=). */
|
| 53 |
+
startTime?: number;
|
| 54 |
+
/** Show the next-episode button (?nextEpisode=). */
|
| 55 |
+
nextEpisode?: boolean;
|
| 56 |
+
/** Enable the season/episode picker (?episodeSelector=). */
|
| 57 |
+
episodeSelector?: boolean;
|
| 58 |
+
/** Auto-advance to the next episode on finish (?autoplayNextEpisode=). */
|
| 59 |
+
autoplayNext?: boolean;
|
| 60 |
+
/** Netflix-style overlay after 5s paused (?overlay=). */
|
| 61 |
+
overlay?: boolean;
|
| 62 |
+
/** Show a Skip Intro button over intro/recap segments (?skip_intro=). */
|
| 63 |
+
skipIntro?: boolean;
|
| 64 |
+
/** TMDB rating (0–10) shown on the idle overlay. */
|
| 65 |
+
rating?: number;
|
| 66 |
+
/** Title logo image (transparent PNG) shown on the idle overlay. */
|
| 67 |
+
logo?: string | null;
|
| 68 |
+
/** Runtime in minutes, shown on the idle overlay. */
|
| 69 |
+
runtime?: number;
|
| 70 |
+
/** Episode title (TV), shown on the idle overlay. */
|
| 71 |
+
episodeTitle?: string;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
type Tab = "Quality" | "Subs" | "Servers" | "Speed";
|
| 75 |
+
type Panel = Tab | "CaptionStyle" | "Episodes" | "SubSources";
|
| 76 |
+
|
| 77 |
+
// ---- Subtitle appearance (persisted) --------------------------------------
|
| 78 |
+
interface SubStyle {
|
| 79 |
+
size: number; // px
|
| 80 |
+
color: string;
|
| 81 |
+
opacity: number; // text opacity 0..1
|
| 82 |
+
bg: string; // background color, "none" to disable
|
| 83 |
+
bgOpacity: number; // 0..1
|
| 84 |
+
font: string; // css font-family stack
|
| 85 |
+
bold: boolean;
|
| 86 |
+
edge: "none" | "shadow" | "outline" | "glow";
|
| 87 |
+
position: number; // distance from bottom, % of player height (when controls hidden)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const MONTSERRAT_STACK =
|
| 91 |
+
'var(--font-montserrat), Montserrat, system-ui, "Segoe UI", sans-serif';
|
| 92 |
+
|
| 93 |
+
const DEFAULT_SUB_STYLE: SubStyle = {
|
| 94 |
+
size: 28,
|
| 95 |
+
color: "#ffffff",
|
| 96 |
+
opacity: 1,
|
| 97 |
+
bg: "#000000",
|
| 98 |
+
bgOpacity: 0, // no background by default
|
| 99 |
+
font: MONTSERRAT_STACK,
|
| 100 |
+
bold: false,
|
| 101 |
+
edge: "shadow",
|
| 102 |
+
position: 8,
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
const SUB_FONTS: { label: string; value: string }[] = [
|
| 106 |
+
{ label: "Montserrat", value: MONTSERRAT_STACK },
|
| 107 |
+
{ label: "Sans", value: 'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' },
|
| 108 |
+
{ label: "Serif", value: 'Georgia, "Times New Roman", serif' },
|
| 109 |
+
{ label: "Mono", value: '"SF Mono", "Roboto Mono", Menlo, Consolas, monospace' },
|
| 110 |
+
{ label: "Rounded", value: '"Trebuchet MS", "Segoe UI", system-ui, sans-serif' },
|
| 111 |
+
];
|
| 112 |
+
|
| 113 |
+
const SUB_COLORS = ["#ffffff", "#ffe600", "#00e5ff", "#7CFC00", "#ff6b6b", "#ff8a3d", "#c77dff"];
|
| 114 |
+
|
| 115 |
+
// Build the CSS for the rendered caption text from a SubStyle.
|
| 116 |
+
function captionTextStyle(s: SubStyle): React.CSSProperties {
|
| 117 |
+
const edge: Record<SubStyle["edge"], string> = {
|
| 118 |
+
none: "none",
|
| 119 |
+
shadow: "0 2px 4px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.7)",
|
| 120 |
+
outline:
|
| 121 |
+
"-1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000, 0 0 3px #000",
|
| 122 |
+
glow: "0 0 8px rgba(0,0,0,1), 0 0 14px var(--accent)",
|
| 123 |
+
};
|
| 124 |
+
return {
|
| 125 |
+
fontSize: `${s.size}px`,
|
| 126 |
+
lineHeight: 1.3,
|
| 127 |
+
color: s.color,
|
| 128 |
+
opacity: s.opacity,
|
| 129 |
+
fontFamily: s.font,
|
| 130 |
+
fontWeight: s.bold ? 800 : 500,
|
| 131 |
+
textShadow: edge[s.edge],
|
| 132 |
+
backgroundColor:
|
| 133 |
+
s.bg === "none" ? "transparent" : hexWithAlpha(s.bg, s.bgOpacity),
|
| 134 |
+
padding: s.bg === "none" ? 0 : "0.1em 0.4em",
|
| 135 |
+
borderRadius: 6,
|
| 136 |
+
whiteSpace: "pre-line",
|
| 137 |
+
boxDecorationBreak: "clone",
|
| 138 |
+
WebkitBoxDecorationBreak: "clone",
|
| 139 |
+
} as React.CSSProperties;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function hexWithAlpha(hex: string, alpha: number): string {
|
| 143 |
+
const h = hex.replace("#", "");
|
| 144 |
+
const r = parseInt(h.slice(0, 2), 16) || 0;
|
| 145 |
+
const g = parseInt(h.slice(2, 4), 16) || 0;
|
| 146 |
+
const b = parseInt(h.slice(4, 6), 16) || 0;
|
| 147 |
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// Source providers are discovered at runtime from /api/sources/list, which is
|
| 151 |
+
// driven by the lib/scraper registry — so adding a scraper file automatically
|
| 152 |
+
// adds a server here.
|
| 153 |
+
type Provider = {
|
| 154 |
+
id: string;
|
| 155 |
+
name: string;
|
| 156 |
+
label: string;
|
| 157 |
+
children?: { id: string; name: string }[];
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
| 161 |
+
|
| 162 |
+
// Quality labels are usually a height ("1080") — show "1080p". Anything else
|
| 163 |
+
// (a sub-source name like "MovieBox") is shown as-is.
|
| 164 |
+
function qualityLabel(label: string): string {
|
| 165 |
+
return /^\d{3,4}$/.test(label) ? `${label}p` : label;
|
| 166 |
+
}
|
| 167 |
+
// Display name for a (possibly "parent:child") server id, using the loaded list.
|
| 168 |
+
function serverName(id: string, providers: Provider[]): string {
|
| 169 |
+
const [pid, cid] = id.split(":");
|
| 170 |
+
const p = providers.find((x) => x.id === pid);
|
| 171 |
+
if (!p) return id;
|
| 172 |
+
if (!cid) return p.name;
|
| 173 |
+
const c = p.children?.find((x) => x.id === cid);
|
| 174 |
+
return c ? `${p.name} · ${c.name}` : p.name;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
function fmt(t: number) {
|
| 178 |
+
if (!isFinite(t) || t < 0) t = 0;
|
| 179 |
+
const h = Math.floor(t / 3600);
|
| 180 |
+
const m = Math.floor((t % 3600) / 60);
|
| 181 |
+
const s = Math.floor(t % 60);
|
| 182 |
+
const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
|
| 183 |
+
return `${h > 0 ? h + ":" : ""}${mm}:${String(s).padStart(2, "0")}`;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
// Minutes -> "1h 23m" / "23m" for the idle overlay meta line.
|
| 187 |
+
function fmtRuntime(min?: number | null) {
|
| 188 |
+
if (!min || min <= 0) return "";
|
| 189 |
+
const h = Math.floor(min / 60);
|
| 190 |
+
const m = min % 60;
|
| 191 |
+
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
function qualityBadge(height: number) {
|
| 195 |
+
if (height >= 2160) return "4K";
|
| 196 |
+
if (height >= 1080) return "Full HD";
|
| 197 |
+
if (height >= 720) return "HD";
|
| 198 |
+
return "";
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
// "en" + "English" -> "English - English", matching the player's subtitle list
|
| 202 |
+
function subLabel(lang: string | undefined, name: string | undefined) {
|
| 203 |
+
let langName = "";
|
| 204 |
+
if (lang) {
|
| 205 |
+
try {
|
| 206 |
+
langName =
|
| 207 |
+
new Intl.DisplayNames(["en"], { type: "language" }).of(lang) || lang;
|
| 208 |
+
} catch {
|
| 209 |
+
langName = lang;
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
const clean = (name || "").trim();
|
| 213 |
+
if (langName && clean && langName.toLowerCase() !== clean.toLowerCase())
|
| 214 |
+
return `${langName} - ${clean}`;
|
| 215 |
+
return clean || langName || "Subtitle";
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
export default function VideoPlayer({
|
| 219 |
+
src,
|
| 220 |
+
title,
|
| 221 |
+
year,
|
| 222 |
+
poster,
|
| 223 |
+
color = "ef4444",
|
| 224 |
+
type = "movie",
|
| 225 |
+
contentId,
|
| 226 |
+
autoPlay = false,
|
| 227 |
+
season,
|
| 228 |
+
episode,
|
| 229 |
+
initialServer,
|
| 230 |
+
overview,
|
| 231 |
+
startTime = 0,
|
| 232 |
+
nextEpisode = false,
|
| 233 |
+
episodeSelector = false,
|
| 234 |
+
autoplayNext = false,
|
| 235 |
+
overlay = false,
|
| 236 |
+
skipIntro = false,
|
| 237 |
+
rating,
|
| 238 |
+
logo,
|
| 239 |
+
runtime,
|
| 240 |
+
episodeTitle,
|
| 241 |
+
}: PlayerProps) {
|
| 242 |
+
const accent = `#${color.replace("#", "")}`;
|
| 243 |
+
|
| 244 |
+
const wrapRef = useRef<HTMLDivElement>(null);
|
| 245 |
+
const videoRef = useRef<HTMLVideoElement>(null);
|
| 246 |
+
const hlsRef = useRef<any>(null);
|
| 247 |
+
const dashRef = useRef<any>(null);
|
| 248 |
+
const engineRef = useRef<"hls" | "dash" | "native">("native");
|
| 249 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 250 |
+
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
| 251 |
+
|
| 252 |
+
const [started, setStarted] = useState(autoPlay);
|
| 253 |
+
const [playing, setPlaying] = useState(false);
|
| 254 |
+
const [buffering, setBuffering] = useState(false);
|
| 255 |
+
const [current, setCurrent] = useState(0);
|
| 256 |
+
const [duration, setDuration] = useState(0);
|
| 257 |
+
const [buffered, setBuffered] = useState(0);
|
| 258 |
+
const [volume, setVolume] = useState(1);
|
| 259 |
+
const [muted, setMuted] = useState(false);
|
| 260 |
+
const [fullscreen, setFullscreen] = useState(false);
|
| 261 |
+
const [controlsVisible, setControlsVisible] = useState(true);
|
| 262 |
+
const [panel, setPanel] = useState<Panel | null>(null);
|
| 263 |
+
const [hoverTime, setHoverTime] = useState<{ x: number; t: number } | null>(
|
| 264 |
+
null
|
| 265 |
+
);
|
| 266 |
+
const [skipFlash, setSkipFlash] = useState<"fwd" | "back" | null>(null);
|
| 267 |
+
|
| 268 |
+
// episodes / next-episode (TV)
|
| 269 |
+
const isSeries = type === "tv";
|
| 270 |
+
const curSeason = parseInt(String(season ?? "0"), 10) || 0;
|
| 271 |
+
const curEpisode = parseInt(String(episode ?? "0"), 10) || 0;
|
| 272 |
+
const [seasonsInfo, setSeasonsInfo] = useState<
|
| 273 |
+
{ season: number; episodeCount: number; name: string }[]
|
| 274 |
+
>([]);
|
| 275 |
+
const [pickerSeason, setPickerSeason] = useState(curSeason || 1);
|
| 276 |
+
const [pickerEpisodes, setPickerEpisodes] = useState<
|
| 277 |
+
{ episode: number; name: string; still: string | null; overview: string }[]
|
| 278 |
+
>([]);
|
| 279 |
+
// Netflix-style idle overlay
|
| 280 |
+
const [showOverlay, setShowOverlay] = useState(false);
|
| 281 |
+
const overlayTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
| 282 |
+
const didInitialSeekRef = useRef(false);
|
| 283 |
+
// Always points at the latest armOverlay (poke is defined before it).
|
| 284 |
+
const armOverlayRef = useRef<() => void>(() => {});
|
| 285 |
+
|
| 286 |
+
// skip intro / recap (?skip_intro=)
|
| 287 |
+
type SkipSeg = { start: number; end: number; label: string };
|
| 288 |
+
const [skipSegments, setSkipSegments] = useState<SkipSeg[]>([]);
|
| 289 |
+
const [activeSkip, setActiveSkip] = useState<SkipSeg | null>(null);
|
| 290 |
+
|
| 291 |
+
// source switching: show a loading curtain + remember which servers failed.
|
| 292 |
+
const [switching, setSwitching] = useState(false);
|
| 293 |
+
const [failedServers, setFailedServers] = useState<string[]>([]);
|
| 294 |
+
|
| 295 |
+
// selections
|
| 296 |
+
const [levels, setLevels] = useState<{ height: number; index: number }[]>([]);
|
| 297 |
+
const [currentLevel, setCurrentLevel] = useState(-1); // -1 = auto
|
| 298 |
+
const [speed, setSpeed] = useState(1);
|
| 299 |
+
const [serverId, setServerId] = useState(initialServer || "oneroom");
|
| 300 |
+
const [audioTracks, setAudioTracks] = useState<
|
| 301 |
+
{ id: number; name: string }[]
|
| 302 |
+
>([]);
|
| 303 |
+
const [currentAudio, setCurrentAudio] = useState(0);
|
| 304 |
+
// Source providers, discovered from the lib/scraper registry at runtime.
|
| 305 |
+
const [providers, setProviders] = useState<Provider[]>([]);
|
| 306 |
+
useEffect(() => {
|
| 307 |
+
let cancelled = false;
|
| 308 |
+
fetch("/api/sources/list")
|
| 309 |
+
.then((r) => r.json())
|
| 310 |
+
.then((d) => {
|
| 311 |
+
if (!cancelled && Array.isArray(d.sources)) setProviders(d.sources);
|
| 312 |
+
})
|
| 313 |
+
.catch(() => {});
|
| 314 |
+
return () => {
|
| 315 |
+
cancelled = true;
|
| 316 |
+
};
|
| 317 |
+
}, []);
|
| 318 |
+
// Which parent source's sub-sources are shown (in the SubSources sub-panel).
|
| 319 |
+
const [subSourceParent, setSubSourceParent] = useState<string | null>(null);
|
| 320 |
+
// Favorited servers (persisted) — these take priority when resolving sources.
|
| 321 |
+
const [favorites, setFavorites] = useState<string[]>([]);
|
| 322 |
+
useEffect(() => {
|
| 323 |
+
try {
|
| 324 |
+
const raw = localStorage.getItem("vc-fav-servers");
|
| 325 |
+
if (raw) setFavorites(JSON.parse(raw));
|
| 326 |
+
} catch {}
|
| 327 |
+
}, []);
|
| 328 |
+
const [subsOn, setSubsOn] = useState(false);
|
| 329 |
+
const [loadError, setLoadError] = useState<string | null>(null);
|
| 330 |
+
const [subTracks, setSubTracks] = useState<
|
| 331 |
+
{ id: number; label: string; builtIn: boolean }[]
|
| 332 |
+
>([]);
|
| 333 |
+
const [activeSub, setActiveSub] = useState(-1); // -1 = off
|
| 334 |
+
|
| 335 |
+
// Custom subtitle rendering: we keep the active track in "hidden" mode (so the
|
| 336 |
+
// browser parses cues but doesn't draw them) and render them ourselves into a
|
| 337 |
+
// floating overlay that sits above the controls and is fully styleable.
|
| 338 |
+
const [cueText, setCueText] = useState("");
|
| 339 |
+
const [subStyle, setSubStyle] = useState<SubStyle>(DEFAULT_SUB_STYLE);
|
| 340 |
+
const cueTrackRef = useRef<TextTrack | null>(null);
|
| 341 |
+
// Subtitle delay (seconds): positive = subs appear later, negative = earlier.
|
| 342 |
+
const [subDelay, setSubDelay] = useState(0);
|
| 343 |
+
const subDelayRef = useRef(0);
|
| 344 |
+
useEffect(() => {
|
| 345 |
+
subDelayRef.current = subDelay;
|
| 346 |
+
}, [subDelay]);
|
| 347 |
+
|
| 348 |
+
// Load persisted caption style once on mount.
|
| 349 |
+
useEffect(() => {
|
| 350 |
+
try {
|
| 351 |
+
const raw = localStorage.getItem("vc-sub-style");
|
| 352 |
+
if (raw) setSubStyle({ ...DEFAULT_SUB_STYLE, ...JSON.parse(raw) });
|
| 353 |
+
} catch {}
|
| 354 |
+
}, []);
|
| 355 |
+
const updateSubStyle = (patch: Partial<SubStyle>) =>
|
| 356 |
+
setSubStyle((prev) => {
|
| 357 |
+
const next = { ...prev, ...patch };
|
| 358 |
+
try {
|
| 359 |
+
localStorage.setItem("vc-sub-style", JSON.stringify(next));
|
| 360 |
+
} catch {}
|
| 361 |
+
return next;
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
// source resolution
|
| 365 |
+
const [resolvedSrc, setResolvedSrc] = useState<string | null>(null);
|
| 366 |
+
const [resolvedKind, setResolvedKind] = useState<
|
| 367 |
+
"hls" | "dash" | "native" | null
|
| 368 |
+
>(null);
|
| 369 |
+
const [resolving, setResolving] = useState(false);
|
| 370 |
+
const activeSrc = src || resolvedSrc;
|
| 371 |
+
|
| 372 |
+
// all sources from the API (for multi-quality switching) + Wyzie subtitles
|
| 373 |
+
type ApiSource = { file: string; label: string; type: string };
|
| 374 |
+
type ApiSub = {
|
| 375 |
+
url: string;
|
| 376 |
+
display: string;
|
| 377 |
+
language: string;
|
| 378 |
+
hi?: boolean;
|
| 379 |
+
source?: string;
|
| 380 |
+
/** Flag image URL supplied by the source (Wyzie); else derived. */
|
| 381 |
+
flag?: string;
|
| 382 |
+
};
|
| 383 |
+
const [resolvedSources, setResolvedSources] = useState<ApiSource[]>([]);
|
| 384 |
+
const [qualityIndex, setQualityIndex] = useState(0);
|
| 385 |
+
const [apiSubs, setApiSubs] = useState<ApiSub[]>([]);
|
| 386 |
+
const [activeApiSub, setActiveApiSub] = useState(-1);
|
| 387 |
+
// Once the user picks/clears a subtitle, stop auto-loading the default one.
|
| 388 |
+
const userPickedSubRef = useRef(false);
|
| 389 |
+
const pendingSeekRef = useRef<number | null>(null);
|
| 390 |
+
const wasPlayingRef = useRef(false);
|
| 391 |
+
// Set when the user has asked to play — the source effect starts playback as
|
| 392 |
+
// soon as the stream is ready (regardless of autoPlay).
|
| 393 |
+
const wantPlayRef = useRef(false);
|
| 394 |
+
// Servers already attempted for this title, so failover doesn't loop forever.
|
| 395 |
+
const triedServersRef = useRef<Set<string>>(new Set());
|
| 396 |
+
// Always points at the latest failover handler (the source effect captures a
|
| 397 |
+
// stale closure otherwise).
|
| 398 |
+
const failoverRef = useRef<() => void>(() => {});
|
| 399 |
+
// Resolution order: favorited servers first (in provider order), then the
|
| 400 |
+
// rest. Drives both the initial pick and the failover sequence. Falls back to
|
| 401 |
+
// the initial server until the provider list has loaded.
|
| 402 |
+
const serverOrder = useMemo(() => {
|
| 403 |
+
const ids = providers.map((p) => p.id);
|
| 404 |
+
if (!ids.length) return initialServer ? [initialServer] : [];
|
| 405 |
+
const favs = ids.filter((id) => favorites.includes(id));
|
| 406 |
+
const rest = ids.filter((id) => !favorites.includes(id));
|
| 407 |
+
return [...favs, ...rest];
|
| 408 |
+
}, [providers, favorites, initialServer]);
|
| 409 |
+
|
| 410 |
+
/* ---------------- source loading (HLS / DASH / MP4) ---------------- */
|
| 411 |
+
useEffect(() => {
|
| 412 |
+
const video = videoRef.current;
|
| 413 |
+
if (!video || !started || !activeSrc) return;
|
| 414 |
+
const src = activeSrc;
|
| 415 |
+
let destroyed = false;
|
| 416 |
+
const kind = resolvedKind || detectStreamKind(src);
|
| 417 |
+
engineRef.current = kind;
|
| 418 |
+
setLoadError(null);
|
| 419 |
+
|
| 420 |
+
// Begin playback once ready if autoPlay is set or the user pressed play.
|
| 421 |
+
const playIfWanted = () => {
|
| 422 |
+
if (autoPlay || wantPlayRef.current) {
|
| 423 |
+
wantPlayRef.current = false;
|
| 424 |
+
video!.play().catch(() => {});
|
| 425 |
+
}
|
| 426 |
+
};
|
| 427 |
+
|
| 428 |
+
const dedupeByHeight = (
|
| 429 |
+
arr: { height: number; index: number }[]
|
| 430 |
+
) => {
|
| 431 |
+
const byHeight = new Map<number, number>();
|
| 432 |
+
arr.forEach(({ height, index }) => {
|
| 433 |
+
if (height && !byHeight.has(height)) byHeight.set(height, index);
|
| 434 |
+
});
|
| 435 |
+
return Array.from(byHeight, ([height, index]) => ({ height, index })).sort(
|
| 436 |
+
(a, b) => b.height - a.height
|
| 437 |
+
);
|
| 438 |
+
};
|
| 439 |
+
|
| 440 |
+
async function setupHls() {
|
| 441 |
+
// Prefer hls.js when supported — it gives us quality, ABR and subtitle
|
| 442 |
+
// track control. Only fall back to native HLS (Safari / iOS) otherwise.
|
| 443 |
+
// NOTE: some Chrome builds report canPlayType("…mpegurl") === "maybe",
|
| 444 |
+
// so we must check Hls.isSupported() first rather than trusting that.
|
| 445 |
+
const Hls = (await import("hls.js")).default;
|
| 446 |
+
if (destroyed) return;
|
| 447 |
+
if (!Hls.isSupported()) {
|
| 448 |
+
if (video!.canPlayType("application/vnd.apple.mpegurl")) {
|
| 449 |
+
video!.src = src;
|
| 450 |
+
video!.addEventListener("loadedmetadata", readNativeTextTracks);
|
| 451 |
+
video!.textTracks.addEventListener?.("addtrack", readNativeTextTracks);
|
| 452 |
+
video!.addEventListener("canplay", playIfWanted, { once: true });
|
| 453 |
+
video!.addEventListener("error", () => failoverRef.current());
|
| 454 |
+
return;
|
| 455 |
+
}
|
| 456 |
+
setLoadError("HLS is not supported in this browser.");
|
| 457 |
+
return;
|
| 458 |
+
}
|
| 459 |
+
const hls = new Hls({ enableWorker: true, lowLatencyMode: true });
|
| 460 |
+
hlsRef.current = hls;
|
| 461 |
+
hls.loadSource(src);
|
| 462 |
+
hls.attachMedia(video!);
|
| 463 |
+
const readSubs = (tracks: any[]) => {
|
| 464 |
+
setSubTracks(
|
| 465 |
+
(tracks || []).map((t: any, id: number) => ({
|
| 466 |
+
id,
|
| 467 |
+
label: subLabel(t.lang || t.language, t.name),
|
| 468 |
+
builtIn: true,
|
| 469 |
+
}))
|
| 470 |
+
);
|
| 471 |
+
};
|
| 472 |
+
hls.on(Hls.Events.MANIFEST_PARSED, (_e: any, data: any) => {
|
| 473 |
+
setLevels(
|
| 474 |
+
dedupeByHeight(hls.levels.map((l, index) => ({ height: l.height, index })))
|
| 475 |
+
);
|
| 476 |
+
// The parsed manifest carries the full alternate-track lists.
|
| 477 |
+
readSubs(data?.subtitleTracks || hls.subtitleTracks);
|
| 478 |
+
playIfWanted();
|
| 479 |
+
});
|
| 480 |
+
hls.on(Hls.Events.LEVEL_SWITCHED, (_e, d) =>
|
| 481 |
+
setCurrentLevel(hls.autoLevelEnabled ? -1 : d.level)
|
| 482 |
+
);
|
| 483 |
+
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_e: any, data: any) =>
|
| 484 |
+
readSubs(data?.subtitleTracks || hls.subtitleTracks)
|
| 485 |
+
);
|
| 486 |
+
const readAudio = () => {
|
| 487 |
+
const tracks = (hls.audioTracks || []).map((t: any, id: number) => ({
|
| 488 |
+
id,
|
| 489 |
+
name: t.name || t.lang || `Audio ${id + 1}`,
|
| 490 |
+
}));
|
| 491 |
+
setAudioTracks(tracks.length > 1 ? tracks : []);
|
| 492 |
+
setCurrentAudio(hls.audioTrack ?? 0);
|
| 493 |
+
};
|
| 494 |
+
hls.on(Hls.Events.MANIFEST_PARSED, readAudio);
|
| 495 |
+
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, readAudio);
|
| 496 |
+
hls.on(Hls.Events.AUDIO_TRACK_SWITCHED, (_e: any, d: any) =>
|
| 497 |
+
setCurrentAudio(d.id)
|
| 498 |
+
);
|
| 499 |
+
// hls.js renders its own subtitle track; keep its display off by default
|
| 500 |
+
hls.subtitleDisplay = false;
|
| 501 |
+
let netRetries = 0;
|
| 502 |
+
hls.on(Hls.Events.ERROR, (_e, data) => {
|
| 503 |
+
if (data.fatal) {
|
| 504 |
+
if (data.type === "networkError" && netRetries++ < 2) hls.startLoad();
|
| 505 |
+
else if (data.type === "mediaError") hls.recoverMediaError();
|
| 506 |
+
// Unrecoverable — give up on this server and fail over to the next.
|
| 507 |
+
else failoverRef.current();
|
| 508 |
+
}
|
| 509 |
+
});
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
async function setupDash() {
|
| 513 |
+
const dashjs = await import("dashjs");
|
| 514 |
+
if (destroyed) return;
|
| 515 |
+
const player = dashjs.MediaPlayer().create();
|
| 516 |
+
dashRef.current = player;
|
| 517 |
+
player.initialize(video!, src, autoPlay || wantPlayRef.current);
|
| 518 |
+
if (wantPlayRef.current) wantPlayRef.current = false;
|
| 519 |
+
player.on("streamInitialized", () => {
|
| 520 |
+
try {
|
| 521 |
+
const list = player.getBitrateInfoListFor("video") || [];
|
| 522 |
+
setLevels(
|
| 523 |
+
dedupeByHeight(
|
| 524 |
+
list.map((b: any) => ({ height: b.height, index: b.qualityIndex }))
|
| 525 |
+
)
|
| 526 |
+
);
|
| 527 |
+
} catch {}
|
| 528 |
+
// dash.js exposes subtitle tracks on the media element
|
| 529 |
+
setTimeout(() => {
|
| 530 |
+
const tt = video!.textTracks;
|
| 531 |
+
const out: { id: number; label: string; builtIn: boolean }[] = [];
|
| 532 |
+
for (let i = 0; i < tt.length; i++) {
|
| 533 |
+
const t = tt[i];
|
| 534 |
+
if (t.kind === "subtitles" || t.kind === "captions") {
|
| 535 |
+
out.push({ id: i, label: subLabel(t.language, t.label), builtIn: true });
|
| 536 |
+
t.mode = "disabled";
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
setSubTracks(out);
|
| 540 |
+
// multi-audio (dub) tracks
|
| 541 |
+
try {
|
| 542 |
+
const at = player.getTracksFor("audio") || [];
|
| 543 |
+
const audio = at.map((t: any, i: number) => ({
|
| 544 |
+
id: typeof t.index === "number" ? t.index : i,
|
| 545 |
+
name:
|
| 546 |
+
(t.labels && t.labels[0]?.text) ||
|
| 547 |
+
t.lang ||
|
| 548 |
+
`Audio ${i + 1}`,
|
| 549 |
+
}));
|
| 550 |
+
setAudioTracks(audio.length > 1 ? audio : []);
|
| 551 |
+
} catch {}
|
| 552 |
+
}, 300);
|
| 553 |
+
});
|
| 554 |
+
player.on("error", () => failoverRef.current());
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
function readNativeTextTracks() {
|
| 558 |
+
const tt = video!.textTracks;
|
| 559 |
+
const list: { id: number; label: string; builtIn: boolean }[] = [];
|
| 560 |
+
for (let i = 0; i < tt.length; i++) {
|
| 561 |
+
const t = tt[i];
|
| 562 |
+
if (t.kind === "subtitles" || t.kind === "captions") {
|
| 563 |
+
list.push({
|
| 564 |
+
id: i,
|
| 565 |
+
label: subLabel(t.language, t.label),
|
| 566 |
+
builtIn: true,
|
| 567 |
+
});
|
| 568 |
+
t.mode = "disabled";
|
| 569 |
+
}
|
| 570 |
+
}
|
| 571 |
+
setSubTracks(list);
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
function setupNative() {
|
| 575 |
+
// Progressive MP4 / WebM / etc.
|
| 576 |
+
video!.src = src;
|
| 577 |
+
const onErr = () => failoverRef.current();
|
| 578 |
+
video!.addEventListener("error", onErr);
|
| 579 |
+
video!.textTracks.addEventListener?.("addtrack", readNativeTextTracks);
|
| 580 |
+
video!.addEventListener("loadedmetadata", readNativeTextTracks);
|
| 581 |
+
video!.addEventListener("canplay", playIfWanted, { once: true });
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
(async () => {
|
| 585 |
+
try {
|
| 586 |
+
if (kind === "hls") await setupHls();
|
| 587 |
+
else if (kind === "dash") await setupDash();
|
| 588 |
+
else setupNative();
|
| 589 |
+
} catch (e) {
|
| 590 |
+
if (!destroyed) setLoadError("Failed to load the player.");
|
| 591 |
+
}
|
| 592 |
+
})();
|
| 593 |
+
|
| 594 |
+
return () => {
|
| 595 |
+
destroyed = true;
|
| 596 |
+
try {
|
| 597 |
+
hlsRef.current?.destroy?.();
|
| 598 |
+
} catch {}
|
| 599 |
+
try {
|
| 600 |
+
dashRef.current?.reset?.();
|
| 601 |
+
} catch {}
|
| 602 |
+
hlsRef.current = null;
|
| 603 |
+
dashRef.current = null;
|
| 604 |
+
};
|
| 605 |
+
}, [activeSrc, resolvedKind, started, autoPlay]);
|
| 606 |
+
|
| 607 |
+
/* ---------------- episodes / next-episode ---------------- */
|
| 608 |
+
// Navigate the iframe to another episode, preserving every query param so the
|
| 609 |
+
// host's feature flags (color, nextEpisode, etc.) carry over.
|
| 610 |
+
const goToEpisode = useCallback(
|
| 611 |
+
(s: number, e: number) => {
|
| 612 |
+
if (typeof window === "undefined" || contentId == null) return;
|
| 613 |
+
const qs = window.location.search;
|
| 614 |
+
window.location.href = `/tv/${contentId}/${s}/${e}${qs}`;
|
| 615 |
+
},
|
| 616 |
+
[contentId]
|
| 617 |
+
);
|
| 618 |
+
|
| 619 |
+
// Compute the next episode (rolls into the next season for TV when known).
|
| 620 |
+
const nextEpisodeTarget = useMemo((): { s: number; e: number } | null => {
|
| 621 |
+
if (!isSeries || !curEpisode) return null;
|
| 622 |
+
const info = seasonsInfo.find((x) => x.season === curSeason);
|
| 623 |
+
if (info && curEpisode >= info.episodeCount) {
|
| 624 |
+
const hasNextSeason = seasonsInfo.some((x) => x.season === curSeason + 1);
|
| 625 |
+
if (hasNextSeason) return { s: curSeason + 1, e: 1 };
|
| 626 |
+
return null; // series finished
|
| 627 |
+
}
|
| 628 |
+
return { s: curSeason, e: curEpisode + 1 };
|
| 629 |
+
}, [isSeries, type, curSeason, curEpisode, seasonsInfo]);
|
| 630 |
+
|
| 631 |
+
// Fetch the TV season list when episode features are on.
|
| 632 |
+
useEffect(() => {
|
| 633 |
+
if (type !== "tv" || contentId == null) return;
|
| 634 |
+
if (!nextEpisode && !episodeSelector && !autoplayNext) return;
|
| 635 |
+
let cancelled = false;
|
| 636 |
+
fetch(`/api/tmdb?action=seasons&type=tv&id=${contentId}`)
|
| 637 |
+
.then((r) => r.json())
|
| 638 |
+
.then((d) => {
|
| 639 |
+
if (!cancelled) setSeasonsInfo(d.seasons || []);
|
| 640 |
+
})
|
| 641 |
+
.catch(() => {});
|
| 642 |
+
return () => {
|
| 643 |
+
cancelled = true;
|
| 644 |
+
};
|
| 645 |
+
}, [type, contentId, nextEpisode, episodeSelector, autoplayNext]);
|
| 646 |
+
|
| 647 |
+
// Lazy-load the episode list for the picker's selected season.
|
| 648 |
+
useEffect(() => {
|
| 649 |
+
if (panel !== "Episodes" || type !== "tv" || contentId == null) return;
|
| 650 |
+
let cancelled = false;
|
| 651 |
+
setPickerEpisodes([]);
|
| 652 |
+
fetch(`/api/tmdb?action=episodes&type=tv&id=${contentId}&season=${pickerSeason}`)
|
| 653 |
+
.then((r) => r.json())
|
| 654 |
+
.then((d) => {
|
| 655 |
+
if (!cancelled) setPickerEpisodes(d.episodes || []);
|
| 656 |
+
})
|
| 657 |
+
.catch(() => {});
|
| 658 |
+
return () => {
|
| 659 |
+
cancelled = true;
|
| 660 |
+
};
|
| 661 |
+
}, [panel, pickerSeason, type, contentId]);
|
| 662 |
+
|
| 663 |
+
/* ---------------- parent-window progress events ---------------- */
|
| 664 |
+
// Posts playback events to the embedding page (documented postMessage API).
|
| 665 |
+
const postEvent = useCallback(
|
| 666 |
+
(eventType: string) => {
|
| 667 |
+
if (typeof window === "undefined" || window.parent === window) return;
|
| 668 |
+
const v = videoRef.current;
|
| 669 |
+
if (!v) return;
|
| 670 |
+
try {
|
| 671 |
+
window.parent.postMessage(
|
| 672 |
+
JSON.stringify({
|
| 673 |
+
type: eventType,
|
| 674 |
+
id: contentId,
|
| 675 |
+
mediaType: type,
|
| 676 |
+
progress: Math.floor(v.currentTime || 0),
|
| 677 |
+
duration: Math.floor(v.duration || 0),
|
| 678 |
+
timestamp: Date.now(),
|
| 679 |
+
season: curSeason || undefined,
|
| 680 |
+
episode: curEpisode || undefined,
|
| 681 |
+
}),
|
| 682 |
+
"*"
|
| 683 |
+
);
|
| 684 |
+
} catch {}
|
| 685 |
+
},
|
| 686 |
+
[contentId, type, curSeason, curEpisode]
|
| 687 |
+
);
|
| 688 |
+
|
| 689 |
+
/* ---------------- idle overlay (?overlay=) ---------------- */
|
| 690 |
+
// Show the overlay instantly whenever playback is paused; hide it otherwise.
|
| 691 |
+
const armOverlay = useCallback(() => {
|
| 692 |
+
if (!overlay) return;
|
| 693 |
+
clearTimeout(overlayTimer.current);
|
| 694 |
+
const v = videoRef.current;
|
| 695 |
+
setShowOverlay(!!v && v.paused && started);
|
| 696 |
+
}, [overlay, started]);
|
| 697 |
+
armOverlayRef.current = armOverlay;
|
| 698 |
+
|
| 699 |
+
/* ---------------- skip intro / recap (?skip_intro=) ---------------- */
|
| 700 |
+
useEffect(() => {
|
| 701 |
+
if (!skipIntro || contentId == null || !duration) return;
|
| 702 |
+
let cancelled = false;
|
| 703 |
+
const qs = new URLSearchParams({
|
| 704 |
+
id: String(contentId),
|
| 705 |
+
duration_ms: String(Math.floor(duration * 1000)),
|
| 706 |
+
});
|
| 707 |
+
if (isSeries) {
|
| 708 |
+
if (curSeason) qs.set("season", String(curSeason));
|
| 709 |
+
if (curEpisode) qs.set("episode", String(curEpisode));
|
| 710 |
+
}
|
| 711 |
+
fetch(`/api/introdb?${qs.toString()}`)
|
| 712 |
+
.then((r) => r.json())
|
| 713 |
+
.then((d) => {
|
| 714 |
+
if (cancelled) return;
|
| 715 |
+
const segs: SkipSeg[] = [];
|
| 716 |
+
const add = (arr: any[], label: string) =>
|
| 717 |
+
(arr || []).forEach((x) =>
|
| 718 |
+
segs.push({
|
| 719 |
+
start: (x.start_ms ?? 0) / 1000,
|
| 720 |
+
// Credits often have a null end (runs to the finish) — fall back
|
| 721 |
+
// to the video duration so the segment is valid.
|
| 722 |
+
end: x.end_ms == null ? duration : x.end_ms / 1000,
|
| 723 |
+
label,
|
| 724 |
+
})
|
| 725 |
+
);
|
| 726 |
+
add(d.intro, "Skip Intro");
|
| 727 |
+
add(d.recap, "Skip Recap");
|
| 728 |
+
add(d.credits, "Skip Credits");
|
| 729 |
+
setSkipSegments(segs.filter((s) => s.end > s.start));
|
| 730 |
+
})
|
| 731 |
+
.catch(() => {});
|
| 732 |
+
return () => {
|
| 733 |
+
cancelled = true;
|
| 734 |
+
};
|
| 735 |
+
}, [skipIntro, contentId, duration, isSeries, curSeason, curEpisode]);
|
| 736 |
+
|
| 737 |
+
// Which skip segment (if any) the playhead is currently inside.
|
| 738 |
+
useEffect(() => {
|
| 739 |
+
if (!skipSegments.length) {
|
| 740 |
+
setActiveSkip(null);
|
| 741 |
+
return;
|
| 742 |
+
}
|
| 743 |
+
const seg = skipSegments.find(
|
| 744 |
+
(s) => current >= s.start && current < s.end - 0.3
|
| 745 |
+
);
|
| 746 |
+
setActiveSkip(seg || null);
|
| 747 |
+
}, [current, skipSegments]);
|
| 748 |
+
|
| 749 |
+
/* ---------------- video events ---------------- */
|
| 750 |
+
useEffect(() => {
|
| 751 |
+
const v = videoRef.current;
|
| 752 |
+
if (!v) return;
|
| 753 |
+
const onTime = () => {
|
| 754 |
+
setCurrent(v.currentTime);
|
| 755 |
+
// hls.js can disable foreign text tracks during setup; keep the bound
|
| 756 |
+
// caption track in "hidden" mode and mirror its cues into our overlay.
|
| 757 |
+
const tr = cueTrackRef.current;
|
| 758 |
+
if (tr) {
|
| 759 |
+
if (tr.mode === "disabled") tr.mode = "hidden";
|
| 760 |
+
renderCues();
|
| 761 |
+
}
|
| 762 |
+
};
|
| 763 |
+
const onDur = () => setDuration(v.duration || 0);
|
| 764 |
+
const onMeta = () => {
|
| 765 |
+
setDuration(v.duration || 0);
|
| 766 |
+
// ?progress= — start at the given second (once, on first load).
|
| 767 |
+
if (
|
| 768 |
+
!didInitialSeekRef.current &&
|
| 769 |
+
startTime > 0 &&
|
| 770 |
+
isFinite(v.duration) &&
|
| 771 |
+
pendingSeekRef.current == null
|
| 772 |
+
) {
|
| 773 |
+
try {
|
| 774 |
+
v.currentTime = Math.min(startTime, v.duration - 1);
|
| 775 |
+
} catch {}
|
| 776 |
+
didInitialSeekRef.current = true;
|
| 777 |
+
}
|
| 778 |
+
// restore position after a quality switch
|
| 779 |
+
if (pendingSeekRef.current != null) {
|
| 780 |
+
try {
|
| 781 |
+
v.currentTime = pendingSeekRef.current;
|
| 782 |
+
} catch {}
|
| 783 |
+
pendingSeekRef.current = null;
|
| 784 |
+
if (wasPlayingRef.current) v.play().catch(() => {});
|
| 785 |
+
}
|
| 786 |
+
};
|
| 787 |
+
const onPlay = () => setPlaying(true);
|
| 788 |
+
const onPause = () => setPlaying(false);
|
| 789 |
+
const onWaiting = () => setBuffering(true);
|
| 790 |
+
const onPlaying = () => setBuffering(false);
|
| 791 |
+
const onProgress = () => {
|
| 792 |
+
if (v.buffered.length) setBuffered(v.buffered.end(v.buffered.length - 1));
|
| 793 |
+
};
|
| 794 |
+
const onVol = () => {
|
| 795 |
+
setVolume(v.volume);
|
| 796 |
+
setMuted(v.muted);
|
| 797 |
+
};
|
| 798 |
+
v.addEventListener("timeupdate", onTime);
|
| 799 |
+
v.addEventListener("durationchange", onDur);
|
| 800 |
+
v.addEventListener("loadedmetadata", onMeta);
|
| 801 |
+
v.addEventListener("play", onPlay);
|
| 802 |
+
v.addEventListener("pause", onPause);
|
| 803 |
+
v.addEventListener("waiting", onWaiting);
|
| 804 |
+
v.addEventListener("playing", onPlaying);
|
| 805 |
+
v.addEventListener("progress", onProgress);
|
| 806 |
+
v.addEventListener("volumechange", onVol);
|
| 807 |
+
return () => {
|
| 808 |
+
v.removeEventListener("timeupdate", onTime);
|
| 809 |
+
v.removeEventListener("durationchange", onDur);
|
| 810 |
+
v.removeEventListener("loadedmetadata", onMeta);
|
| 811 |
+
v.removeEventListener("play", onPlay);
|
| 812 |
+
v.removeEventListener("pause", onPause);
|
| 813 |
+
v.removeEventListener("waiting", onWaiting);
|
| 814 |
+
v.removeEventListener("playing", onPlaying);
|
| 815 |
+
v.removeEventListener("progress", onProgress);
|
| 816 |
+
v.removeEventListener("volumechange", onVol);
|
| 817 |
+
};
|
| 818 |
+
}, [started]);
|
| 819 |
+
|
| 820 |
+
/* playback events: parent postMessage + idle overlay + autoplay-next */
|
| 821 |
+
useEffect(() => {
|
| 822 |
+
const v = videoRef.current;
|
| 823 |
+
if (!v || !started) return;
|
| 824 |
+
let lastPost = 0;
|
| 825 |
+
const onTimePost = () => {
|
| 826 |
+
const now = Date.now();
|
| 827 |
+
if (now - lastPost > 2000) {
|
| 828 |
+
lastPost = now;
|
| 829 |
+
postEvent("timeupdate");
|
| 830 |
+
}
|
| 831 |
+
};
|
| 832 |
+
const onPlay = () => {
|
| 833 |
+
postEvent("play");
|
| 834 |
+
setShowOverlay(false);
|
| 835 |
+
clearTimeout(overlayTimer.current);
|
| 836 |
+
};
|
| 837 |
+
const onPause = () => {
|
| 838 |
+
postEvent("pause");
|
| 839 |
+
armOverlay();
|
| 840 |
+
};
|
| 841 |
+
const onEnded = () => {
|
| 842 |
+
postEvent("ended");
|
| 843 |
+
if (autoplayNext && nextEpisodeTarget) {
|
| 844 |
+
goToEpisode(nextEpisodeTarget.s, nextEpisodeTarget.e);
|
| 845 |
+
}
|
| 846 |
+
};
|
| 847 |
+
v.addEventListener("timeupdate", onTimePost);
|
| 848 |
+
v.addEventListener("play", onPlay);
|
| 849 |
+
v.addEventListener("pause", onPause);
|
| 850 |
+
v.addEventListener("ended", onEnded);
|
| 851 |
+
return () => {
|
| 852 |
+
v.removeEventListener("timeupdate", onTimePost);
|
| 853 |
+
v.removeEventListener("play", onPlay);
|
| 854 |
+
v.removeEventListener("pause", onPause);
|
| 855 |
+
v.removeEventListener("ended", onEnded);
|
| 856 |
+
clearTimeout(overlayTimer.current);
|
| 857 |
+
};
|
| 858 |
+
}, [started, postEvent, armOverlay, autoplayNext, nextEpisodeTarget, goToEpisode]);
|
| 859 |
+
|
| 860 |
+
/* ---------------- fullscreen ---------------- */
|
| 861 |
+
useEffect(() => {
|
| 862 |
+
const onFs = () => setFullscreen(!!document.fullscreenElement);
|
| 863 |
+
document.addEventListener("fullscreenchange", onFs);
|
| 864 |
+
return () => document.removeEventListener("fullscreenchange", onFs);
|
| 865 |
+
}, []);
|
| 866 |
+
|
| 867 |
+
const toggleFullscreen = useCallback(() => {
|
| 868 |
+
if (document.fullscreenElement) document.exitFullscreen();
|
| 869 |
+
else wrapRef.current?.requestFullscreen?.().catch(() => {});
|
| 870 |
+
}, []);
|
| 871 |
+
|
| 872 |
+
/* ---------------- controls ---------------- */
|
| 873 |
+
// Single attempt against one server. Returns true only if it yields a
|
| 874 |
+
// playable source. Records the server so failover can skip it next time.
|
| 875 |
+
const attemptResolve = useCallback(
|
| 876 |
+
async (sv: string): Promise<boolean> => {
|
| 877 |
+
if (contentId == null) return false;
|
| 878 |
+
triedServersRef.current.add(sv);
|
| 879 |
+
setResolving(true);
|
| 880 |
+
try {
|
| 881 |
+
const qs = new URLSearchParams({ type, id: String(contentId), server: sv });
|
| 882 |
+
if (season) qs.set("season", String(season));
|
| 883 |
+
if (episode) qs.set("episode", String(episode));
|
| 884 |
+
|
| 885 |
+
const res = await fetch(`/api/sources?${qs.toString()}`);
|
| 886 |
+
if (!res.ok) throw new Error(String(res.status));
|
| 887 |
+
const json = await res.json();
|
| 888 |
+
const list: ApiSource[] = json.sources || [];
|
| 889 |
+
const best = list[0];
|
| 890 |
+
if (!best?.file) throw new Error("no sources");
|
| 891 |
+
setAudioTracks([]);
|
| 892 |
+
setCurrentAudio(0);
|
| 893 |
+
setResolvedSources(list);
|
| 894 |
+
setQualityIndex(0);
|
| 895 |
+
setApiSubs(json.subtitles || []);
|
| 896 |
+
setResolvedSrc(best.file);
|
| 897 |
+
setResolvedKind(best.type === "mp4" ? "native" : (best.type as any));
|
| 898 |
+
setFailedServers((f) => f.filter((x) => x !== sv)); // recovered
|
| 899 |
+
return true;
|
| 900 |
+
} catch {
|
| 901 |
+
setFailedServers((f) => (f.includes(sv) ? f : [...f, sv]));
|
| 902 |
+
return false;
|
| 903 |
+
} finally {
|
| 904 |
+
setResolving(false);
|
| 905 |
+
}
|
| 906 |
+
},
|
| 907 |
+
[contentId, type, season, episode]
|
| 908 |
+
);
|
| 909 |
+
|
| 910 |
+
// Resolve `sv`, and if it fails, auto-advance through the remaining servers
|
| 911 |
+
// until one works or all are exhausted.
|
| 912 |
+
const resolveSources = useCallback(
|
| 913 |
+
async (sv: string = serverId, force = false): Promise<boolean> => {
|
| 914 |
+
if (resolvedSrc && !force) return true;
|
| 915 |
+
if (contentId == null) return false;
|
| 916 |
+
setLoadError(null);
|
| 917 |
+
let cur = sv;
|
| 918 |
+
for (;;) {
|
| 919 |
+
if (await attemptResolve(cur)) {
|
| 920 |
+
setServerId(cur);
|
| 921 |
+
return true;
|
| 922 |
+
}
|
| 923 |
+
const next = serverOrder.find((s) => !triedServersRef.current.has(s));
|
| 924 |
+
if (!next) {
|
| 925 |
+
setLoadError("Couldn't find a playable source on any server.");
|
| 926 |
+
return false;
|
| 927 |
+
}
|
| 928 |
+
cur = next;
|
| 929 |
+
}
|
| 930 |
+
},
|
| 931 |
+
[serverId, resolvedSrc, contentId, attemptResolve, serverOrder]
|
| 932 |
+
);
|
| 933 |
+
|
| 934 |
+
// Called when a resolved stream fails to actually play — drop it and fail
|
| 935 |
+
// over to the next untried server, preserving the user's intent to play.
|
| 936 |
+
const handlePlaybackFailure = useCallback(() => {
|
| 937 |
+
// The current stream failed to play — flag this server in the menu.
|
| 938 |
+
setFailedServers((f) => (f.includes(serverId) ? f : [...f, serverId]));
|
| 939 |
+
const next = serverOrder.find((s) => !triedServersRef.current.has(s));
|
| 940 |
+
if (!next) {
|
| 941 |
+
setLoadError("This stream could not be played.");
|
| 942 |
+
return;
|
| 943 |
+
}
|
| 944 |
+
const v = videoRef.current;
|
| 945 |
+
if (v) {
|
| 946 |
+
pendingSeekRef.current = v.currentTime;
|
| 947 |
+
wasPlayingRef.current = !v.paused;
|
| 948 |
+
try {
|
| 949 |
+
v.pause();
|
| 950 |
+
} catch {}
|
| 951 |
+
}
|
| 952 |
+
wantPlayRef.current = true;
|
| 953 |
+
setSwitching(true);
|
| 954 |
+
setServerId(next);
|
| 955 |
+
setResolvedSrc(null);
|
| 956 |
+
void resolveSources(next, true).finally(() => setSwitching(false));
|
| 957 |
+
}, [resolveSources, serverOrder, serverId]);
|
| 958 |
+
failoverRef.current = handlePlaybackFailure;
|
| 959 |
+
|
| 960 |
+
// Switch source provider — pause, show a loading curtain, then re-resolve and
|
| 961 |
+
// keep the playback position.
|
| 962 |
+
const switchServer = (id: string) => {
|
| 963 |
+
if (id === serverId) {
|
| 964 |
+
setPanel(null);
|
| 965 |
+
return;
|
| 966 |
+
}
|
| 967 |
+
const v = videoRef.current;
|
| 968 |
+
if (v) {
|
| 969 |
+
pendingSeekRef.current = v.currentTime;
|
| 970 |
+
wasPlayingRef.current = !v.paused;
|
| 971 |
+
wantPlayRef.current = !v.paused;
|
| 972 |
+
try {
|
| 973 |
+
v.pause();
|
| 974 |
+
} catch {}
|
| 975 |
+
}
|
| 976 |
+
setServerId(id);
|
| 977 |
+
setPanel(null);
|
| 978 |
+
setSwitching(true);
|
| 979 |
+
// Manual switch starts a fresh failover cycle from the chosen server.
|
| 980 |
+
triedServersRef.current = new Set();
|
| 981 |
+
void resolveSources(id, true).finally(() => setSwitching(false));
|
| 982 |
+
};
|
| 983 |
+
|
| 984 |
+
// Switch the embedded audio track (multi-dub streams).
|
| 985 |
+
const switchAudio = (id: number) => {
|
| 986 |
+
if (engineRef.current === "hls" && hlsRef.current) {
|
| 987 |
+
hlsRef.current.audioTrack = id;
|
| 988 |
+
} else if (engineRef.current === "dash" && dashRef.current) {
|
| 989 |
+
try {
|
| 990 |
+
const tracks = dashRef.current.getTracksFor("audio") || [];
|
| 991 |
+
const t = tracks.find((x: any) => x.index === id) || tracks[id];
|
| 992 |
+
if (t) dashRef.current.setCurrentTrack(t);
|
| 993 |
+
} catch {}
|
| 994 |
+
}
|
| 995 |
+
setCurrentAudio(id);
|
| 996 |
+
};
|
| 997 |
+
|
| 998 |
+
const start = useCallback(async () => {
|
| 999 |
+
if (started) return;
|
| 1000 |
+
// User pressed play — begin playback as soon as the source is ready.
|
| 1001 |
+
wantPlayRef.current = true;
|
| 1002 |
+
if (src || resolvedSrc) {
|
| 1003 |
+
setStarted(true);
|
| 1004 |
+
return;
|
| 1005 |
+
}
|
| 1006 |
+
// Start from the highest-priority server (favorited first).
|
| 1007 |
+
if (await resolveSources(serverOrder[0])) setStarted(true);
|
| 1008 |
+
else wantPlayRef.current = false;
|
| 1009 |
+
}, [started, src, resolvedSrc, resolveSources, serverOrder]);
|
| 1010 |
+
|
| 1011 |
+
const togglePlay = useCallback(() => {
|
| 1012 |
+
const v = videoRef.current;
|
| 1013 |
+
if (!started) {
|
| 1014 |
+
void start();
|
| 1015 |
+
return;
|
| 1016 |
+
}
|
| 1017 |
+
if (!v) return;
|
| 1018 |
+
if (v.paused) v.play().catch(() => {});
|
| 1019 |
+
else v.pause();
|
| 1020 |
+
}, [started, start]);
|
| 1021 |
+
|
| 1022 |
+
const skip = useCallback((sec: number) => {
|
| 1023 |
+
const v = videoRef.current;
|
| 1024 |
+
if (!v) return;
|
| 1025 |
+
v.currentTime = Math.min(
|
| 1026 |
+
Math.max(0, v.currentTime + sec),
|
| 1027 |
+
v.duration || Infinity
|
| 1028 |
+
);
|
| 1029 |
+
setSkipFlash(sec > 0 ? "fwd" : "back");
|
| 1030 |
+
setTimeout(() => setSkipFlash(null), 450);
|
| 1031 |
+
}, []);
|
| 1032 |
+
|
| 1033 |
+
const toggleMute = useCallback(() => {
|
| 1034 |
+
const v = videoRef.current;
|
| 1035 |
+
if (!v) return;
|
| 1036 |
+
v.muted = !v.muted;
|
| 1037 |
+
}, []);
|
| 1038 |
+
|
| 1039 |
+
const onSeek = useCallback(
|
| 1040 |
+
(clientX: number, el: HTMLElement) => {
|
| 1041 |
+
const v = videoRef.current;
|
| 1042 |
+
if (!v || !duration) return;
|
| 1043 |
+
const rect = el.getBoundingClientRect();
|
| 1044 |
+
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
| 1045 |
+
v.currentTime = ratio * duration;
|
| 1046 |
+
},
|
| 1047 |
+
[duration]
|
| 1048 |
+
);
|
| 1049 |
+
|
| 1050 |
+
/* auto-hide controls */
|
| 1051 |
+
const poke = useCallback(() => {
|
| 1052 |
+
setControlsVisible(true);
|
| 1053 |
+
clearTimeout(hideTimer.current);
|
| 1054 |
+
if (playing && !panel) {
|
| 1055 |
+
hideTimer.current = setTimeout(() => setControlsVisible(false), 3000);
|
| 1056 |
+
}
|
| 1057 |
+
// Reset the idle-overlay countdown on any activity.
|
| 1058 |
+
armOverlayRef.current();
|
| 1059 |
+
}, [playing, panel]);
|
| 1060 |
+
|
| 1061 |
+
useEffect(() => {
|
| 1062 |
+
poke();
|
| 1063 |
+
}, [playing, panel, poke]);
|
| 1064 |
+
|
| 1065 |
+
/* keyboard */
|
| 1066 |
+
useEffect(() => {
|
| 1067 |
+
const onKey = (e: KeyboardEvent) => {
|
| 1068 |
+
if (!started) return;
|
| 1069 |
+
const tag = (e.target as HTMLElement)?.tagName;
|
| 1070 |
+
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
| 1071 |
+
switch (e.key) {
|
| 1072 |
+
case " ":
|
| 1073 |
+
case "k":
|
| 1074 |
+
e.preventDefault();
|
| 1075 |
+
togglePlay();
|
| 1076 |
+
break;
|
| 1077 |
+
case "ArrowLeft":
|
| 1078 |
+
skip(-10);
|
| 1079 |
+
break;
|
| 1080 |
+
case "ArrowRight":
|
| 1081 |
+
skip(10);
|
| 1082 |
+
break;
|
| 1083 |
+
case "ArrowUp": {
|
| 1084 |
+
const v = videoRef.current;
|
| 1085 |
+
if (v) v.volume = Math.min(1, v.volume + 0.1);
|
| 1086 |
+
break;
|
| 1087 |
+
}
|
| 1088 |
+
case "ArrowDown": {
|
| 1089 |
+
const v = videoRef.current;
|
| 1090 |
+
if (v) v.volume = Math.max(0, v.volume - 0.1);
|
| 1091 |
+
break;
|
| 1092 |
+
}
|
| 1093 |
+
case "f":
|
| 1094 |
+
toggleFullscreen();
|
| 1095 |
+
break;
|
| 1096 |
+
case "m":
|
| 1097 |
+
toggleMute();
|
| 1098 |
+
break;
|
| 1099 |
+
case "Escape":
|
| 1100 |
+
setPanel(null);
|
| 1101 |
+
break;
|
| 1102 |
+
}
|
| 1103 |
+
poke();
|
| 1104 |
+
};
|
| 1105 |
+
window.addEventListener("keydown", onKey);
|
| 1106 |
+
return () => window.removeEventListener("keydown", onKey);
|
| 1107 |
+
}, [started, togglePlay, skip, toggleFullscreen, toggleMute, poke]);
|
| 1108 |
+
|
| 1109 |
+
const applyLevel = (idx: number) => {
|
| 1110 |
+
if (engineRef.current === "hls" && hlsRef.current) {
|
| 1111 |
+
hlsRef.current.currentLevel = idx; // -1 = auto
|
| 1112 |
+
} else if (engineRef.current === "dash" && dashRef.current) {
|
| 1113 |
+
const player = dashRef.current;
|
| 1114 |
+
if (idx === -1) {
|
| 1115 |
+
player.updateSettings({
|
| 1116 |
+
streaming: { abr: { autoSwitchBitrate: { video: true } } },
|
| 1117 |
+
});
|
| 1118 |
+
} else {
|
| 1119 |
+
player.updateSettings({
|
| 1120 |
+
streaming: { abr: { autoSwitchBitrate: { video: false } } },
|
| 1121 |
+
});
|
| 1122 |
+
player.setQualityFor("video", idx);
|
| 1123 |
+
}
|
| 1124 |
+
}
|
| 1125 |
+
setCurrentLevel(idx);
|
| 1126 |
+
};
|
| 1127 |
+
|
| 1128 |
+
const applySpeed = (s: number) => {
|
| 1129 |
+
if (videoRef.current) videoRef.current.playbackRate = s;
|
| 1130 |
+
setSpeed(s);
|
| 1131 |
+
};
|
| 1132 |
+
|
| 1133 |
+
// Swap to a different quality file, preserving playback position + state.
|
| 1134 |
+
const switchQuality = (i: number) => {
|
| 1135 |
+
const s = resolvedSources[i];
|
| 1136 |
+
if (!s) return;
|
| 1137 |
+
const v = videoRef.current;
|
| 1138 |
+
if (v) {
|
| 1139 |
+
pendingSeekRef.current = v.currentTime;
|
| 1140 |
+
wasPlayingRef.current = !v.paused;
|
| 1141 |
+
}
|
| 1142 |
+
setQualityIndex(i);
|
| 1143 |
+
setResolvedKind(s.type === "mp4" ? "native" : (s.type as any));
|
| 1144 |
+
setResolvedSrc(s.file); // triggers the source-loading effect
|
| 1145 |
+
setPanel(null);
|
| 1146 |
+
};
|
| 1147 |
+
|
| 1148 |
+
// ---- custom cue rendering ----
|
| 1149 |
+
// Read the active cues off the bound track into our overlay. Stable across
|
| 1150 |
+
// renders (reads the ref), so it can be added/removed as an event listener.
|
| 1151 |
+
const renderCues = useCallback(() => {
|
| 1152 |
+
const tr = cueTrackRef.current;
|
| 1153 |
+
if (!tr) {
|
| 1154 |
+
setCueText("");
|
| 1155 |
+
return;
|
| 1156 |
+
}
|
| 1157 |
+
const delay = subDelayRef.current;
|
| 1158 |
+
// With no delay, the browser's activeCues are authoritative (cheap). With a
|
| 1159 |
+
// delay we scan all cues against (currentTime − delay) ourselves.
|
| 1160 |
+
let active: (TextTrackCue | VTTCue)[] = [];
|
| 1161 |
+
if (delay !== 0 && tr.cues) {
|
| 1162 |
+
const t = (videoRef.current?.currentTime || 0) - delay;
|
| 1163 |
+
for (let i = 0; i < tr.cues.length; i++) {
|
| 1164 |
+
const c = tr.cues[i];
|
| 1165 |
+
if (c.startTime <= t && t <= c.endTime) active.push(c);
|
| 1166 |
+
}
|
| 1167 |
+
} else if (tr.activeCues) {
|
| 1168 |
+
active = Array.from(tr.activeCues);
|
| 1169 |
+
}
|
| 1170 |
+
if (active.length === 0) {
|
| 1171 |
+
setCueText("");
|
| 1172 |
+
return;
|
| 1173 |
+
}
|
| 1174 |
+
let txt = "";
|
| 1175 |
+
for (let i = 0; i < active.length; i++) {
|
| 1176 |
+
const c = active[i] as VTTCue;
|
| 1177 |
+
txt += (i ? "\n" : "") + (c.text || "");
|
| 1178 |
+
}
|
| 1179 |
+
// strip simple VTT/SRT tags; keep the text + line breaks.
|
| 1180 |
+
setCueText(txt.replace(/<[^>]+>/g, ""));
|
| 1181 |
+
}, []);
|
| 1182 |
+
|
| 1183 |
+
// Bind a TextTrack for custom rendering: keep it "hidden" (parsed, not drawn
|
| 1184 |
+
// natively) and mirror its cues into our overlay.
|
| 1185 |
+
const bindTrack = useCallback(
|
| 1186 |
+
(track: TextTrack | null) => {
|
| 1187 |
+
if (cueTrackRef.current && cueTrackRef.current !== track) {
|
| 1188 |
+
cueTrackRef.current.removeEventListener("cuechange", renderCues);
|
| 1189 |
+
}
|
| 1190 |
+
cueTrackRef.current = track;
|
| 1191 |
+
if (track) {
|
| 1192 |
+
track.mode = "hidden";
|
| 1193 |
+
track.removeEventListener("cuechange", renderCues);
|
| 1194 |
+
track.addEventListener("cuechange", renderCues);
|
| 1195 |
+
}
|
| 1196 |
+
renderCues();
|
| 1197 |
+
},
|
| 1198 |
+
[renderCues]
|
| 1199 |
+
);
|
| 1200 |
+
|
| 1201 |
+
// Safety net: while subtitles are on, keep the bound track in "hidden" mode
|
| 1202 |
+
// (some engines flip it back to "disabled") and mirror its cues into our
|
| 1203 |
+
// overlay on a short interval. This guarantees captions render even when
|
| 1204 |
+
// paused or when a `cuechange`/`timeupdate` event is missed.
|
| 1205 |
+
useEffect(() => {
|
| 1206 |
+
if (!subsOn) return;
|
| 1207 |
+
const sync = () => {
|
| 1208 |
+
const tr = cueTrackRef.current;
|
| 1209 |
+
if (tr && tr.mode === "disabled") tr.mode = "hidden";
|
| 1210 |
+
renderCues();
|
| 1211 |
+
};
|
| 1212 |
+
sync();
|
| 1213 |
+
const interval = setInterval(sync, 300);
|
| 1214 |
+
return () => clearInterval(interval);
|
| 1215 |
+
}, [subsOn, activeApiSub, activeSub, subDelay, renderCues]);
|
| 1216 |
+
|
| 1217 |
+
const subsOff = () => {
|
| 1218 |
+
const v = videoRef.current;
|
| 1219 |
+
if (engineRef.current === "hls" && hlsRef.current) {
|
| 1220 |
+
hlsRef.current.subtitleDisplay = false;
|
| 1221 |
+
hlsRef.current.subtitleTrack = -1;
|
| 1222 |
+
}
|
| 1223 |
+
if (v) {
|
| 1224 |
+
for (let i = 0; i < v.textTracks.length; i++) v.textTracks[i].mode = "disabled";
|
| 1225 |
+
v.querySelectorAll("track[data-api]").forEach((t) => t.remove());
|
| 1226 |
+
}
|
| 1227 |
+
bindTrack(null);
|
| 1228 |
+
setActiveSub(-1);
|
| 1229 |
+
setActiveApiSub(-1);
|
| 1230 |
+
setSubsOn(false);
|
| 1231 |
+
userPickedSubRef.current = true;
|
| 1232 |
+
};
|
| 1233 |
+
|
| 1234 |
+
// Built-in track (HLS/DASH/native manifest subtitle).
|
| 1235 |
+
const selectSub = (id: number) => {
|
| 1236 |
+
const v = videoRef.current;
|
| 1237 |
+
if (!v) return;
|
| 1238 |
+
v.querySelectorAll("track[data-api]").forEach((t) => t.remove());
|
| 1239 |
+
if (engineRef.current === "hls" && hlsRef.current) {
|
| 1240 |
+
// Let hls fetch the cues but render them ourselves.
|
| 1241 |
+
hlsRef.current.subtitleDisplay = false;
|
| 1242 |
+
hlsRef.current.subtitleTrack = id;
|
| 1243 |
+
// hls sets the matching text track to "hidden"; grab it once ready.
|
| 1244 |
+
setTimeout(() => {
|
| 1245 |
+
let chosen: TextTrack | null = null;
|
| 1246 |
+
for (let i = 0; i < v.textTracks.length; i++) {
|
| 1247 |
+
const t = v.textTracks[i];
|
| 1248 |
+
if (t.mode !== "disabled") chosen = t;
|
| 1249 |
+
else t.mode = "disabled";
|
| 1250 |
+
}
|
| 1251 |
+
bindTrack(chosen);
|
| 1252 |
+
}, 60);
|
| 1253 |
+
} else {
|
| 1254 |
+
const tt = v.textTracks[id] || null;
|
| 1255 |
+
for (let i = 0; i < v.textTracks.length; i++)
|
| 1256 |
+
v.textTracks[i].mode = "disabled";
|
| 1257 |
+
bindTrack(tt);
|
| 1258 |
+
}
|
| 1259 |
+
setActiveSub(id);
|
| 1260 |
+
setActiveApiSub(-1);
|
| 1261 |
+
setSubsOn(true);
|
| 1262 |
+
userPickedSubRef.current = true;
|
| 1263 |
+
};
|
| 1264 |
+
|
| 1265 |
+
// Create a same-origin WebVTT <track> for an external subtitle URL and render
|
| 1266 |
+
// it through the custom cue overlay.
|
| 1267 |
+
const loadSubTrack = (url: string, display: string, language: string) => {
|
| 1268 |
+
const v = videoRef.current;
|
| 1269 |
+
if (!v) return;
|
| 1270 |
+
if (engineRef.current === "hls" && hlsRef.current) {
|
| 1271 |
+
hlsRef.current.subtitleDisplay = false;
|
| 1272 |
+
hlsRef.current.subtitleTrack = -1;
|
| 1273 |
+
}
|
| 1274 |
+
for (let k = 0; k < v.textTracks.length; k++) v.textTracks[k].mode = "disabled";
|
| 1275 |
+
v.querySelectorAll("track[data-api]").forEach((t) => t.remove());
|
| 1276 |
+
|
| 1277 |
+
const track = document.createElement("track");
|
| 1278 |
+
track.kind = "subtitles";
|
| 1279 |
+
track.label = display;
|
| 1280 |
+
track.srclang = language || "en";
|
| 1281 |
+
track.src = url;
|
| 1282 |
+
track.setAttribute("data-api", "1");
|
| 1283 |
+
v.appendChild(track);
|
| 1284 |
+
// The TextTrack appears asynchronously; bind once cues start parsing.
|
| 1285 |
+
const trySelect = (attempt = 0) => {
|
| 1286 |
+
const tt = track.track;
|
| 1287 |
+
if (tt) bindTrack(tt);
|
| 1288 |
+
else if (attempt < 10) setTimeout(() => trySelect(attempt + 1), 60);
|
| 1289 |
+
};
|
| 1290 |
+
trySelect();
|
| 1291 |
+
setSubsOn(true);
|
| 1292 |
+
};
|
| 1293 |
+
|
| 1294 |
+
// Load a Wyzie subtitle and render it.
|
| 1295 |
+
const selectApiSub = (i: number, auto = false) => {
|
| 1296 |
+
const sub = apiSubs[i];
|
| 1297 |
+
if (!sub) return;
|
| 1298 |
+
loadSubTrack(sub.url, sub.display, sub.language);
|
| 1299 |
+
setActiveApiSub(i);
|
| 1300 |
+
setActiveSub(-1);
|
| 1301 |
+
if (!auto) userPickedSubRef.current = true;
|
| 1302 |
+
};
|
| 1303 |
+
|
| 1304 |
+
// Picks the best default Wyzie track: English, non-hearing-impaired first.
|
| 1305 |
+
const defaultSubIndex = (subs: ApiSub[]): number => {
|
| 1306 |
+
const en = (s: ApiSub) =>
|
| 1307 |
+
(s.language || "").toLowerCase().startsWith("en") ||
|
| 1308 |
+
/english/i.test(s.display || "");
|
| 1309 |
+
let idx = subs.findIndex((s) => en(s) && !s.hi);
|
| 1310 |
+
if (idx < 0) idx = subs.findIndex((s) => en(s));
|
| 1311 |
+
return idx;
|
| 1312 |
+
};
|
| 1313 |
+
|
| 1314 |
+
// Auto-load the default subtitle once sources resolve (unless the user has
|
| 1315 |
+
// already made a choice). Wyzie is the default source for every server.
|
| 1316 |
+
useEffect(() => {
|
| 1317 |
+
if (!started || userPickedSubRef.current) return;
|
| 1318 |
+
if (activeApiSub !== -1 || activeSub !== -1) return;
|
| 1319 |
+
if (!apiSubs.length) return;
|
| 1320 |
+
const idx = defaultSubIndex(apiSubs);
|
| 1321 |
+
if (idx >= 0) {
|
| 1322 |
+
// Wait a tick so the <video> element and current source are mounted.
|
| 1323 |
+
const t = setTimeout(() => selectApiSub(idx, true), 200);
|
| 1324 |
+
return () => clearTimeout(t);
|
| 1325 |
+
}
|
| 1326 |
+
}, [apiSubs, started]);
|
| 1327 |
+
|
| 1328 |
+
const onUploadSubs = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 1329 |
+
const file = e.target.files?.[0];
|
| 1330 |
+
const v = videoRef.current;
|
| 1331 |
+
if (!file || !v) return;
|
| 1332 |
+
const url = URL.createObjectURL(file);
|
| 1333 |
+
// remove existing custom tracks
|
| 1334 |
+
if (engineRef.current === "hls" && hlsRef.current) {
|
| 1335 |
+
hlsRef.current.subtitleDisplay = false;
|
| 1336 |
+
hlsRef.current.subtitleTrack = -1;
|
| 1337 |
+
}
|
| 1338 |
+
[...v.querySelectorAll("track")].forEach((t) => t.remove());
|
| 1339 |
+
for (let k = 0; k < v.textTracks.length; k++) v.textTracks[k].mode = "disabled";
|
| 1340 |
+
const track = document.createElement("track");
|
| 1341 |
+
track.kind = "subtitles";
|
| 1342 |
+
track.label = "Custom";
|
| 1343 |
+
track.srclang = "en";
|
| 1344 |
+
track.src = url;
|
| 1345 |
+
track.setAttribute("data-api", "1");
|
| 1346 |
+
v.appendChild(track);
|
| 1347 |
+
const trySelect = (attempt = 0) => {
|
| 1348 |
+
if (track.track) bindTrack(track.track);
|
| 1349 |
+
else if (attempt < 10) setTimeout(() => trySelect(attempt + 1), 60);
|
| 1350 |
+
};
|
| 1351 |
+
trySelect();
|
| 1352 |
+
setActiveApiSub(-1);
|
| 1353 |
+
setActiveSub(-1);
|
| 1354 |
+
setSubsOn(true);
|
| 1355 |
+
setPanel(null);
|
| 1356 |
+
userPickedSubRef.current = true;
|
| 1357 |
+
};
|
| 1358 |
+
|
| 1359 |
+
const pct = duration ? (current / duration) * 100 : 0;
|
| 1360 |
+
const bufPct = duration ? (buffered / duration) * 100 : 0;
|
| 1361 |
+
|
| 1362 |
+
const toggleFav = (name: string) =>
|
| 1363 |
+
setFavorites((f) => {
|
| 1364 |
+
const next = f.includes(name) ? f.filter((x) => x !== name) : [...f, name];
|
| 1365 |
+
try {
|
| 1366 |
+
localStorage.setItem("vc-fav-servers", JSON.stringify(next));
|
| 1367 |
+
} catch {}
|
| 1368 |
+
return next;
|
| 1369 |
+
});
|
| 1370 |
+
|
| 1371 |
+
/* CSS var for accent so child styles can reference it */
|
| 1372 |
+
const accentStyle = { ["--accent" as any]: accent } as React.CSSProperties;
|
| 1373 |
+
|
| 1374 |
+
return (
|
| 1375 |
+
<div
|
| 1376 |
+
ref={wrapRef}
|
| 1377 |
+
style={accentStyle}
|
| 1378 |
+
className="group relative h-full w-full select-none overflow-hidden bg-black"
|
| 1379 |
+
onMouseMove={poke}
|
| 1380 |
+
onMouseLeave={() => playing && !panel && setControlsVisible(false)}
|
| 1381 |
+
>
|
| 1382 |
+
{/* video */}
|
| 1383 |
+
<video
|
| 1384 |
+
ref={videoRef}
|
| 1385 |
+
className="absolute inset-0 h-full w-full bg-black"
|
| 1386 |
+
playsInline
|
| 1387 |
+
onClick={() => {
|
| 1388 |
+
if (panel) setPanel(null);
|
| 1389 |
+
else togglePlay();
|
| 1390 |
+
}}
|
| 1391 |
+
onDoubleClick={toggleFullscreen}
|
| 1392 |
+
poster={poster || undefined}
|
| 1393 |
+
/>
|
| 1394 |
+
|
| 1395 |
+
{/* poster / start overlay */}
|
| 1396 |
+
{!started && (
|
| 1397 |
+
<button
|
| 1398 |
+
onClick={togglePlay}
|
| 1399 |
+
disabled={resolving}
|
| 1400 |
+
className="absolute inset-0 z-20 flex flex-col items-center justify-center"
|
| 1401 |
+
>
|
| 1402 |
+
{poster && (
|
| 1403 |
+
/* eslint-disable-next-line @next/next/no-img-element */
|
| 1404 |
+
<img
|
| 1405 |
+
src={poster}
|
| 1406 |
+
alt={title || ""}
|
| 1407 |
+
className="absolute inset-0 h-full w-full object-cover"
|
| 1408 |
+
/>
|
| 1409 |
+
)}
|
| 1410 |
+
<span className="absolute inset-0 bg-black/40" />
|
| 1411 |
+
<span className="relative flex h-16 w-16 items-center justify-center rounded-full bg-white text-black shadow-2xl transition-transform hover:scale-110">
|
| 1412 |
+
{resolving ? (
|
| 1413 |
+
<span
|
| 1414 |
+
className="h-7 w-7 animate-spin rounded-full border-[3px] border-black/20"
|
| 1415 |
+
style={{ borderTopColor: "#000" }}
|
| 1416 |
+
/>
|
| 1417 |
+
) : (
|
| 1418 |
+
<PlayIcon className="ml-1 h-7 w-7" />
|
| 1419 |
+
)}
|
| 1420 |
+
</span>
|
| 1421 |
+
{title && (
|
| 1422 |
+
<span className="relative mt-4 text-center">
|
| 1423 |
+
<span className="block text-lg font-bold text-white drop-shadow">
|
| 1424 |
+
{title}
|
| 1425 |
+
</span>
|
| 1426 |
+
{year && (
|
| 1427 |
+
<span className="block text-sm text-white/70">{year}</span>
|
| 1428 |
+
)}
|
| 1429 |
+
</span>
|
| 1430 |
+
)}
|
| 1431 |
+
</button>
|
| 1432 |
+
)}
|
| 1433 |
+
|
| 1434 |
+
{/* buffering spinner */}
|
| 1435 |
+
{started && buffering && !switching && (
|
| 1436 |
+
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
|
| 1437 |
+
<span
|
| 1438 |
+
className="h-12 w-12 animate-spin rounded-full border-[3px] border-white/25"
|
| 1439 |
+
style={{ borderTopColor: "#fff" }}
|
| 1440 |
+
/>
|
| 1441 |
+
</div>
|
| 1442 |
+
)}
|
| 1443 |
+
|
| 1444 |
+
{/* source-switching curtain */}
|
| 1445 |
+
{started && switching && (
|
| 1446 |
+
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-4 bg-black/80 text-center">
|
| 1447 |
+
<span
|
| 1448 |
+
className="h-12 w-12 animate-spin rounded-full border-[3px] border-white/25"
|
| 1449 |
+
style={{ borderTopColor: accent }}
|
| 1450 |
+
/>
|
| 1451 |
+
<div>
|
| 1452 |
+
<p className="text-sm font-semibold text-white/90">
|
| 1453 |
+
Loading new source…
|
| 1454 |
+
</p>
|
| 1455 |
+
<p className="mt-1 text-xs text-white/50">
|
| 1456 |
+
Switching to {serverName(serverId, providers)}
|
| 1457 |
+
</p>
|
| 1458 |
+
</div>
|
| 1459 |
+
</div>
|
| 1460 |
+
)}
|
| 1461 |
+
|
| 1462 |
+
{/* skip intro / recap button — z-40 so it sits above the controls bar */}
|
| 1463 |
+
{started && skipIntro && activeSkip && !switching && (
|
| 1464 |
+
<button
|
| 1465 |
+
onClick={(e) => {
|
| 1466 |
+
e.stopPropagation();
|
| 1467 |
+
const v = videoRef.current;
|
| 1468 |
+
const target = activeSkip;
|
| 1469 |
+
if (v && target) {
|
| 1470 |
+
const dest = Math.max(0, target.end);
|
| 1471 |
+
const seekTo = () => {
|
| 1472 |
+
try {
|
| 1473 |
+
v.currentTime = dest;
|
| 1474 |
+
} catch {}
|
| 1475 |
+
};
|
| 1476 |
+
seekTo();
|
| 1477 |
+
// HLS may clamp a forward seek until the manifest is seekable;
|
| 1478 |
+
// retry once on the next tick.
|
| 1479 |
+
if (Math.abs(v.currentTime - dest) > 1) {
|
| 1480 |
+
setTimeout(seekTo, 120);
|
| 1481 |
+
}
|
| 1482 |
+
v.play().catch(() => {});
|
| 1483 |
+
}
|
| 1484 |
+
setActiveSkip(null);
|
| 1485 |
+
}}
|
| 1486 |
+
className="absolute bottom-28 right-4 z-40 flex items-center gap-2 rounded-lg border border-white/20 bg-black/75 px-5 py-2.5 text-sm font-bold text-white shadow-xl backdrop-blur transition hover:bg-black/95 sm:bottom-32 sm:right-6"
|
| 1487 |
+
>
|
| 1488 |
+
{activeSkip.label}
|
| 1489 |
+
<ChevronRightIcon className="h-4 w-4" />
|
| 1490 |
+
</button>
|
| 1491 |
+
)}
|
| 1492 |
+
|
| 1493 |
+
{/* load error */}
|
| 1494 |
+
{started && loadError && (
|
| 1495 |
+
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/70 px-6 text-center">
|
| 1496 |
+
<p className="text-sm text-white/80">{loadError}</p>
|
| 1497 |
+
<button
|
| 1498 |
+
onClick={() => {
|
| 1499 |
+
// Fresh failover cycle from the first server.
|
| 1500 |
+
triedServersRef.current = new Set();
|
| 1501 |
+
wantPlayRef.current = true;
|
| 1502 |
+
setLoadError(null);
|
| 1503 |
+
setResolvedSrc(null);
|
| 1504 |
+
void resolveSources(serverOrder[0], true);
|
| 1505 |
+
}}
|
| 1506 |
+
className="rounded-full px-4 py-2 text-sm font-semibold text-white"
|
| 1507 |
+
style={{ backgroundColor: accent }}
|
| 1508 |
+
>
|
| 1509 |
+
Retry
|
| 1510 |
+
</button>
|
| 1511 |
+
</div>
|
| 1512 |
+
)}
|
| 1513 |
+
|
| 1514 |
+
{/* skip flash */}
|
| 1515 |
+
{skipFlash && (
|
| 1516 |
+
<div
|
| 1517 |
+
className={`pointer-events-none absolute top-1/2 z-10 -translate-y-1/2 ${
|
| 1518 |
+
skipFlash === "fwd" ? "right-[22%]" : "left-[22%]"
|
| 1519 |
+
} flex h-20 w-20 items-center justify-center rounded-full bg-white/10 text-white animate-fade-in-slow`}
|
| 1520 |
+
>
|
| 1521 |
+
{skipFlash === "fwd" ? (
|
| 1522 |
+
<Forward10Icon className="h-9 w-9" />
|
| 1523 |
+
) : (
|
| 1524 |
+
<Rewind10Icon className="h-9 w-9" />
|
| 1525 |
+
)}
|
| 1526 |
+
</div>
|
| 1527 |
+
)}
|
| 1528 |
+
|
| 1529 |
+
{/* floating subtitles — rendered above the controls/progress bar */}
|
| 1530 |
+
{started && cueText && (
|
| 1531 |
+
<div
|
| 1532 |
+
className="pointer-events-none absolute inset-x-0 z-20 flex justify-center px-[6%] text-center transition-[bottom] duration-200"
|
| 1533 |
+
style={{
|
| 1534 |
+
// The position slider always applies; when the controls/panel are
|
| 1535 |
+
// visible we lift the subs by the control-bar height so they clear it.
|
| 1536 |
+
bottom:
|
| 1537 |
+
controlsVisible || panel
|
| 1538 |
+
? `calc(${subStyle.position}% + 4.25rem)`
|
| 1539 |
+
: `${subStyle.position}%`,
|
| 1540 |
+
}}
|
| 1541 |
+
>
|
| 1542 |
+
<span style={captionTextStyle(subStyle)}>{cueText}</span>
|
| 1543 |
+
</div>
|
| 1544 |
+
)}
|
| 1545 |
+
|
| 1546 |
+
{/* Netflix-style idle overlay (?overlay=) — appears instantly on pause */}
|
| 1547 |
+
{started && showOverlay && overlay && !panel && (
|
| 1548 |
+
<div
|
| 1549 |
+
className="absolute inset-0 z-30 flex flex-col justify-end bg-gradient-to-r from-black/95 via-black/70 to-transparent px-8 pb-24 pt-8 sm:px-12 sm:pb-28"
|
| 1550 |
+
onClick={() => {
|
| 1551 |
+
setShowOverlay(false);
|
| 1552 |
+
videoRef.current?.play().catch(() => {});
|
| 1553 |
+
}}
|
| 1554 |
+
>
|
| 1555 |
+
<p className="text-xs font-semibold uppercase tracking-widest text-white/50">
|
| 1556 |
+
You're watching
|
| 1557 |
+
</p>
|
| 1558 |
+
|
| 1559 |
+
{/* title logo image, falling back to plain title text */}
|
| 1560 |
+
{logo ? (
|
| 1561 |
+
/* eslint-disable-next-line @next/next/no-img-element */
|
| 1562 |
+
<img
|
| 1563 |
+
src={logo}
|
| 1564 |
+
alt={title || ""}
|
| 1565 |
+
className="mt-2 max-h-24 w-auto max-w-[60%] object-contain object-left drop-shadow-lg"
|
| 1566 |
+
/>
|
| 1567 |
+
) : (
|
| 1568 |
+
title && (
|
| 1569 |
+
<h2 className="mt-1 max-w-xl text-3xl font-black text-white sm:text-4xl">
|
| 1570 |
+
{title}
|
| 1571 |
+
</h2>
|
| 1572 |
+
)
|
| 1573 |
+
)}
|
| 1574 |
+
|
| 1575 |
+
{/* meta line: golden star + rating · season/episode · runtime */}
|
| 1576 |
+
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm font-medium text-white/70">
|
| 1577 |
+
{rating != null && rating > 0 && (
|
| 1578 |
+
<span className="flex items-center gap-1">
|
| 1579 |
+
<StarIcon className="h-4 w-4" style={{ color: "#f5c518" }} />
|
| 1580 |
+
<span className="text-white/90">{rating.toFixed(1)}</span>
|
| 1581 |
+
</span>
|
| 1582 |
+
)}
|
| 1583 |
+
{isSeries && curSeason > 0 && curEpisode > 0 && (
|
| 1584 |
+
<>
|
| 1585 |
+
{rating != null && rating > 0 && <span className="text-white/30">·</span>}
|
| 1586 |
+
<span>
|
| 1587 |
+
Season {curSeason} · Episode {curEpisode}
|
| 1588 |
+
</span>
|
| 1589 |
+
</>
|
| 1590 |
+
)}
|
| 1591 |
+
{!isSeries && year && (
|
| 1592 |
+
<>
|
| 1593 |
+
{rating != null && rating > 0 && <span className="text-white/30">·</span>}
|
| 1594 |
+
<span>{year}</span>
|
| 1595 |
+
</>
|
| 1596 |
+
)}
|
| 1597 |
+
{fmtRuntime(runtime) && (
|
| 1598 |
+
<>
|
| 1599 |
+
<span className="text-white/30">·</span>
|
| 1600 |
+
<span>{fmtRuntime(runtime)}</span>
|
| 1601 |
+
</>
|
| 1602 |
+
)}
|
| 1603 |
+
</div>
|
| 1604 |
+
|
| 1605 |
+
{episodeTitle && (
|
| 1606 |
+
<h3 className="mt-2 max-w-xl text-xl font-bold text-white">
|
| 1607 |
+
{episodeTitle}
|
| 1608 |
+
</h3>
|
| 1609 |
+
)}
|
| 1610 |
+
{overview && (
|
| 1611 |
+
<p className="mt-2 line-clamp-3 max-w-xl text-sm leading-relaxed text-white/70">
|
| 1612 |
+
{overview}
|
| 1613 |
+
</p>
|
| 1614 |
+
)}
|
| 1615 |
+
</div>
|
| 1616 |
+
)}
|
| 1617 |
+
|
| 1618 |
+
{/* dim when panel open */}
|
| 1619 |
+
{panel && panel !== "CaptionStyle" && (
|
| 1620 |
+
<div
|
| 1621 |
+
className="absolute inset-0 z-20 bg-black/50"
|
| 1622 |
+
onClick={() => setPanel(null)}
|
| 1623 |
+
/>
|
| 1624 |
+
)}
|
| 1625 |
+
|
| 1626 |
+
{/* ---------------- Controls ---------------- */}
|
| 1627 |
+
{started && (
|
| 1628 |
+
<div
|
| 1629 |
+
className={`absolute inset-x-0 bottom-0 z-30 bg-gradient-to-t from-black/90 via-black/40 to-transparent px-3 pb-3 pt-16 transition-opacity duration-300 sm:px-5 sm:pb-4 ${
|
| 1630 |
+
controlsVisible || panel ? "opacity-100" : "pointer-events-none opacity-0"
|
| 1631 |
+
}`}
|
| 1632 |
+
>
|
| 1633 |
+
{/* progress bar */}
|
| 1634 |
+
<div className="mb-2 flex items-center gap-3">
|
| 1635 |
+
<div
|
| 1636 |
+
className="group/seek relative h-4 flex-1 cursor-pointer"
|
| 1637 |
+
onClick={(e) => onSeek(e.clientX, e.currentTarget)}
|
| 1638 |
+
onMouseMove={(e) => {
|
| 1639 |
+
if (!duration) return;
|
| 1640 |
+
const rect = e.currentTarget.getBoundingClientRect();
|
| 1641 |
+
const ratio = Math.min(
|
| 1642 |
+
1,
|
| 1643 |
+
Math.max(0, (e.clientX - rect.left) / rect.width)
|
| 1644 |
+
);
|
| 1645 |
+
setHoverTime({ x: e.clientX - rect.left, t: ratio * duration });
|
| 1646 |
+
}}
|
| 1647 |
+
onMouseLeave={() => setHoverTime(null)}
|
| 1648 |
+
>
|
| 1649 |
+
<div className="absolute top-1/2 h-1 w-full -translate-y-1/2 rounded-full bg-white/25 transition-all group-hover/seek:h-1.5">
|
| 1650 |
+
<div
|
| 1651 |
+
className="absolute inset-y-0 left-0 rounded-full bg-white/30"
|
| 1652 |
+
style={{ width: `${bufPct}%` }}
|
| 1653 |
+
/>
|
| 1654 |
+
<div
|
| 1655 |
+
className="absolute inset-y-0 left-0 rounded-full"
|
| 1656 |
+
style={{ width: `${pct}%`, backgroundColor: accent }}
|
| 1657 |
+
/>
|
| 1658 |
+
<div
|
| 1659 |
+
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 scale-0 rounded-full bg-white shadow transition-transform group-hover/seek:scale-100"
|
| 1660 |
+
style={{ left: `${pct}%` }}
|
| 1661 |
+
/>
|
| 1662 |
+
</div>
|
| 1663 |
+
{hoverTime && (
|
| 1664 |
+
<div
|
| 1665 |
+
className="pointer-events-none absolute bottom-5 -translate-x-1/2 rounded bg-black/90 px-1.5 py-0.5 font-sans text-xs font-medium text-white"
|
| 1666 |
+
style={{ left: hoverTime.x }}
|
| 1667 |
+
>
|
| 1668 |
+
{fmt(hoverTime.t)}
|
| 1669 |
+
</div>
|
| 1670 |
+
)}
|
| 1671 |
+
</div>
|
| 1672 |
+
<span className="shrink-0 font-sans text-[13px] font-medium tabular-nums text-white/90 sm:text-sm">
|
| 1673 |
+
{fmt(current)} / {fmt(duration)}
|
| 1674 |
+
</span>
|
| 1675 |
+
</div>
|
| 1676 |
+
|
| 1677 |
+
{/* buttons row */}
|
| 1678 |
+
<div className="flex items-center justify-between">
|
| 1679 |
+
<div className="flex items-center gap-1 sm:gap-2">
|
| 1680 |
+
<CtrlBtn onClick={togglePlay} label={playing ? "Pause" : "Play"}>
|
| 1681 |
+
{playing ? (
|
| 1682 |
+
<PauseIcon className="h-6 w-6" />
|
| 1683 |
+
) : (
|
| 1684 |
+
<PlayIcon className="h-6 w-6" />
|
| 1685 |
+
)}
|
| 1686 |
+
</CtrlBtn>
|
| 1687 |
+
<CtrlBtn onClick={() => skip(-10)} label="Rewind 10s">
|
| 1688 |
+
<Rewind10Icon className="h-7 w-7" />
|
| 1689 |
+
</CtrlBtn>
|
| 1690 |
+
<CtrlBtn onClick={() => skip(10)} label="Forward 10s">
|
| 1691 |
+
<Forward10Icon className="h-7 w-7" />
|
| 1692 |
+
</CtrlBtn>
|
| 1693 |
+
<div className="group/vol flex items-center">
|
| 1694 |
+
<CtrlBtn onClick={toggleMute} label="Mute">
|
| 1695 |
+
{muted || volume === 0 ? (
|
| 1696 |
+
<VolumeMutedIcon className="h-7 w-7" />
|
| 1697 |
+
) : (
|
| 1698 |
+
<VolumeHighIcon className="h-7 w-7" />
|
| 1699 |
+
)}
|
| 1700 |
+
</CtrlBtn>
|
| 1701 |
+
<input
|
| 1702 |
+
type="range"
|
| 1703 |
+
min={0}
|
| 1704 |
+
max={1}
|
| 1705 |
+
step={0.05}
|
| 1706 |
+
value={muted ? 0 : volume}
|
| 1707 |
+
onChange={(e) => {
|
| 1708 |
+
const v = videoRef.current;
|
| 1709 |
+
if (!v) return;
|
| 1710 |
+
v.volume = Number(e.target.value);
|
| 1711 |
+
v.muted = Number(e.target.value) === 0;
|
| 1712 |
+
}}
|
| 1713 |
+
className="w-0 cursor-pointer opacity-0 transition-all duration-200 group-hover/vol:ml-1 group-hover/vol:w-16 group-hover/vol:opacity-100"
|
| 1714 |
+
style={{ accentColor: accent }}
|
| 1715 |
+
aria-label="Volume"
|
| 1716 |
+
/>
|
| 1717 |
+
</div>
|
| 1718 |
+
</div>
|
| 1719 |
+
|
| 1720 |
+
<div className="flex items-center gap-1 sm:gap-2">
|
| 1721 |
+
{nextEpisode && nextEpisodeTarget && (
|
| 1722 |
+
<CtrlBtn
|
| 1723 |
+
onClick={() =>
|
| 1724 |
+
goToEpisode(nextEpisodeTarget.s, nextEpisodeTarget.e)
|
| 1725 |
+
}
|
| 1726 |
+
label="Next episode"
|
| 1727 |
+
>
|
| 1728 |
+
<NextEpisodeIcon className="h-6 w-6" />
|
| 1729 |
+
</CtrlBtn>
|
| 1730 |
+
)}
|
| 1731 |
+
{episodeSelector && type === "tv" && (
|
| 1732 |
+
<CtrlBtn
|
| 1733 |
+
onClick={() =>
|
| 1734 |
+
setPanel(panel === "Episodes" ? null : "Episodes")
|
| 1735 |
+
}
|
| 1736 |
+
label="Episodes"
|
| 1737 |
+
>
|
| 1738 |
+
<ListIcon className="h-7 w-7" />
|
| 1739 |
+
</CtrlBtn>
|
| 1740 |
+
)}
|
| 1741 |
+
<CtrlBtn
|
| 1742 |
+
onClick={() => setPanel(panel === "Subs" ? null : "Subs")}
|
| 1743 |
+
label="Subtitles"
|
| 1744 |
+
active={subsOn}
|
| 1745 |
+
>
|
| 1746 |
+
<SubtitlesIcon className="h-7 w-7" />
|
| 1747 |
+
</CtrlBtn>
|
| 1748 |
+
<CtrlBtn
|
| 1749 |
+
onClick={() => setPanel(panel === "Quality" ? null : "Quality")}
|
| 1750 |
+
label="Settings"
|
| 1751 |
+
>
|
| 1752 |
+
<SettingsIcon className="h-7 w-7" />
|
| 1753 |
+
</CtrlBtn>
|
| 1754 |
+
<CtrlBtn
|
| 1755 |
+
onClick={() => setPanel(panel === "Servers" ? null : "Servers")}
|
| 1756 |
+
label="Servers"
|
| 1757 |
+
>
|
| 1758 |
+
<CloudIcon className="h-7 w-7" />
|
| 1759 |
+
</CtrlBtn>
|
| 1760 |
+
<CtrlBtn onClick={toggleFullscreen} label="Fullscreen">
|
| 1761 |
+
{fullscreen ? (
|
| 1762 |
+
<FullscreenExitIcon className="h-6 w-6" />
|
| 1763 |
+
) : (
|
| 1764 |
+
<FullscreenIcon className="h-6 w-6" />
|
| 1765 |
+
)}
|
| 1766 |
+
</CtrlBtn>
|
| 1767 |
+
</div>
|
| 1768 |
+
</div>
|
| 1769 |
+
</div>
|
| 1770 |
+
)}
|
| 1771 |
+
|
| 1772 |
+
{/* ---------------- Settings panel ---------------- */}
|
| 1773 |
+
{panel && (
|
| 1774 |
+
<div className="absolute bottom-[4.5rem] right-3 z-40 flex max-h-[calc(100%-6rem)] w-[min(92vw,360px)] flex-col sm:bottom-20 sm:right-5">
|
| 1775 |
+
{/* tabs */}
|
| 1776 |
+
<div className="mb-3 flex shrink-0 items-center justify-end gap-1">
|
| 1777 |
+
{panel === "CaptionStyle" ? (
|
| 1778 |
+
<button
|
| 1779 |
+
onClick={() => setPanel("Subs")}
|
| 1780 |
+
className="flex items-center gap-1.5 rounded-full bg-black/50 py-1 pl-2 pr-3 text-[13px] font-medium text-white/85 backdrop-blur transition hover:text-white"
|
| 1781 |
+
>
|
| 1782 |
+
<ChevronRightIcon className="h-4 w-4 rotate-180" />
|
| 1783 |
+
Caption style
|
| 1784 |
+
</button>
|
| 1785 |
+
) : panel === "Episodes" ? (
|
| 1786 |
+
<span className="rounded-full bg-black/50 px-3 py-1.5 text-[13px] font-medium text-white/85 backdrop-blur">
|
| 1787 |
+
Episodes
|
| 1788 |
+
</span>
|
| 1789 |
+
) : panel === "SubSources" ? (
|
| 1790 |
+
<button
|
| 1791 |
+
onClick={() => setPanel("Servers")}
|
| 1792 |
+
className="flex items-center gap-1.5 rounded-full bg-black/50 py-1 pl-2 pr-3 text-[13px] font-medium text-white/85 backdrop-blur transition hover:text-white"
|
| 1793 |
+
>
|
| 1794 |
+
<ChevronRightIcon className="h-4 w-4 rotate-180" />
|
| 1795 |
+
{providers.find((p) => p.id === subSourceParent)?.name || "Sources"}
|
| 1796 |
+
</button>
|
| 1797 |
+
) : (
|
| 1798 |
+
<div className="flex items-center gap-0.5 rounded-full bg-black/50 p-1 backdrop-blur">
|
| 1799 |
+
{(["Quality", "Subs", "Servers", "Speed"] as Tab[]).map((t) => (
|
| 1800 |
+
<button
|
| 1801 |
+
key={t}
|
| 1802 |
+
onClick={() => setPanel(t)}
|
| 1803 |
+
className={`rounded-full px-2.5 py-1 text-[13px] font-medium transition ${
|
| 1804 |
+
panel === t
|
| 1805 |
+
? "bg-white/15 text-white"
|
| 1806 |
+
: "text-white/60 hover:text-white"
|
| 1807 |
+
}`}
|
| 1808 |
+
>
|
| 1809 |
+
{t}
|
| 1810 |
+
</button>
|
| 1811 |
+
))}
|
| 1812 |
+
</div>
|
| 1813 |
+
)}
|
| 1814 |
+
<button
|
| 1815 |
+
onClick={() => setPanel(null)}
|
| 1816 |
+
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/50 text-white/70 backdrop-blur transition hover:text-white"
|
| 1817 |
+
>
|
| 1818 |
+
<CloseIcon className="h-4 w-4" />
|
| 1819 |
+
</button>
|
| 1820 |
+
</div>
|
| 1821 |
+
|
| 1822 |
+
{/* content */}
|
| 1823 |
+
<div className="no-scrollbar min-h-0 flex-1 overflow-y-auto rounded-2xl border border-white/10 bg-[#0c0a14]/95 p-2 backdrop-blur-xl">
|
| 1824 |
+
{panel === "Quality" && (
|
| 1825 |
+
<Rows>
|
| 1826 |
+
{resolvedSources.length > 1 ? (
|
| 1827 |
+
// Multi-file qualities from the sources API
|
| 1828 |
+
resolvedSources.map((s, i) => {
|
| 1829 |
+
const h = parseInt(s.label, 10) || 0;
|
| 1830 |
+
return (
|
| 1831 |
+
<Row
|
| 1832 |
+
key={`${s.label}-${i}`}
|
| 1833 |
+
selected={qualityIndex === i}
|
| 1834 |
+
accent={accent}
|
| 1835 |
+
onClick={() => switchQuality(i)}
|
| 1836 |
+
left={qualityLabel(s.label)}
|
| 1837 |
+
right={
|
| 1838 |
+
qualityBadge(h) ? (
|
| 1839 |
+
<Badge accent={accent}>{qualityBadge(h)}</Badge>
|
| 1840 |
+
) : null
|
| 1841 |
+
}
|
| 1842 |
+
/>
|
| 1843 |
+
);
|
| 1844 |
+
})
|
| 1845 |
+
) : (
|
| 1846 |
+
// Adaptive (single HLS) — use the stream's own levels
|
| 1847 |
+
<>
|
| 1848 |
+
<Row
|
| 1849 |
+
selected={currentLevel === -1}
|
| 1850 |
+
accent={accent}
|
| 1851 |
+
onClick={() => applyLevel(-1)}
|
| 1852 |
+
left="Auto"
|
| 1853 |
+
right={<Badge accent={accent}>Adaptive</Badge>}
|
| 1854 |
+
/>
|
| 1855 |
+
{levels.map((l) => (
|
| 1856 |
+
<Row
|
| 1857 |
+
key={l.index}
|
| 1858 |
+
selected={currentLevel === l.index}
|
| 1859 |
+
accent={accent}
|
| 1860 |
+
onClick={() => applyLevel(l.index)}
|
| 1861 |
+
left={`${l.height}p`}
|
| 1862 |
+
right={
|
| 1863 |
+
qualityBadge(l.height) ? (
|
| 1864 |
+
<Badge accent={accent}>{qualityBadge(l.height)}</Badge>
|
| 1865 |
+
) : null
|
| 1866 |
+
}
|
| 1867 |
+
/>
|
| 1868 |
+
))}
|
| 1869 |
+
{levels.length === 0 && (
|
| 1870 |
+
<p className="px-3 py-3 text-sm text-white/40">
|
| 1871 |
+
Loading qualities…
|
| 1872 |
+
</p>
|
| 1873 |
+
)}
|
| 1874 |
+
</>
|
| 1875 |
+
)}
|
| 1876 |
+
</Rows>
|
| 1877 |
+
)}
|
| 1878 |
+
|
| 1879 |
+
{panel === "Subs" && (
|
| 1880 |
+
<div className="flex flex-col">
|
| 1881 |
+
{/* subtitle menu header with the appearance/settings cog */}
|
| 1882 |
+
<div className="mb-1 flex items-center justify-between px-2 pt-1">
|
| 1883 |
+
<span className="text-xs font-semibold uppercase tracking-wide text-white/40">
|
| 1884 |
+
Subtitles
|
| 1885 |
+
</span>
|
| 1886 |
+
<button
|
| 1887 |
+
onClick={() => setPanel("CaptionStyle")}
|
| 1888 |
+
aria-label="Subtitle settings"
|
| 1889 |
+
title="Subtitle settings"
|
| 1890 |
+
className="flex h-7 w-7 items-center justify-center rounded-full text-white/60 transition hover:bg-white/10 hover:text-white"
|
| 1891 |
+
>
|
| 1892 |
+
<SettingsIcon className="h-4 w-4" />
|
| 1893 |
+
</button>
|
| 1894 |
+
</div>
|
| 1895 |
+
<Rows>
|
| 1896 |
+
<Row
|
| 1897 |
+
selected={!subsOn}
|
| 1898 |
+
accent={accent}
|
| 1899 |
+
onClick={subsOff}
|
| 1900 |
+
left="Off"
|
| 1901 |
+
right={
|
| 1902 |
+
<span className="text-xs text-white/45">No subtitles</span>
|
| 1903 |
+
}
|
| 1904 |
+
/>
|
| 1905 |
+
<button
|
| 1906 |
+
onClick={() => fileInputRef.current?.click()}
|
| 1907 |
+
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left text-white/85 transition hover:bg-white/5"
|
| 1908 |
+
>
|
| 1909 |
+
<UploadIcon className="h-5 w-5 text-white/70" />
|
| 1910 |
+
Upload subtitles
|
| 1911 |
+
</button>
|
| 1912 |
+
{apiSubs.map((s, i) => (
|
| 1913 |
+
<Row
|
| 1914 |
+
key={`api-${i}`}
|
| 1915 |
+
selected={subsOn && activeApiSub === i}
|
| 1916 |
+
accent={accent}
|
| 1917 |
+
onClick={() => selectApiSub(i)}
|
| 1918 |
+
left={
|
| 1919 |
+
<span className="flex items-center gap-2">
|
| 1920 |
+
<Flag src={s.flag} language={s.language} />
|
| 1921 |
+
{s.display}
|
| 1922 |
+
{s.hi && (
|
| 1923 |
+
<span className="rounded bg-white/10 px-1 text-[10px] font-semibold text-white/60">
|
| 1924 |
+
HI
|
| 1925 |
+
</span>
|
| 1926 |
+
)}
|
| 1927 |
+
</span>
|
| 1928 |
+
}
|
| 1929 |
+
right={
|
| 1930 |
+
<span className="text-xs capitalize text-white/40">
|
| 1931 |
+
{s.source || "OpenSubtitles"}
|
| 1932 |
+
</span>
|
| 1933 |
+
}
|
| 1934 |
+
/>
|
| 1935 |
+
))}
|
| 1936 |
+
{subTracks.map((t) => (
|
| 1937 |
+
<Row
|
| 1938 |
+
key={t.id}
|
| 1939 |
+
selected={subsOn && activeSub === t.id}
|
| 1940 |
+
accent={accent}
|
| 1941 |
+
onClick={() => selectSub(t.id)}
|
| 1942 |
+
left={t.label}
|
| 1943 |
+
right={
|
| 1944 |
+
t.builtIn ? (
|
| 1945 |
+
<Badge accent={accent}>Built-in</Badge>
|
| 1946 |
+
) : null
|
| 1947 |
+
}
|
| 1948 |
+
/>
|
| 1949 |
+
))}
|
| 1950 |
+
</Rows>
|
| 1951 |
+
<input
|
| 1952 |
+
ref={fileInputRef}
|
| 1953 |
+
type="file"
|
| 1954 |
+
accept=".vtt,.srt"
|
| 1955 |
+
hidden
|
| 1956 |
+
onChange={onUploadSubs}
|
| 1957 |
+
/>
|
| 1958 |
+
</div>
|
| 1959 |
+
)}
|
| 1960 |
+
|
| 1961 |
+
{panel === "Servers" && (
|
| 1962 |
+
<Rows>
|
| 1963 |
+
{providers.map((p) => {
|
| 1964 |
+
const hasKids = !!p.children?.length;
|
| 1965 |
+
// Parent is "active" when it (or one of its children) is the
|
| 1966 |
+
// current server.
|
| 1967 |
+
const active =
|
| 1968 |
+
serverId === p.id || serverId.startsWith(`${p.id}:`);
|
| 1969 |
+
const onClick = () => {
|
| 1970 |
+
if (hasKids) {
|
| 1971 |
+
setSubSourceParent(p.id);
|
| 1972 |
+
setPanel("SubSources");
|
| 1973 |
+
} else {
|
| 1974 |
+
switchServer(p.id);
|
| 1975 |
+
}
|
| 1976 |
+
};
|
| 1977 |
+
return (
|
| 1978 |
+
<div
|
| 1979 |
+
key={p.id}
|
| 1980 |
+
onClick={onClick}
|
| 1981 |
+
className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-3 transition ${
|
| 1982 |
+
active
|
| 1983 |
+
? "border-transparent"
|
| 1984 |
+
: "border-transparent hover:bg-white/5"
|
| 1985 |
+
}`}
|
| 1986 |
+
style={
|
| 1987 |
+
active
|
| 1988 |
+
? {
|
| 1989 |
+
backgroundColor: `${accent}33`,
|
| 1990 |
+
borderColor: `${accent}55`,
|
| 1991 |
+
}
|
| 1992 |
+
: undefined
|
| 1993 |
+
}
|
| 1994 |
+
>
|
| 1995 |
+
{/* English source — US flag next to every server. */}
|
| 1996 |
+
<Flag
|
| 1997 |
+
language="en-US"
|
| 1998 |
+
size={48}
|
| 1999 |
+
className="h-6 w-auto shrink-0 rounded-[3px]"
|
| 2000 |
+
/>
|
| 2001 |
+
<span className="min-w-0 flex-1">
|
| 2002 |
+
<span
|
| 2003 |
+
className="block text-sm font-semibold"
|
| 2004 |
+
style={{ color: active ? accent : "#fff" }}
|
| 2005 |
+
>
|
| 2006 |
+
{p.name}
|
| 2007 |
+
</span>
|
| 2008 |
+
<span className="block text-xs text-white/45">
|
| 2009 |
+
{p.label}
|
| 2010 |
+
</span>
|
| 2011 |
+
</span>
|
| 2012 |
+
{failedServers.includes(p.id) && (
|
| 2013 |
+
<span title="This source failed">
|
| 2014 |
+
<WarnIcon className="h-4 w-4 text-red-500" />
|
| 2015 |
+
</span>
|
| 2016 |
+
)}
|
| 2017 |
+
{active && !hasKids && !failedServers.includes(p.id) && (
|
| 2018 |
+
<CheckIcon className="h-4 w-4" style={{ color: accent }} />
|
| 2019 |
+
)}
|
| 2020 |
+
<button
|
| 2021 |
+
onClick={(e) => {
|
| 2022 |
+
e.stopPropagation();
|
| 2023 |
+
toggleFav(p.id);
|
| 2024 |
+
}}
|
| 2025 |
+
className="text-white/50 transition hover:text-white"
|
| 2026 |
+
>
|
| 2027 |
+
<HeartIcon
|
| 2028 |
+
filled={favorites.includes(p.id)}
|
| 2029 |
+
className="h-4 w-4"
|
| 2030 |
+
style={
|
| 2031 |
+
favorites.includes(p.id)
|
| 2032 |
+
? { color: accent }
|
| 2033 |
+
: undefined
|
| 2034 |
+
}
|
| 2035 |
+
/>
|
| 2036 |
+
</button>
|
| 2037 |
+
{hasKids && (
|
| 2038 |
+
<ChevronRightIcon className="h-4 w-4 text-white/40" />
|
| 2039 |
+
)}
|
| 2040 |
+
</div>
|
| 2041 |
+
);
|
| 2042 |
+
})}
|
| 2043 |
+
|
| 2044 |
+
{audioTracks.length > 1 && (
|
| 2045 |
+
<>
|
| 2046 |
+
<p className="px-1 pb-1 pt-3 text-xs font-medium uppercase tracking-wide text-white/40">
|
| 2047 |
+
Audio
|
| 2048 |
+
</p>
|
| 2049 |
+
{audioTracks.map((a) => (
|
| 2050 |
+
<Row
|
| 2051 |
+
key={`audio-${a.id}`}
|
| 2052 |
+
selected={currentAudio === a.id}
|
| 2053 |
+
accent={accent}
|
| 2054 |
+
onClick={() => switchAudio(a.id)}
|
| 2055 |
+
left={a.name}
|
| 2056 |
+
/>
|
| 2057 |
+
))}
|
| 2058 |
+
</>
|
| 2059 |
+
)}
|
| 2060 |
+
</Rows>
|
| 2061 |
+
)}
|
| 2062 |
+
|
| 2063 |
+
{panel === "SubSources" &&
|
| 2064 |
+
(() => {
|
| 2065 |
+
const parent = providers.find((p) => p.id === subSourceParent);
|
| 2066 |
+
if (!parent) return null;
|
| 2067 |
+
const items = [
|
| 2068 |
+
{ id: parent.id, name: "Auto · try all", auto: true },
|
| 2069 |
+
...(parent.children || []).map((c) => ({
|
| 2070 |
+
id: `${parent.id}:${c.id}`,
|
| 2071 |
+
name: c.name,
|
| 2072 |
+
auto: false,
|
| 2073 |
+
})),
|
| 2074 |
+
];
|
| 2075 |
+
return (
|
| 2076 |
+
<Rows>
|
| 2077 |
+
{items.map((c) => {
|
| 2078 |
+
const csel = serverId === c.id;
|
| 2079 |
+
const cfail = failedServers.includes(c.id);
|
| 2080 |
+
return (
|
| 2081 |
+
<Row
|
| 2082 |
+
key={c.id}
|
| 2083 |
+
selected={csel}
|
| 2084 |
+
accent={accent}
|
| 2085 |
+
onClick={() => switchServer(c.id)}
|
| 2086 |
+
left={c.name}
|
| 2087 |
+
right={
|
| 2088 |
+
cfail ? (
|
| 2089 |
+
<span title="This source failed">
|
| 2090 |
+
<WarnIcon className="h-4 w-4 text-red-500" />
|
| 2091 |
+
</span>
|
| 2092 |
+
) : c.auto ? (
|
| 2093 |
+
<Badge accent={accent}>auto</Badge>
|
| 2094 |
+
) : null
|
| 2095 |
+
}
|
| 2096 |
+
/>
|
| 2097 |
+
);
|
| 2098 |
+
})}
|
| 2099 |
+
</Rows>
|
| 2100 |
+
);
|
| 2101 |
+
})()}
|
| 2102 |
+
|
| 2103 |
+
{panel === "Speed" && (
|
| 2104 |
+
<Rows>
|
| 2105 |
+
{SPEEDS.map((s) => (
|
| 2106 |
+
<Row
|
| 2107 |
+
key={s}
|
| 2108 |
+
selected={speed === s}
|
| 2109 |
+
accent={accent}
|
| 2110 |
+
onClick={() => applySpeed(s)}
|
| 2111 |
+
left={s === 1 ? "1x (Normal)" : `${s}x`}
|
| 2112 |
+
right={
|
| 2113 |
+
speed === s ? (
|
| 2114 |
+
<span
|
| 2115 |
+
className="h-2.5 w-2.5 rounded-full"
|
| 2116 |
+
style={{ backgroundColor: accent }}
|
| 2117 |
+
/>
|
| 2118 |
+
) : null
|
| 2119 |
+
}
|
| 2120 |
+
/>
|
| 2121 |
+
))}
|
| 2122 |
+
</Rows>
|
| 2123 |
+
)}
|
| 2124 |
+
|
| 2125 |
+
{panel === "Episodes" && (
|
| 2126 |
+
<div className="flex flex-col">
|
| 2127 |
+
{/* season selector */}
|
| 2128 |
+
{seasonsInfo.length > 1 && (
|
| 2129 |
+
<div className="no-scrollbar mb-2 flex gap-1.5 overflow-x-auto pb-1">
|
| 2130 |
+
{seasonsInfo.map((s) => (
|
| 2131 |
+
<button
|
| 2132 |
+
key={s.season}
|
| 2133 |
+
onClick={() => setPickerSeason(s.season)}
|
| 2134 |
+
className="shrink-0 rounded-lg border px-3 py-1.5 text-[13px] font-medium transition"
|
| 2135 |
+
style={
|
| 2136 |
+
pickerSeason === s.season
|
| 2137 |
+
? {
|
| 2138 |
+
backgroundColor: `${accent}33`,
|
| 2139 |
+
borderColor: `${accent}77`,
|
| 2140 |
+
color: "#fff",
|
| 2141 |
+
}
|
| 2142 |
+
: {
|
| 2143 |
+
backgroundColor: "rgba(255,255,255,0.05)",
|
| 2144 |
+
borderColor: "transparent",
|
| 2145 |
+
color: "rgba(255,255,255,0.7)",
|
| 2146 |
+
}
|
| 2147 |
+
}
|
| 2148 |
+
>
|
| 2149 |
+
S{s.season}
|
| 2150 |
+
</button>
|
| 2151 |
+
))}
|
| 2152 |
+
</div>
|
| 2153 |
+
)}
|
| 2154 |
+
<Rows>
|
| 2155 |
+
{pickerEpisodes.length === 0 && (
|
| 2156 |
+
<p className="px-3 py-3 text-sm text-white/40">
|
| 2157 |
+
Loading episodes…
|
| 2158 |
+
</p>
|
| 2159 |
+
)}
|
| 2160 |
+
{pickerEpisodes.map((ep) => {
|
| 2161 |
+
const isCurrent =
|
| 2162 |
+
pickerSeason === curSeason && ep.episode === curEpisode;
|
| 2163 |
+
return (
|
| 2164 |
+
<button
|
| 2165 |
+
key={ep.episode}
|
| 2166 |
+
onClick={() => goToEpisode(pickerSeason, ep.episode)}
|
| 2167 |
+
className={`flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition ${
|
| 2168 |
+
isCurrent
|
| 2169 |
+
? "border-transparent"
|
| 2170 |
+
: "border-transparent hover:bg-white/5"
|
| 2171 |
+
}`}
|
| 2172 |
+
style={
|
| 2173 |
+
isCurrent
|
| 2174 |
+
? {
|
| 2175 |
+
backgroundColor: `${accent}33`,
|
| 2176 |
+
borderColor: `${accent}55`,
|
| 2177 |
+
}
|
| 2178 |
+
: undefined
|
| 2179 |
+
}
|
| 2180 |
+
>
|
| 2181 |
+
<span
|
| 2182 |
+
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-xs font-bold"
|
| 2183 |
+
style={{
|
| 2184 |
+
backgroundColor: isCurrent
|
| 2185 |
+
? accent
|
| 2186 |
+
: "rgba(255,255,255,0.08)",
|
| 2187 |
+
color: isCurrent ? "#fff" : "rgba(255,255,255,0.7)",
|
| 2188 |
+
}}
|
| 2189 |
+
>
|
| 2190 |
+
{ep.episode}
|
| 2191 |
+
</span>
|
| 2192 |
+
<span className="min-w-0 flex-1">
|
| 2193 |
+
<span
|
| 2194 |
+
className="block truncate text-sm font-medium"
|
| 2195 |
+
style={{ color: isCurrent ? accent : "#fff" }}
|
| 2196 |
+
>
|
| 2197 |
+
{ep.name}
|
| 2198 |
+
</span>
|
| 2199 |
+
</span>
|
| 2200 |
+
</button>
|
| 2201 |
+
);
|
| 2202 |
+
})}
|
| 2203 |
+
</Rows>
|
| 2204 |
+
</div>
|
| 2205 |
+
)}
|
| 2206 |
+
|
| 2207 |
+
{panel === "CaptionStyle" && (
|
| 2208 |
+
<div className="space-y-4 px-1 pb-1">
|
| 2209 |
+
{/* live preview */}
|
| 2210 |
+
<div className="relative flex h-24 items-end justify-center overflow-hidden rounded-xl border border-white/10 bg-[radial-gradient(circle_at_30%_20%,#3a3550,#0b0a12)]">
|
| 2211 |
+
<div
|
| 2212 |
+
className="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center px-3 text-center"
|
| 2213 |
+
>
|
| 2214 |
+
<span style={captionTextStyle(subStyle)}>
|
| 2215 |
+
{cueText || "The quick brown fox"}
|
| 2216 |
+
</span>
|
| 2217 |
+
</div>
|
| 2218 |
+
</div>
|
| 2219 |
+
|
| 2220 |
+
<StyleControl label="Text size">
|
| 2221 |
+
<input
|
| 2222 |
+
type="range"
|
| 2223 |
+
min={16}
|
| 2224 |
+
max={64}
|
| 2225 |
+
step={1}
|
| 2226 |
+
value={subStyle.size}
|
| 2227 |
+
onChange={(e) =>
|
| 2228 |
+
updateSubStyle({ size: Number(e.target.value) })
|
| 2229 |
+
}
|
| 2230 |
+
className="w-full cursor-pointer"
|
| 2231 |
+
style={{ accentColor: accent }}
|
| 2232 |
+
/>
|
| 2233 |
+
</StyleControl>
|
| 2234 |
+
|
| 2235 |
+
<StyleControl label="Text color">
|
| 2236 |
+
<div className="flex flex-wrap gap-2">
|
| 2237 |
+
{SUB_COLORS.map((c) => (
|
| 2238 |
+
<Swatch
|
| 2239 |
+
key={c}
|
| 2240 |
+
color={c}
|
| 2241 |
+
selected={subStyle.color.toLowerCase() === c.toLowerCase()}
|
| 2242 |
+
accent={accent}
|
| 2243 |
+
onClick={() => updateSubStyle({ color: c })}
|
| 2244 |
+
/>
|
| 2245 |
+
))}
|
| 2246 |
+
</div>
|
| 2247 |
+
</StyleControl>
|
| 2248 |
+
|
| 2249 |
+
<StyleControl label="Font">
|
| 2250 |
+
<div className="flex flex-wrap gap-1.5">
|
| 2251 |
+
{SUB_FONTS.map((f) => (
|
| 2252 |
+
<Chip
|
| 2253 |
+
key={f.label}
|
| 2254 |
+
selected={subStyle.font === f.value}
|
| 2255 |
+
accent={accent}
|
| 2256 |
+
onClick={() => updateSubStyle({ font: f.value })}
|
| 2257 |
+
>
|
| 2258 |
+
<span style={{ fontFamily: f.value }}>{f.label}</span>
|
| 2259 |
+
</Chip>
|
| 2260 |
+
))}
|
| 2261 |
+
<Chip
|
| 2262 |
+
selected={subStyle.bold}
|
| 2263 |
+
accent={accent}
|
| 2264 |
+
onClick={() => updateSubStyle({ bold: !subStyle.bold })}
|
| 2265 |
+
>
|
| 2266 |
+
<span className="font-extrabold">Bold</span>
|
| 2267 |
+
</Chip>
|
| 2268 |
+
</div>
|
| 2269 |
+
</StyleControl>
|
| 2270 |
+
|
| 2271 |
+
<StyleControl label="Edge style">
|
| 2272 |
+
<div className="flex flex-wrap gap-1.5">
|
| 2273 |
+
{(
|
| 2274 |
+
[
|
| 2275 |
+
["none", "None"],
|
| 2276 |
+
["shadow", "Shadow"],
|
| 2277 |
+
["outline", "Outline"],
|
| 2278 |
+
["glow", "Glow"],
|
| 2279 |
+
] as [SubStyle["edge"], string][]
|
| 2280 |
+
).map(([val, lbl]) => (
|
| 2281 |
+
<Chip
|
| 2282 |
+
key={val}
|
| 2283 |
+
selected={subStyle.edge === val}
|
| 2284 |
+
accent={accent}
|
| 2285 |
+
onClick={() => updateSubStyle({ edge: val })}
|
| 2286 |
+
>
|
| 2287 |
+
{lbl}
|
| 2288 |
+
</Chip>
|
| 2289 |
+
))}
|
| 2290 |
+
</div>
|
| 2291 |
+
</StyleControl>
|
| 2292 |
+
|
| 2293 |
+
<StyleControl label="Background">
|
| 2294 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 2295 |
+
<Chip
|
| 2296 |
+
selected={subStyle.bg === "none"}
|
| 2297 |
+
accent={accent}
|
| 2298 |
+
onClick={() => updateSubStyle({ bg: "none" })}
|
| 2299 |
+
>
|
| 2300 |
+
Off
|
| 2301 |
+
</Chip>
|
| 2302 |
+
{["#000000", "#ffffff"].map((c) => (
|
| 2303 |
+
<Swatch
|
| 2304 |
+
key={c}
|
| 2305 |
+
color={c}
|
| 2306 |
+
selected={subStyle.bg.toLowerCase() === c}
|
| 2307 |
+
accent={accent}
|
| 2308 |
+
onClick={() => updateSubStyle({ bg: c })}
|
| 2309 |
+
/>
|
| 2310 |
+
))}
|
| 2311 |
+
</div>
|
| 2312 |
+
</StyleControl>
|
| 2313 |
+
|
| 2314 |
+
<StyleControl
|
| 2315 |
+
label={`Background opacity · ${Math.round(
|
| 2316 |
+
subStyle.bgOpacity * 100
|
| 2317 |
+
)}%`}
|
| 2318 |
+
>
|
| 2319 |
+
<input
|
| 2320 |
+
type="range"
|
| 2321 |
+
min={0}
|
| 2322 |
+
max={1}
|
| 2323 |
+
step={0.05}
|
| 2324 |
+
disabled={subStyle.bg === "none"}
|
| 2325 |
+
value={subStyle.bgOpacity}
|
| 2326 |
+
onChange={(e) =>
|
| 2327 |
+
updateSubStyle({ bgOpacity: Number(e.target.value) })
|
| 2328 |
+
}
|
| 2329 |
+
className="w-full cursor-pointer disabled:opacity-30"
|
| 2330 |
+
style={{ accentColor: accent }}
|
| 2331 |
+
aria-label="Background opacity"
|
| 2332 |
+
/>
|
| 2333 |
+
</StyleControl>
|
| 2334 |
+
|
| 2335 |
+
<StyleControl label={`Vertical position · ${subStyle.position}%`}>
|
| 2336 |
+
<input
|
| 2337 |
+
type="range"
|
| 2338 |
+
min={2}
|
| 2339 |
+
max={40}
|
| 2340 |
+
step={1}
|
| 2341 |
+
value={subStyle.position}
|
| 2342 |
+
onChange={(e) =>
|
| 2343 |
+
updateSubStyle({ position: Number(e.target.value) })
|
| 2344 |
+
}
|
| 2345 |
+
className="w-full cursor-pointer"
|
| 2346 |
+
style={{ accentColor: accent }}
|
| 2347 |
+
/>
|
| 2348 |
+
</StyleControl>
|
| 2349 |
+
|
| 2350 |
+
<StyleControl label="Subtitle delay">
|
| 2351 |
+
<div className="flex items-center gap-2">
|
| 2352 |
+
<button
|
| 2353 |
+
onClick={() =>
|
| 2354 |
+
setSubDelay((d) => Math.round((d - 0.1) * 10) / 10)
|
| 2355 |
+
}
|
| 2356 |
+
aria-label="Subtitles earlier"
|
| 2357 |
+
className="flex h-9 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-lg font-semibold text-white/80 transition hover:bg-white/10 hover:text-white"
|
| 2358 |
+
>
|
| 2359 |
+
−
|
| 2360 |
+
</button>
|
| 2361 |
+
<div className="relative flex-1">
|
| 2362 |
+
<input
|
| 2363 |
+
type="number"
|
| 2364 |
+
step={0.1}
|
| 2365 |
+
value={subDelay}
|
| 2366 |
+
onChange={(e) => {
|
| 2367 |
+
const n = parseFloat(e.target.value);
|
| 2368 |
+
setSubDelay(isNaN(n) ? 0 : Math.round(n * 10) / 10);
|
| 2369 |
+
}}
|
| 2370 |
+
className="w-full rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-center text-sm tabular-nums text-white outline-none transition focus:border-white/30"
|
| 2371 |
+
aria-label="Subtitle delay in seconds"
|
| 2372 |
+
/>
|
| 2373 |
+
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-white/40">
|
| 2374 |
+
s
|
| 2375 |
+
</span>
|
| 2376 |
+
</div>
|
| 2377 |
+
<button
|
| 2378 |
+
onClick={() =>
|
| 2379 |
+
setSubDelay((d) => Math.round((d + 0.1) * 10) / 10)
|
| 2380 |
+
}
|
| 2381 |
+
aria-label="Subtitles later"
|
| 2382 |
+
className="flex h-9 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-lg font-semibold text-white/80 transition hover:bg-white/10 hover:text-white"
|
| 2383 |
+
>
|
| 2384 |
+
+
|
| 2385 |
+
</button>
|
| 2386 |
+
</div>
|
| 2387 |
+
<p className="mt-1.5 text-[11px] text-white/35">
|
| 2388 |
+
{subDelay > 0
|
| 2389 |
+
? "Subtitles appear later"
|
| 2390 |
+
: subDelay < 0
|
| 2391 |
+
? "Subtitles appear earlier"
|
| 2392 |
+
: "In sync"}{" "}
|
| 2393 |
+
· ± steps by 0.1s
|
| 2394 |
+
</p>
|
| 2395 |
+
</StyleControl>
|
| 2396 |
+
|
| 2397 |
+
<button
|
| 2398 |
+
onClick={() => {
|
| 2399 |
+
updateSubStyle(DEFAULT_SUB_STYLE);
|
| 2400 |
+
setSubDelay(0);
|
| 2401 |
+
}}
|
| 2402 |
+
className="w-full rounded-xl border border-white/10 bg-white/5 py-2.5 text-sm font-medium text-white/70 transition hover:bg-white/10 hover:text-white"
|
| 2403 |
+
>
|
| 2404 |
+
Reset to default
|
| 2405 |
+
</button>
|
| 2406 |
+
</div>
|
| 2407 |
+
)}
|
| 2408 |
+
</div>
|
| 2409 |
+
</div>
|
| 2410 |
+
)}
|
| 2411 |
+
</div>
|
| 2412 |
+
);
|
| 2413 |
+
}
|
| 2414 |
+
|
| 2415 |
+
/* ---------------- small building blocks ---------------- */
|
| 2416 |
+
|
| 2417 |
+
// Country flag for a subtitle: prefers a flag URL supplied by the source
|
| 2418 |
+
// (Wyzie returns one per subtitle), otherwise derives one from the language
|
| 2419 |
+
// via flagsapi.com. Renders nothing if neither is available or it fails to load.
|
| 2420 |
+
function Flag({
|
| 2421 |
+
src,
|
| 2422 |
+
language,
|
| 2423 |
+
size = 24,
|
| 2424 |
+
className = "h-3.5 w-auto shrink-0 rounded-[2px]",
|
| 2425 |
+
}: {
|
| 2426 |
+
src?: string;
|
| 2427 |
+
language?: string;
|
| 2428 |
+
size?: 16 | 24 | 32 | 48 | 64;
|
| 2429 |
+
className?: string;
|
| 2430 |
+
}) {
|
| 2431 |
+
const url = src || flagUrl(language, size);
|
| 2432 |
+
if (!url) return null;
|
| 2433 |
+
return (
|
| 2434 |
+
/* eslint-disable-next-line @next/next/no-img-element */
|
| 2435 |
+
<img
|
| 2436 |
+
src={url}
|
| 2437 |
+
alt={language || ""}
|
| 2438 |
+
loading="lazy"
|
| 2439 |
+
className={className}
|
| 2440 |
+
onError={(e) => {
|
| 2441 |
+
(e.currentTarget as HTMLImageElement).style.display = "none";
|
| 2442 |
+
}}
|
| 2443 |
+
/>
|
| 2444 |
+
);
|
| 2445 |
+
}
|
| 2446 |
+
|
| 2447 |
+
function CtrlBtn({
|
| 2448 |
+
children,
|
| 2449 |
+
onClick,
|
| 2450 |
+
label,
|
| 2451 |
+
active,
|
| 2452 |
+
}: {
|
| 2453 |
+
children: React.ReactNode;
|
| 2454 |
+
onClick: () => void;
|
| 2455 |
+
label: string;
|
| 2456 |
+
active?: boolean;
|
| 2457 |
+
}) {
|
| 2458 |
+
return (
|
| 2459 |
+
<button
|
| 2460 |
+
onClick={onClick}
|
| 2461 |
+
aria-label={label}
|
| 2462 |
+
title={label}
|
| 2463 |
+
className={`flex h-10 w-10 items-center justify-center rounded-full transition hover:bg-white/10 ${
|
| 2464 |
+
active ? "text-white" : "text-white/90 hover:text-white"
|
| 2465 |
+
}`}
|
| 2466 |
+
>
|
| 2467 |
+
{children}
|
| 2468 |
+
</button>
|
| 2469 |
+
);
|
| 2470 |
+
}
|
| 2471 |
+
|
| 2472 |
+
function Rows({ children }: { children: React.ReactNode }) {
|
| 2473 |
+
return <div className="space-y-1">{children}</div>;
|
| 2474 |
+
}
|
| 2475 |
+
|
| 2476 |
+
function Row({
|
| 2477 |
+
left,
|
| 2478 |
+
right,
|
| 2479 |
+
selected,
|
| 2480 |
+
accent,
|
| 2481 |
+
onClick,
|
| 2482 |
+
}: {
|
| 2483 |
+
left: React.ReactNode;
|
| 2484 |
+
right?: React.ReactNode;
|
| 2485 |
+
selected?: boolean;
|
| 2486 |
+
accent: string;
|
| 2487 |
+
onClick: () => void;
|
| 2488 |
+
}) {
|
| 2489 |
+
return (
|
| 2490 |
+
<button
|
| 2491 |
+
onClick={onClick}
|
| 2492 |
+
className={`flex w-full items-center justify-between rounded-xl border px-3 py-3 text-left transition ${
|
| 2493 |
+
selected ? "border-transparent" : "border-transparent hover:bg-white/5"
|
| 2494 |
+
}`}
|
| 2495 |
+
style={
|
| 2496 |
+
selected
|
| 2497 |
+
? { backgroundColor: `${accent}33`, borderColor: `${accent}55` }
|
| 2498 |
+
: undefined
|
| 2499 |
+
}
|
| 2500 |
+
>
|
| 2501 |
+
<span
|
| 2502 |
+
className="text-sm font-medium"
|
| 2503 |
+
style={{ color: selected ? accent : "#fff" }}
|
| 2504 |
+
>
|
| 2505 |
+
{left}
|
| 2506 |
+
</span>
|
| 2507 |
+
{right}
|
| 2508 |
+
</button>
|
| 2509 |
+
);
|
| 2510 |
+
}
|
| 2511 |
+
|
| 2512 |
+
function Badge({
|
| 2513 |
+
children,
|
| 2514 |
+
accent,
|
| 2515 |
+
}: {
|
| 2516 |
+
children: React.ReactNode;
|
| 2517 |
+
accent: string;
|
| 2518 |
+
}) {
|
| 2519 |
+
return (
|
| 2520 |
+
<span
|
| 2521 |
+
className="rounded-md px-2 py-0.5 text-xs font-semibold"
|
| 2522 |
+
style={{ backgroundColor: `${accent}33`, color: accent }}
|
| 2523 |
+
>
|
| 2524 |
+
{children}
|
| 2525 |
+
</span>
|
| 2526 |
+
);
|
| 2527 |
+
}
|
| 2528 |
+
|
| 2529 |
+
function StyleControl({
|
| 2530 |
+
label,
|
| 2531 |
+
children,
|
| 2532 |
+
}: {
|
| 2533 |
+
label: string;
|
| 2534 |
+
children: React.ReactNode;
|
| 2535 |
+
}) {
|
| 2536 |
+
return (
|
| 2537 |
+
<div>
|
| 2538 |
+
<p className="mb-1.5 text-xs font-medium text-white/50">{label}</p>
|
| 2539 |
+
{children}
|
| 2540 |
+
</div>
|
| 2541 |
+
);
|
| 2542 |
+
}
|
| 2543 |
+
|
| 2544 |
+
function Swatch({
|
| 2545 |
+
color,
|
| 2546 |
+
selected,
|
| 2547 |
+
accent,
|
| 2548 |
+
onClick,
|
| 2549 |
+
}: {
|
| 2550 |
+
color: string;
|
| 2551 |
+
selected?: boolean;
|
| 2552 |
+
accent: string;
|
| 2553 |
+
onClick: () => void;
|
| 2554 |
+
}) {
|
| 2555 |
+
return (
|
| 2556 |
+
<button
|
| 2557 |
+
onClick={onClick}
|
| 2558 |
+
aria-label={color}
|
| 2559 |
+
className="h-7 w-7 rounded-full border transition hover:scale-110"
|
| 2560 |
+
style={{
|
| 2561 |
+
backgroundColor: color,
|
| 2562 |
+
borderColor: selected ? accent : "rgba(255,255,255,0.25)",
|
| 2563 |
+
boxShadow: selected ? `0 0 0 2px ${accent}` : undefined,
|
| 2564 |
+
}}
|
| 2565 |
+
/>
|
| 2566 |
+
);
|
| 2567 |
+
}
|
| 2568 |
+
|
| 2569 |
+
function Chip({
|
| 2570 |
+
children,
|
| 2571 |
+
selected,
|
| 2572 |
+
accent,
|
| 2573 |
+
onClick,
|
| 2574 |
+
}: {
|
| 2575 |
+
children: React.ReactNode;
|
| 2576 |
+
selected?: boolean;
|
| 2577 |
+
accent: string;
|
| 2578 |
+
onClick: () => void;
|
| 2579 |
+
}) {
|
| 2580 |
+
return (
|
| 2581 |
+
<button
|
| 2582 |
+
onClick={onClick}
|
| 2583 |
+
className="rounded-lg border px-3 py-1.5 text-[13px] font-medium transition"
|
| 2584 |
+
style={
|
| 2585 |
+
selected
|
| 2586 |
+
? { backgroundColor: `${accent}33`, borderColor: `${accent}77`, color: "#fff" }
|
| 2587 |
+
: {
|
| 2588 |
+
backgroundColor: "rgba(255,255,255,0.05)",
|
| 2589 |
+
borderColor: "transparent",
|
| 2590 |
+
color: "rgba(255,255,255,0.7)",
|
| 2591 |
+
}
|
| 2592 |
+
}
|
| 2593 |
+
>
|
| 2594 |
+
{children}
|
| 2595 |
+
</button>
|
| 2596 |
+
);
|
| 2597 |
+
}
|
| 2598 |
+
|
components/player/icons.tsx
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Player control icons — recreated to match the vidsuper player. */
|
| 2 |
+
import { SVGProps } from "react";
|
| 3 |
+
|
| 4 |
+
type P = SVGProps<SVGSVGElement>;
|
| 5 |
+
|
| 6 |
+
export const PlayIcon = (p: P) => (
|
| 7 |
+
<svg viewBox="0 0 384 512" fill="currentColor" {...p}>
|
| 8 |
+
<path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80V432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z" />
|
| 9 |
+
</svg>
|
| 10 |
+
);
|
| 11 |
+
|
| 12 |
+
export const PauseIcon = (p: P) => (
|
| 13 |
+
<svg viewBox="0 0 320 512" fill="currentColor" {...p}>
|
| 14 |
+
<path d="M48 64C21.5 64 0 85.5 0 112V400c0 26.5 21.5 48 48 48H80c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H48zm192 0c-26.5 0-48 21.5-48 48V400c0 26.5 21.5 48 48 48h32c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H240z" />
|
| 15 |
+
</svg>
|
| 16 |
+
);
|
| 17 |
+
|
| 18 |
+
/* Rewind 10s — p-stream skip-backward (curved arrow with baked-in "10") */
|
| 19 |
+
export const Rewind10Icon = (p: P) => (
|
| 20 |
+
<svg viewBox="0 0 25 24" fill="none" {...p}>
|
| 21 |
+
<path
|
| 22 |
+
d="M13.6667 12.3333L9 7.66667M9 7.66667L13.6667 3M9 7.66667H18.3333C19.571 7.66667 20.758 8.15833 21.6332 9.0335C22.5083 9.90867 23 11.0957 23 12.3333C23 13.571 22.5083 14.758 21.6332 15.6332C20.758 16.5083 19.571 17 18.3333 17H16"
|
| 23 |
+
stroke="currentColor"
|
| 24 |
+
strokeWidth="2.5"
|
| 25 |
+
strokeLinecap="round"
|
| 26 |
+
strokeLinejoin="round"
|
| 27 |
+
/>
|
| 28 |
+
<path
|
| 29 |
+
d="M4.50426 14.2727V23H2.65909V16.0241H2.60795L0.609375 17.277V15.6406L2.76989 14.2727H4.50426ZM10.0004 23.1918C9.2674 23.1889 8.63672 23.0085 8.10831 22.6506C7.58274 22.2926 7.17791 21.7741 6.89382 21.0952C6.61257 20.4162 6.47337 19.5994 6.47621 18.6449C6.47621 17.6932 6.61683 16.8821 6.89808 16.2116C7.18217 15.5412 7.587 15.0312 8.11257 14.6818C8.64098 14.3295 9.27024 14.1534 10.0004 14.1534C10.7305 14.1534 11.3583 14.3295 11.8839 14.6818C12.4123 15.0341 12.8185 15.5455 13.1026 16.2159C13.3867 16.8835 13.5273 17.6932 13.5245 18.6449C13.5245 19.6023 13.3825 20.4205 13.0984 21.0994C12.8171 21.7784 12.4137 22.2969 11.8881 22.6548C11.3626 23.0128 10.7333 23.1918 10.0004 23.1918ZM10.0004 21.6619C10.5004 21.6619 10.8995 21.4105 11.1978 20.9077C11.4961 20.4048 11.6438 19.6506 11.641 18.6449C11.641 17.983 11.5728 17.4318 11.4364 16.9915C11.3029 16.5511 11.1126 16.2202 10.8654 15.9986C10.6211 15.777 10.3327 15.6662 10.0004 15.6662C9.5032 15.6662 9.10547 15.9148 8.80717 16.4119C8.50888 16.9091 8.35831 17.6534 8.35547 18.6449C8.35547 19.3153 8.42223 19.875 8.55575 20.3239C8.69212 20.7699 8.88388 21.1051 9.13104 21.3295C9.3782 21.5511 9.66797 21.6619 10.0004 21.6619Z"
|
| 30 |
+
fill="currentColor"
|
| 31 |
+
/>
|
| 32 |
+
</svg>
|
| 33 |
+
);
|
| 34 |
+
|
| 35 |
+
/* Forward 10s — p-stream skip-forward (curved arrow with baked-in "10") */
|
| 36 |
+
export const Forward10Icon = (p: P) => (
|
| 37 |
+
<svg viewBox="0 0 26 24" fill="none" {...p}>
|
| 38 |
+
<path
|
| 39 |
+
d="M11.3333 12.3333L16 7.66667M16 7.66667L11.3333 3M16 7.66667H6.66667C5.42899 7.66667 4.242 8.15833 3.36684 9.0335C2.49167 9.90867 2 11.0957 2 12.3333C2 13.571 2.49167 14.758 3.36684 15.6332C4.242 16.5083 5.42899 17 6.66667 17H9"
|
| 40 |
+
stroke="currentColor"
|
| 41 |
+
strokeWidth="2.5"
|
| 42 |
+
strokeLinecap="round"
|
| 43 |
+
strokeLinejoin="round"
|
| 44 |
+
/>
|
| 45 |
+
<path
|
| 46 |
+
d="M16.5043 14.2727V23H14.6591V16.0241H14.608L12.6094 17.277V15.6406L14.7699 14.2727H16.5043ZM22.0004 23.1918C21.2674 23.1889 20.6367 23.0085 20.1083 22.6506C19.5827 22.2926 19.1779 21.7741 18.8938 21.0952C18.6126 20.4162 18.4734 19.5994 18.4762 18.6449C18.4762 17.6932 18.6168 16.8821 18.8981 16.2116C19.1822 15.5412 19.587 15.0312 20.1126 14.6818C20.641 14.3295 21.2702 14.1534 22.0004 14.1534C22.7305 14.1534 23.3583 14.3295 23.8839 14.6818C24.4123 15.0341 24.8185 15.5455 25.1026 16.2159C25.3867 16.8835 25.5273 17.6932 25.5245 18.6449C25.5245 19.6023 25.3825 20.4205 25.0984 21.0994C24.8171 21.7784 24.4137 22.2969 23.8881 22.6548C23.3626 23.0128 22.7333 23.1918 22.0004 23.1918ZM22.0004 21.6619C22.5004 21.6619 22.8995 21.4105 23.1978 20.9077C23.4961 20.4048 23.6438 19.6506 23.641 18.6449C23.641 17.983 23.5728 17.4318 23.4364 16.9915C23.3029 16.5511 23.1126 16.2202 22.8654 15.9986C22.6211 15.777 22.3327 15.6662 22.0004 15.6662C21.5032 15.6662 21.1055 15.9148 20.8072 16.4119C20.5089 16.9091 20.3583 17.6534 20.3555 18.6449C20.3555 19.3153 20.4222 19.875 20.5558 20.3239C20.6921 20.7699 20.8839 21.1051 21.131 21.3295C21.3782 21.5511 21.668 21.6619 22.0004 21.6619Z"
|
| 47 |
+
fill="currentColor"
|
| 48 |
+
/>
|
| 49 |
+
</svg>
|
| 50 |
+
);
|
| 51 |
+
|
| 52 |
+
/* Volume (high) — FontAwesome volume-high, p-stream's player volume icon */
|
| 53 |
+
export const VolumeHighIcon = (p: P) => (
|
| 54 |
+
<svg viewBox="0 0 640 512" {...p}>
|
| 55 |
+
<path
|
| 56 |
+
fill="currentColor"
|
| 57 |
+
d="M533.6 32.5C598.5 85.3 640 165.8 640 256s-41.5 170.8-106.4 223.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C557.5 398.2 592 331.2 592 256s-34.5-142.2-88.7-186.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM473.1 107c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C475.3 341.3 496 301.1 496 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3z"
|
| 58 |
+
/>
|
| 59 |
+
</svg>
|
| 60 |
+
);
|
| 61 |
+
|
| 62 |
+
/* Volume muted — FontAwesome volume-xmark (pairs with volume-high) */
|
| 63 |
+
export const VolumeMutedIcon = (p: P) => (
|
| 64 |
+
<svg viewBox="0 0 576 512" {...p}>
|
| 65 |
+
<path
|
| 66 |
+
fill="currentColor"
|
| 67 |
+
d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"
|
| 68 |
+
/>
|
| 69 |
+
</svg>
|
| 70 |
+
);
|
| 71 |
+
|
| 72 |
+
/* Subtitles / closed captions — p-stream captions icon */
|
| 73 |
+
export const SubtitlesIcon = (p: P) => (
|
| 74 |
+
<svg viewBox="0 0 25 20" {...p}>
|
| 75 |
+
<path
|
| 76 |
+
transform="translate(-3 -6)"
|
| 77 |
+
d="M25.5,6H5.5A2.507,2.507,0,0,0,3,8.5v15A2.507,2.507,0,0,0,5.5,26h20A2.507,2.507,0,0,0,28,23.5V8.5A2.507,2.507,0,0,0,25.5,6ZM5.5,16h5v2.5h-5ZM18,23.5H5.5V21H18Zm7.5,0h-5V21h5Zm0-5H13V16H25.5Z"
|
| 78 |
+
fill="currentColor"
|
| 79 |
+
/>
|
| 80 |
+
</svg>
|
| 81 |
+
);
|
| 82 |
+
|
| 83 |
+
/* Settings — p-stream gear icon */
|
| 84 |
+
export const SettingsIcon = (p: P) => (
|
| 85 |
+
<svg viewBox="0 0 48.4 48.4" fill="currentColor" {...p}>
|
| 86 |
+
<path d="M48.4,24.2c0-1.8-1.297-3.719-2.896-4.285s-3.149-1.952-3.6-3.045c-0.451-1.093-0.334-3.173,0.396-4.705 c0.729-1.532,0.287-3.807-0.986-5.08c-1.272-1.273-3.547-1.714-5.08-0.985c-1.532,0.729-3.609,0.848-4.699,0.397 s-2.477-2.003-3.045-3.602C27.921,1.296,26,0,24.2,0c-1.8,0-3.721,1.296-4.29,2.895c-0.569,1.599-1.955,3.151-3.045,3.602 c-1.09,0.451-3.168,0.332-4.7-0.397c-1.532-0.729-3.807-0.288-5.08,0.985c-1.273,1.273-1.714,3.547-0.985,5.08 c0.729,1.533,0.845,3.611,0.392,4.703c-0.453,1.092-1.998,2.481-3.597,3.047S0,22.4,0,24.2s1.296,3.721,2.895,4.29 c1.599,0.568,3.146,1.957,3.599,3.047c0.453,1.089,0.335,3.166-0.394,4.698s-0.288,3.807,0.985,5.08 c1.273,1.272,3.547,1.714,5.08,0.985c1.533-0.729,3.61-0.847,4.7-0.395c1.091,0.452,2.476,2.008,3.045,3.604 c0.569,1.596,2.49,2.891,4.29,2.891c1.8,0,3.721-1.295,4.29-2.891c0.568-1.596,1.953-3.15,3.043-3.604 c1.09-0.453,3.17-0.334,4.701,0.396c1.533,0.729,3.808,0.287,5.08-0.985c1.273-1.273,1.715-3.548,0.986-5.08 c-0.729-1.533-0.849-3.61-0.398-4.7c0.451-1.09,2.004-2.477,3.603-3.045C47.104,27.921,48.4,26,48.4,24.2z M24.2,33.08 c-4.91,0-8.88-3.97-8.88-8.87c0-4.91,3.97-8.88,8.88-8.88c4.899,0,8.87,3.97,8.87,8.88C33.07,29.11,29.1,33.08,24.2,33.08z" />
|
| 87 |
+
</svg>
|
| 88 |
+
);
|
| 89 |
+
|
| 90 |
+
/* Cloud — server selector (solid, FontAwesome cloud) */
|
| 91 |
+
export const CloudIcon = (p: P) => (
|
| 92 |
+
<svg viewBox="0 0 640 512" {...p}>
|
| 93 |
+
<path
|
| 94 |
+
fill="currentColor"
|
| 95 |
+
d="M0 336c0 79.5 64.5 144 144 144H512c70.7 0 128-57.3 128-128c0-61.9-44-113.6-102.4-125.4c4.1-10.7 6.4-22.4 6.4-34.6c0-53-43-96-96-96c-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32C167.6 32 96 103.6 96 192c0 2.7 .1 5.4 .2 8.1C40.2 219.8 0 273.2 0 336z"
|
| 96 |
+
/>
|
| 97 |
+
</svg>
|
| 98 |
+
);
|
| 99 |
+
|
| 100 |
+
/* Fullscreen — FontAwesome expand (p-stream) */
|
| 101 |
+
export const FullscreenIcon = (p: P) => (
|
| 102 |
+
<svg viewBox="0 0 448 512" {...p}>
|
| 103 |
+
<path
|
| 104 |
+
fill="currentColor"
|
| 105 |
+
d="M32 32C14.3 32 0 46.3 0 64v96c0 17.7 14.3 32 32 32s32-14.3 32-32V96h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H32zM64 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H64V352zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32h64v64c0 17.7 14.3 32 32 32s32-14.3 32-32V64c0-17.7-14.3-32-32-32H320zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H320c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V352z"
|
| 106 |
+
/>
|
| 107 |
+
</svg>
|
| 108 |
+
);
|
| 109 |
+
|
| 110 |
+
/* Fullscreen exit — FontAwesome compress (p-stream) */
|
| 111 |
+
export const FullscreenExitIcon = (p: P) => (
|
| 112 |
+
<svg viewBox="0 0 448 512" {...p}>
|
| 113 |
+
<path
|
| 114 |
+
fill="currentColor"
|
| 115 |
+
d="M160 64c0-17.7-14.3-32-32-32s-32 14.3-32 32v64H32c-17.7 0-32 14.3-32 32s14.3 32 32 32h96c17.7 0 32-14.3 32-32V64zM32 320c-17.7 0-32 14.3-32 32s14.3 32 32 32H96v64c0 17.7 14.3 32 32 32s32-14.3 32-32V352c0-17.7-14.3-32-32-32H32zM352 64c0-17.7-14.3-32-32-32s-32 14.3-32 32v96c0 17.7 14.3 32 32 32h96c17.7 0 32-14.3 32-32s-14.3-32-32-32H352V64zM320 320c-17.7 0-32 14.3-32 32v96c0 17.7 14.3 32 32 32s32-14.3 32-32V384h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H320z"
|
| 116 |
+
/>
|
| 117 |
+
</svg>
|
| 118 |
+
);
|
| 119 |
+
|
| 120 |
+
export const HeartIcon = ({ filled, ...p }: P & { filled?: boolean }) => (
|
| 121 |
+
<svg viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} {...p}>
|
| 122 |
+
<path
|
| 123 |
+
d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"
|
| 124 |
+
stroke="currentColor"
|
| 125 |
+
strokeWidth="2"
|
| 126 |
+
strokeLinecap="round"
|
| 127 |
+
strokeLinejoin="round"
|
| 128 |
+
/>
|
| 129 |
+
</svg>
|
| 130 |
+
);
|
| 131 |
+
|
| 132 |
+
/* Filled star — used for the rating badge on the idle overlay */
|
| 133 |
+
export const StarIcon = (p: P) => (
|
| 134 |
+
<svg viewBox="0 0 24 24" fill="currentColor" {...p}>
|
| 135 |
+
<path d="M12 2.5l2.9 5.88 6.49.94-4.7 4.58 1.11 6.46L12 17.77l-5.8 3.05 1.1-6.46-4.69-4.58 6.49-.94L12 2.5z" />
|
| 136 |
+
</svg>
|
| 137 |
+
);
|
| 138 |
+
|
| 139 |
+
export const CheckIcon = (p: P) => (
|
| 140 |
+
<svg viewBox="0 0 24 24" fill="none" {...p}>
|
| 141 |
+
<path
|
| 142 |
+
d="M20 6L9 17l-5-5"
|
| 143 |
+
stroke="currentColor"
|
| 144 |
+
strokeWidth="2.5"
|
| 145 |
+
strokeLinecap="round"
|
| 146 |
+
strokeLinejoin="round"
|
| 147 |
+
/>
|
| 148 |
+
</svg>
|
| 149 |
+
);
|
| 150 |
+
|
| 151 |
+
export const UploadIcon = (p: P) => (
|
| 152 |
+
<svg viewBox="0 0 24 24" fill="none" {...p}>
|
| 153 |
+
<path
|
| 154 |
+
d="M12 16V4m0 0L7 9m5-5l5 5M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"
|
| 155 |
+
stroke="currentColor"
|
| 156 |
+
strokeWidth="2"
|
| 157 |
+
strokeLinecap="round"
|
| 158 |
+
strokeLinejoin="round"
|
| 159 |
+
/>
|
| 160 |
+
</svg>
|
| 161 |
+
);
|
| 162 |
+
|
| 163 |
+
export const SearchIcon = (p: P) => (
|
| 164 |
+
<svg viewBox="0 0 24 24" fill="none" {...p}>
|
| 165 |
+
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2" />
|
| 166 |
+
<path d="m21 21-4.3-4.3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
| 167 |
+
</svg>
|
| 168 |
+
);
|
| 169 |
+
|
| 170 |
+
export const SlidersIcon = (p: P) => (
|
| 171 |
+
<svg viewBox="0 0 24 24" fill="none" {...p}>
|
| 172 |
+
<path
|
| 173 |
+
d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"
|
| 174 |
+
stroke="currentColor"
|
| 175 |
+
strokeWidth="2"
|
| 176 |
+
strokeLinecap="round"
|
| 177 |
+
/>
|
| 178 |
+
</svg>
|
| 179 |
+
);
|
| 180 |
+
|
| 181 |
+
export const ChevronRightIcon = (p: P) => (
|
| 182 |
+
<svg viewBox="0 0 24 24" fill="none" {...p}>
|
| 183 |
+
<path
|
| 184 |
+
d="M9 6l6 6-6 6"
|
| 185 |
+
stroke="currentColor"
|
| 186 |
+
strokeWidth="2"
|
| 187 |
+
strokeLinecap="round"
|
| 188 |
+
strokeLinejoin="round"
|
| 189 |
+
/>
|
| 190 |
+
</svg>
|
| 191 |
+
);
|
| 192 |
+
|
| 193 |
+
export const CloseIcon = (p: P) => (
|
| 194 |
+
<svg viewBox="0 0 24 24" fill="none" {...p}>
|
| 195 |
+
<path
|
| 196 |
+
d="M18 6L6 18M6 6l12 12"
|
| 197 |
+
stroke="currentColor"
|
| 198 |
+
strokeWidth="2"
|
| 199 |
+
strokeLinecap="round"
|
| 200 |
+
/>
|
| 201 |
+
</svg>
|
| 202 |
+
);
|
| 203 |
+
|
| 204 |
+
/* Skip to next episode — play with a trailing bar */
|
| 205 |
+
export const NextEpisodeIcon = (p: P) => (
|
| 206 |
+
<svg viewBox="0 0 24 24" fill="none" {...p}>
|
| 207 |
+
<path d="M5 5l10 7-10 7V5z" fill="currentColor" />
|
| 208 |
+
<path
|
| 209 |
+
d="M19 5v14"
|
| 210 |
+
stroke="currentColor"
|
| 211 |
+
strokeWidth="2.2"
|
| 212 |
+
strokeLinecap="round"
|
| 213 |
+
/>
|
| 214 |
+
</svg>
|
| 215 |
+
);
|
| 216 |
+
|
| 217 |
+
/* Episode list */
|
| 218 |
+
export const ListIcon = (p: P) => (
|
| 219 |
+
<svg viewBox="0 0 24 24" fill="none" {...p}>
|
| 220 |
+
<path
|
| 221 |
+
d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"
|
| 222 |
+
stroke="currentColor"
|
| 223 |
+
strokeWidth="2"
|
| 224 |
+
strokeLinecap="round"
|
| 225 |
+
strokeLinejoin="round"
|
| 226 |
+
/>
|
| 227 |
+
</svg>
|
| 228 |
+
);
|
| 229 |
+
|
| 230 |
+
/* Warning triangle — failed source */
|
| 231 |
+
export const WarnIcon = (p: P) => (
|
| 232 |
+
<svg viewBox="0 0 24 24" fill="none" {...p}>
|
| 233 |
+
<path
|
| 234 |
+
d="M12 3.2 1.6 21h20.8L12 3.2z"
|
| 235 |
+
stroke="currentColor"
|
| 236 |
+
strokeWidth="2"
|
| 237 |
+
strokeLinejoin="round"
|
| 238 |
+
/>
|
| 239 |
+
<path
|
| 240 |
+
d="M12 9.5v5M12 17.6v.01"
|
| 241 |
+
stroke="currentColor"
|
| 242 |
+
strokeWidth="2"
|
| 243 |
+
strokeLinecap="round"
|
| 244 |
+
/>
|
| 245 |
+
</svg>
|
| 246 |
+
);
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
vidsuper:
|
| 3 |
+
build: .
|
| 4 |
+
# Or pull a prebuilt image instead of building:
|
| 5 |
+
# image: ghcr.io/YOUR_USERNAME/vidsuper:latest
|
| 6 |
+
restart: unless-stopped
|
| 7 |
+
ports:
|
| 8 |
+
- "3000:3000"
|
| 9 |
+
env_file:
|
| 10 |
+
- .env
|
lib/config.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Server-side branding config. Read at runtime from the environment, so
|
| 2 |
+
// changing these in .env and restarting is enough — no rebuild required.
|
| 3 |
+
// (These are threaded into the player via server components / props, never
|
| 4 |
+
// referenced from client code, so they don't need the NEXT_PUBLIC_ prefix.)
|
| 5 |
+
|
| 6 |
+
export const PLAYER_NAME = process.env.PLAYER_NAME || "Vidsuper";
|
| 7 |
+
|
| 8 |
+
export const PLAYER_TAGLINE =
|
| 9 |
+
process.env.PLAYER_TAGLINE || "Video streaming player";
|
| 10 |
+
|
| 11 |
+
/** Default accent colour (hex, no leading #). Overridable per-request via ?color= */
|
| 12 |
+
export const DEFAULT_ACCENT = (process.env.ACCENT_COLOR || "ef4444").replace(
|
| 13 |
+
/^#/,
|
| 14 |
+
""
|
| 15 |
+
);
|
| 16 |
+
|
| 17 |
+
/** Logo path served from /public. Replace public/logo.svg to rebrand. */
|
| 18 |
+
export const LOGO_SRC = process.env.LOGO_SRC || "/logo.svg";
|
lib/constants.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type StreamKind = "hls" | "dash" | "native";
|
| 2 |
+
|
| 3 |
+
/** Detect the streaming engine to use from a source URL. */
|
| 4 |
+
export function detectStreamKind(src: string): StreamKind {
|
| 5 |
+
const path = src.split("?")[0].toLowerCase();
|
| 6 |
+
if (path.endsWith(".m3u8")) return "hls";
|
| 7 |
+
if (path.endsWith(".mpd")) return "dash";
|
| 8 |
+
return "native"; // mp4, webm, ogg, mov, …
|
| 9 |
+
}
|
lib/scraper/castle.ts
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from "crypto";
|
| 2 |
+
|
| 3 |
+
// Server-only Castle source resolver (api.hlowb.com). Resolves a TMDB title to
|
| 4 |
+
// a Castle movieId, walks seasons/episodes, and pulls per-language (v1) or
|
| 5 |
+
// shared (v2) HLS streams. All API payloads are AES-CBC encrypted with a key
|
| 6 |
+
// derived from a fetched security key + a fixed suffix (key doubles as IV).
|
| 7 |
+
//
|
| 8 |
+
// The resulting streams play directly (no proxying required).
|
| 9 |
+
|
| 10 |
+
const TMDB_BASE_URL = "https://api.themoviedb.org/3";
|
| 11 |
+
const CASTLE_BASE = "https://api.hlowb.com";
|
| 12 |
+
const PKG = "com.external.castle";
|
| 13 |
+
const CHANNEL = "IndiaA";
|
| 14 |
+
const CLIENT = "1";
|
| 15 |
+
const LANG = "en-US";
|
| 16 |
+
const SUFFIX = "T!BgJB";
|
| 17 |
+
const APK_SIGN_KEY = "ED0955EB04E67A1D9F3305B95454FED485261475";
|
| 18 |
+
|
| 19 |
+
const WORKING_HEADERS: Record<string, string> = {
|
| 20 |
+
"User-Agent": "okhttp/4.9.3",
|
| 21 |
+
Accept: "application/json",
|
| 22 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 23 |
+
Connection: "Keep-Alive",
|
| 24 |
+
Referer: CASTLE_BASE,
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
// Headers the stream host expects for direct playback.
|
| 28 |
+
export const CASTLE_PLAYBACK_HEADERS: Record<string, string> = {
|
| 29 |
+
"User-Agent":
|
| 30 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
| 31 |
+
Accept:
|
| 32 |
+
"video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5",
|
| 33 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 34 |
+
"Accept-Encoding": "identity",
|
| 35 |
+
Connection: "keep-alive",
|
| 36 |
+
"Sec-Fetch-Dest": "video",
|
| 37 |
+
"Sec-Fetch-Mode": "no-cors",
|
| 38 |
+
"Sec-Fetch-Site": "cross-site",
|
| 39 |
+
DNT: "1",
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
export interface CastleStream {
|
| 43 |
+
name: string;
|
| 44 |
+
title: string;
|
| 45 |
+
url: string;
|
| 46 |
+
quality: string;
|
| 47 |
+
size: string;
|
| 48 |
+
provider: string;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
interface TmdbInfo {
|
| 52 |
+
title: string;
|
| 53 |
+
year: number | null;
|
| 54 |
+
tmdbId: number;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const QUALITY_MAP: Record<number, string> = { 1: "480p", 2: "720p", 3: "1080p" };
|
| 58 |
+
|
| 59 |
+
// Castle ids (movieId/episodeId/id) are 16+ digit integers that overflow JS's
|
| 60 |
+
// safe integer range, so JSON.parse silently corrupts them. Quote any integer
|
| 61 |
+
// literal with 16+ digits as a value so it survives parsing as a string.
|
| 62 |
+
function safeJsonParse(text: string): any {
|
| 63 |
+
const safe = text.replace(/:(\s*)(-?\d{16,})(\s*[,}\]])/g, ':$1"$2"$3');
|
| 64 |
+
return JSON.parse(safe);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export class CastleScraper {
|
| 68 |
+
private tmdbKey =
|
| 69 |
+
process.env.CASTLE_TMDB_API_KEY ||
|
| 70 |
+
process.env.TMDB_API_KEY ||
|
| 71 |
+
"439c478a771f35c05022f9feabcca01c";
|
| 72 |
+
private securityKey: string | null = null;
|
| 73 |
+
|
| 74 |
+
// ---- AES-CBC (key derived from security key + suffix, key == IV) -------
|
| 75 |
+
|
| 76 |
+
private deriveKey(apiKeyB64: string): Buffer {
|
| 77 |
+
const apiKeyBytes = Buffer.from(apiKeyB64, "base64");
|
| 78 |
+
let keyMaterial = Buffer.concat([apiKeyBytes, Buffer.from(SUFFIX, "ascii")]);
|
| 79 |
+
if (keyMaterial.length < 16) {
|
| 80 |
+
keyMaterial = Buffer.concat([
|
| 81 |
+
keyMaterial,
|
| 82 |
+
Buffer.alloc(16 - keyMaterial.length),
|
| 83 |
+
]);
|
| 84 |
+
} else if (keyMaterial.length > 16) {
|
| 85 |
+
keyMaterial = keyMaterial.subarray(0, 16);
|
| 86 |
+
}
|
| 87 |
+
return keyMaterial;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
private decryptCastle(encryptedB64: string, securityKeyB64: string): string {
|
| 91 |
+
const aesKey = this.deriveKey(securityKeyB64);
|
| 92 |
+
const iv = aesKey; // key doubles as IV
|
| 93 |
+
const encrypted = Buffer.from(encryptedB64, "base64");
|
| 94 |
+
const decipher = crypto.createDecipheriv("aes-128-cbc", aesKey, iv);
|
| 95 |
+
decipher.setAutoPadding(true); // PKCS#7
|
| 96 |
+
return Buffer.concat([
|
| 97 |
+
decipher.update(encrypted),
|
| 98 |
+
decipher.final(),
|
| 99 |
+
]).toString("utf-8");
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// ---- HTTP -------------------------------------------------------------
|
| 103 |
+
|
| 104 |
+
private async request(
|
| 105 |
+
url: string,
|
| 106 |
+
init?: RequestInit & { headers?: Record<string, string> }
|
| 107 |
+
): Promise<Response> {
|
| 108 |
+
const r = await fetch(url, {
|
| 109 |
+
...init,
|
| 110 |
+
headers: { ...WORKING_HEADERS, ...(init?.headers || {}) },
|
| 111 |
+
});
|
| 112 |
+
if (!r.ok) throw new Error(`Castle request failed ${r.status} for ${url}`);
|
| 113 |
+
return r;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// Response is either {data: "<cipher>"} or a raw cipher string.
|
| 117 |
+
private async extractCipher(response: Response): Promise<string> {
|
| 118 |
+
const text = (await response.text()).trim();
|
| 119 |
+
if (!text) throw new Error("Empty response");
|
| 120 |
+
try {
|
| 121 |
+
const j = JSON.parse(text);
|
| 122 |
+
if (j && typeof j.data === "string") return j.data.trim();
|
| 123 |
+
} catch {
|
| 124 |
+
/* not JSON — raw cipher */
|
| 125 |
+
}
|
| 126 |
+
return text;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
private async decryptedJson(response: Response, securityKey: string): Promise<any> {
|
| 130 |
+
const cipher = await this.extractCipher(response);
|
| 131 |
+
return safeJsonParse(this.decryptCastle(cipher, securityKey));
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
// ---- Castle API -------------------------------------------------------
|
| 135 |
+
|
| 136 |
+
private async getSecurityKey(): Promise<string> {
|
| 137 |
+
if (this.securityKey) return this.securityKey;
|
| 138 |
+
const url = `${CASTLE_BASE}/v0.1/system/getSecurityKey/1?channel=${CHANNEL}&clientType=${CLIENT}&lang=${LANG}`;
|
| 139 |
+
const r = await this.request(url);
|
| 140 |
+
const data = await r.json();
|
| 141 |
+
if (data.code !== 200 || !data.data) {
|
| 142 |
+
throw new Error(`Security key API error: ${JSON.stringify(data)}`);
|
| 143 |
+
}
|
| 144 |
+
this.securityKey = data.data as string;
|
| 145 |
+
return this.securityKey;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
private async searchCastle(
|
| 149 |
+
securityKey: string,
|
| 150 |
+
keyword: string,
|
| 151 |
+
page = 1,
|
| 152 |
+
size = 30
|
| 153 |
+
): Promise<any> {
|
| 154 |
+
const params = new URLSearchParams({
|
| 155 |
+
channel: CHANNEL,
|
| 156 |
+
clientType: CLIENT,
|
| 157 |
+
keyword,
|
| 158 |
+
lang: LANG,
|
| 159 |
+
mode: "1",
|
| 160 |
+
packageName: PKG,
|
| 161 |
+
page: String(page),
|
| 162 |
+
size: String(size),
|
| 163 |
+
});
|
| 164 |
+
const url = `${CASTLE_BASE}/film-api/v1.1.0/movie/searchByKeyword?${params}`;
|
| 165 |
+
return this.decryptedJson(await this.request(url), securityKey);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
private async getDetails(securityKey: string, movieId: string): Promise<any> {
|
| 169 |
+
const url = `${CASTLE_BASE}/film-api/v1.1/movie?channel=${CHANNEL}&clientType=${CLIENT}&lang=${LANG}&movieId=${movieId}&packageName=${PKG}`;
|
| 170 |
+
return this.decryptedJson(await this.request(url), securityKey);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
private async getVideo2(
|
| 174 |
+
securityKey: string,
|
| 175 |
+
movieId: string,
|
| 176 |
+
episodeId: string,
|
| 177 |
+
resolution = 2
|
| 178 |
+
): Promise<any> {
|
| 179 |
+
const url = `${CASTLE_BASE}/film-api/v2.0.1/movie/getVideo2?clientType=${CLIENT}&packageName=${PKG}&channel=${CHANNEL}&lang=${LANG}`;
|
| 180 |
+
const body = {
|
| 181 |
+
mode: "1",
|
| 182 |
+
appMarket: "GuanWang",
|
| 183 |
+
clientType: "1",
|
| 184 |
+
woolUser: "false",
|
| 185 |
+
apkSignKey: APK_SIGN_KEY,
|
| 186 |
+
androidVersion: "13",
|
| 187 |
+
movieId: String(movieId),
|
| 188 |
+
episodeId: String(episodeId),
|
| 189 |
+
isNewUser: "true",
|
| 190 |
+
resolution: String(resolution),
|
| 191 |
+
packageName: PKG,
|
| 192 |
+
};
|
| 193 |
+
const r = await this.request(url, {
|
| 194 |
+
method: "POST",
|
| 195 |
+
headers: { "Content-Type": "application/json" },
|
| 196 |
+
body: JSON.stringify(body),
|
| 197 |
+
});
|
| 198 |
+
return this.decryptedJson(r, securityKey);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
private async getVideoV1(
|
| 202 |
+
securityKey: string,
|
| 203 |
+
movieId: string,
|
| 204 |
+
episodeId: string,
|
| 205 |
+
languageId: string,
|
| 206 |
+
resolution = 2
|
| 207 |
+
): Promise<any> {
|
| 208 |
+
const params = new URLSearchParams({
|
| 209 |
+
apkSignKey: APK_SIGN_KEY,
|
| 210 |
+
channel: CHANNEL,
|
| 211 |
+
clientType: CLIENT,
|
| 212 |
+
episodeId: String(episodeId),
|
| 213 |
+
lang: LANG,
|
| 214 |
+
languageId: String(languageId),
|
| 215 |
+
mode: "1",
|
| 216 |
+
movieId: String(movieId),
|
| 217 |
+
packageName: PKG,
|
| 218 |
+
resolution: String(resolution),
|
| 219 |
+
});
|
| 220 |
+
const url = `${CASTLE_BASE}/film-api/v1.9.1/movie/getVideo?${params}`;
|
| 221 |
+
return this.decryptedJson(await this.request(url), securityKey);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// ---- helpers ----------------------------------------------------------
|
| 225 |
+
|
| 226 |
+
private extractDataBlock(obj: any): any {
|
| 227 |
+
if (obj && typeof obj.data === "object" && obj.data) return obj.data;
|
| 228 |
+
return obj && typeof obj === "object" ? obj : {};
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
private qualityValue(quality?: string): number {
|
| 232 |
+
if (!quality) return 0;
|
| 233 |
+
let q = String(quality).toLowerCase();
|
| 234 |
+
q = q.replace(/^(sd|hd|fhd|uhd|4k)\s*/, "").replace(/p$/, "").trim();
|
| 235 |
+
if (q === "4k" || q === "2160") return 2160;
|
| 236 |
+
const known = [1440, 1080, 720, 480, 360, 240];
|
| 237 |
+
const n = parseInt(q, 10);
|
| 238 |
+
if (known.includes(n)) return n;
|
| 239 |
+
return n > 0 ? n : 0;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
private formatSize(size: any): string {
|
| 243 |
+
if (typeof size === "number" && size > 0) {
|
| 244 |
+
return size > 1_000_000_000
|
| 245 |
+
? `${(size / 1_000_000_000).toFixed(2)} GB`
|
| 246 |
+
: `${Math.round(size / 1_000_000)} MB`;
|
| 247 |
+
}
|
| 248 |
+
return "Unknown";
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
private async getTmdbDetails(
|
| 252 |
+
tmdbId: number,
|
| 253 |
+
mediaType: "movie" | "tv"
|
| 254 |
+
): Promise<TmdbInfo> {
|
| 255 |
+
const endpoint = mediaType === "tv" ? "tv" : "movie";
|
| 256 |
+
const url = `${TMDB_BASE_URL}/${endpoint}/${tmdbId}?api_key=${this.tmdbKey}&append_to_response=external_ids`;
|
| 257 |
+
const r = await this.request(url);
|
| 258 |
+
const data = await r.json();
|
| 259 |
+
const title = mediaType === "tv" ? data.name : data.title;
|
| 260 |
+
const releaseDate =
|
| 261 |
+
mediaType === "tv" ? data.first_air_date : data.release_date;
|
| 262 |
+
const year = releaseDate ? parseInt(String(releaseDate).split("-")[0], 10) : null;
|
| 263 |
+
return { title, year, tmdbId };
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
private findMovieId(searchResult: any, tmdbInfo: TmdbInfo): string {
|
| 267 |
+
const data = this.extractDataBlock(searchResult);
|
| 268 |
+
const rows: any[] = data.rows || [];
|
| 269 |
+
if (!rows.length) throw new Error("No search results found");
|
| 270 |
+
const searchTitle = (tmdbInfo.title || "").toLowerCase();
|
| 271 |
+
|
| 272 |
+
for (const item of rows) {
|
| 273 |
+
const itemTitle = String(item.title || item.name || "").toLowerCase();
|
| 274 |
+
if (
|
| 275 |
+
itemTitle &&
|
| 276 |
+
(itemTitle.includes(searchTitle) || searchTitle.includes(itemTitle))
|
| 277 |
+
) {
|
| 278 |
+
const id = item.id || item.redirectId || item.redirectIdStr;
|
| 279 |
+
if (id) return String(id);
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
const first = rows[0];
|
| 283 |
+
const id = first.id || first.redirectId || first.redirectIdStr;
|
| 284 |
+
if (id) return String(id);
|
| 285 |
+
throw new Error("Could not extract movie ID from search results");
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
private processVideoResponse(
|
| 289 |
+
videoData: any,
|
| 290 |
+
media: TmdbInfo,
|
| 291 |
+
season: number,
|
| 292 |
+
episode: number,
|
| 293 |
+
resolution: number,
|
| 294 |
+
languageInfo: string
|
| 295 |
+
): CastleStream[] {
|
| 296 |
+
const streams: CastleStream[] = [];
|
| 297 |
+
const data = this.extractDataBlock(videoData);
|
| 298 |
+
const videoUrl: string | undefined = data.videoUrl;
|
| 299 |
+
if (!videoUrl) return streams;
|
| 300 |
+
|
| 301 |
+
let mediaTitle = media.title || "Unknown";
|
| 302 |
+
if (media.year) mediaTitle += ` (${media.year})`;
|
| 303 |
+
if (season && episode) {
|
| 304 |
+
mediaTitle = `${media.title} S${String(season).padStart(2, "0")}E${String(
|
| 305 |
+
episode
|
| 306 |
+
).padStart(2, "0")}`;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
const quality = QUALITY_MAP[resolution] || `${resolution}p`;
|
| 310 |
+
|
| 311 |
+
if (Array.isArray(data.videos)) {
|
| 312 |
+
for (const video of data.videos) {
|
| 313 |
+
let q = String(
|
| 314 |
+
video.resolutionDescription || video.resolution || quality
|
| 315 |
+
).replace(/^(SD|HD|FHD)\s+/i, "");
|
| 316 |
+
const name = languageInfo
|
| 317 |
+
? `Castle ${languageInfo} - ${q}`
|
| 318 |
+
: `Castle - ${q}`;
|
| 319 |
+
streams.push({
|
| 320 |
+
name,
|
| 321 |
+
title: mediaTitle,
|
| 322 |
+
url: video.url || videoUrl,
|
| 323 |
+
quality: q,
|
| 324 |
+
size: this.formatSize(video.size),
|
| 325 |
+
provider: "castle",
|
| 326 |
+
});
|
| 327 |
+
}
|
| 328 |
+
} else {
|
| 329 |
+
const name = languageInfo
|
| 330 |
+
? `Castle ${languageInfo} - ${quality}`
|
| 331 |
+
: `Castle - ${quality}`;
|
| 332 |
+
streams.push({
|
| 333 |
+
name,
|
| 334 |
+
title: mediaTitle,
|
| 335 |
+
url: videoUrl,
|
| 336 |
+
quality,
|
| 337 |
+
size: this.formatSize(data.size),
|
| 338 |
+
provider: "castle",
|
| 339 |
+
});
|
| 340 |
+
}
|
| 341 |
+
return streams;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
async fetchSources(
|
| 345 |
+
tmdbId: number,
|
| 346 |
+
season = 0,
|
| 347 |
+
episode = 0
|
| 348 |
+
): Promise<CastleStream[]> {
|
| 349 |
+
const mediaType: "movie" | "tv" = season && episode ? "tv" : "movie";
|
| 350 |
+
try {
|
| 351 |
+
const tmdbInfo = await this.getTmdbDetails(tmdbId, mediaType);
|
| 352 |
+
const securityKey = await this.getSecurityKey();
|
| 353 |
+
|
| 354 |
+
const searchTerm = tmdbInfo.year
|
| 355 |
+
? `${tmdbInfo.title} ${tmdbInfo.year}`
|
| 356 |
+
: tmdbInfo.title;
|
| 357 |
+
const searchResult = await this.searchCastle(securityKey, searchTerm);
|
| 358 |
+
let movieId = this.findMovieId(searchResult, tmdbInfo);
|
| 359 |
+
|
| 360 |
+
let details = await this.getDetails(securityKey, movieId);
|
| 361 |
+
|
| 362 |
+
if (mediaType === "tv") {
|
| 363 |
+
const data = this.extractDataBlock(details);
|
| 364 |
+
const seasons: any[] = data.seasons || [];
|
| 365 |
+
const s = seasons.find((x) => x.number === season);
|
| 366 |
+
if (s && s.movieId && String(s.movieId) !== movieId) {
|
| 367 |
+
movieId = String(s.movieId);
|
| 368 |
+
details = await this.getDetails(securityKey, movieId);
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
const data = this.extractDataBlock(details);
|
| 373 |
+
const episodes: any[] = data.episodes || [];
|
| 374 |
+
|
| 375 |
+
let episodeId: string | null = null;
|
| 376 |
+
if (mediaType === "tv") {
|
| 377 |
+
const ep = episodes.find((e) => e.number === episode);
|
| 378 |
+
if (ep && ep.id) episodeId = String(ep.id);
|
| 379 |
+
} else if (episodes.length) {
|
| 380 |
+
episodeId = String(episodes[0].id);
|
| 381 |
+
}
|
| 382 |
+
if (!episodeId) throw new Error("Could not find episode ID");
|
| 383 |
+
|
| 384 |
+
const epObj = episodes.find((e) => String(e.id) === episodeId);
|
| 385 |
+
const tracks: any[] = epObj?.tracks || [];
|
| 386 |
+
|
| 387 |
+
const resolution = 2;
|
| 388 |
+
let allStreams: CastleStream[] = [];
|
| 389 |
+
|
| 390 |
+
for (let i = 0; i < tracks.length; i++) {
|
| 391 |
+
const track = tracks[i];
|
| 392 |
+
const langName =
|
| 393 |
+
track.languageName || track.abbreviate || `Lang${i + 1}`;
|
| 394 |
+
if (track.existIndividualVideo && track.languageId) {
|
| 395 |
+
try {
|
| 396 |
+
const videoData = await this.getVideoV1(
|
| 397 |
+
securityKey,
|
| 398 |
+
movieId,
|
| 399 |
+
episodeId,
|
| 400 |
+
String(track.languageId),
|
| 401 |
+
resolution
|
| 402 |
+
);
|
| 403 |
+
const langStreams = this.processVideoResponse(
|
| 404 |
+
videoData,
|
| 405 |
+
tmdbInfo,
|
| 406 |
+
season,
|
| 407 |
+
episode,
|
| 408 |
+
resolution,
|
| 409 |
+
`[${langName}]`
|
| 410 |
+
);
|
| 411 |
+
allStreams.push(...langStreams);
|
| 412 |
+
} catch (e: any) {
|
| 413 |
+
console.error(`[Castle] ${langName}: v1 failed - ${e?.message || e}`);
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
if (!allStreams.length) {
|
| 419 |
+
const videoData = await this.getVideo2(
|
| 420 |
+
securityKey,
|
| 421 |
+
movieId,
|
| 422 |
+
episodeId,
|
| 423 |
+
resolution
|
| 424 |
+
);
|
| 425 |
+
allStreams = this.processVideoResponse(
|
| 426 |
+
videoData,
|
| 427 |
+
tmdbInfo,
|
| 428 |
+
season,
|
| 429 |
+
episode,
|
| 430 |
+
resolution,
|
| 431 |
+
"[Shared]"
|
| 432 |
+
);
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
allStreams.sort(
|
| 436 |
+
(a, b) => this.qualityValue(b.quality) - this.qualityValue(a.quality)
|
| 437 |
+
);
|
| 438 |
+
return allStreams;
|
| 439 |
+
} catch (e: any) {
|
| 440 |
+
console.error(`[Castle] Error: ${e?.message || e}`);
|
| 441 |
+
return [];
|
| 442 |
+
}
|
| 443 |
+
}
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
import type { SourceModule } from "./types";
|
| 447 |
+
|
| 448 |
+
const _castle = new CastleScraper();
|
| 449 |
+
|
| 450 |
+
// hlowb.com HLS streams play directly. De-dupe by url (the qualities often share
|
| 451 |
+
// one master playlist) and keep the highest first.
|
| 452 |
+
export const castle: SourceModule = {
|
| 453 |
+
id: "castle",
|
| 454 |
+
name: "Steve",
|
| 455 |
+
label: "HLS · multi-language",
|
| 456 |
+
active: true,
|
| 457 |
+
rank: 5,
|
| 458 |
+
async fetch(ctx) {
|
| 459 |
+
const streams = await _castle
|
| 460 |
+
.fetchSources(ctx.tmdbId, ctx.season, ctx.episode)
|
| 461 |
+
.catch(() => []);
|
| 462 |
+
if (!streams.length) return null;
|
| 463 |
+
const seen = new Set<string>();
|
| 464 |
+
const out = [];
|
| 465 |
+
for (const s of streams) {
|
| 466 |
+
if (seen.has(s.url)) continue;
|
| 467 |
+
seen.add(s.url);
|
| 468 |
+
out.push({
|
| 469 |
+
file: s.url,
|
| 470 |
+
label: s.quality.replace(/p$/i, ""),
|
| 471 |
+
type: (s.url.includes(".m3u8")
|
| 472 |
+
? "hls"
|
| 473 |
+
: s.url.includes(".mpd")
|
| 474 |
+
? "dash"
|
| 475 |
+
: "mp4") as "hls" | "dash" | "mp4",
|
| 476 |
+
});
|
| 477 |
+
}
|
| 478 |
+
return { streams: out };
|
| 479 |
+
},
|
| 480 |
+
};
|
lib/scraper/cinesu.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from "crypto";
|
| 2 |
+
import type { SourceModule, SourceStream } from "./types";
|
| 3 |
+
|
| 4 |
+
// Server-only CineSu resolver (cine.su). Fetches an encrypted octet-stream,
|
| 5 |
+
// decrypts it with a salted-SHA256 keystream + per-byte mask, and rebuilds each
|
| 6 |
+
// HLS variant. The /v1/s path token rotates per site build, so it's scraped out
|
| 7 |
+
// of the page JS automatically (see getApiToken).
|
| 8 |
+
|
| 9 |
+
const BASE_URL = "https://cine.su";
|
| 10 |
+
// Last-known credentials, used only as a fallback if live extraction fails.
|
| 11 |
+
const FALLBACK_TOKEN = "4f069b6a3007";
|
| 12 |
+
const FALLBACK_SALT =
|
| 13 |
+
"ac99adcd819fb92558bbc706dc13d74ed73971fc3619056e56a63a08d1496099";
|
| 14 |
+
const UA =
|
| 15 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36";
|
| 16 |
+
|
| 17 |
+
// ---- Rotating credential extraction ------------------------------------
|
| 18 |
+
// Two per-build secrets live in a Next.js chunk and rotate on every deploy:
|
| 19 |
+
// • the path token — `…="<12 hex>"`, used as `/v1/s/${token}/m/…`
|
| 20 |
+
// • the hash salt — `` `<64 hex>:${e}` `` inside the salted-hash function
|
| 21 |
+
// The chunk, variable, and both values rotate, so we scrape them live + cache.
|
| 22 |
+
interface CineSuCreds {
|
| 23 |
+
token: string;
|
| 24 |
+
salt: string;
|
| 25 |
+
}
|
| 26 |
+
let credsCache: { creds: CineSuCreds; at: number } | null = null;
|
| 27 |
+
const CREDS_TTL = 6 * 60 * 60 * 1000; // 6h
|
| 28 |
+
|
| 29 |
+
function credsFromJs(js: string): CineSuCreds | null {
|
| 30 |
+
if (!js.includes("/v1/s/")) return null;
|
| 31 |
+
// token: the variable used in `/v1/s/${VAR}/m/`, then its literal.
|
| 32 |
+
let token: string | null = null;
|
| 33 |
+
const vm = js.match(/\/v1\/s\/\$\{(\w+)\}\/m\//);
|
| 34 |
+
if (vm) {
|
| 35 |
+
const t = js.match(new RegExp(`\\b${vm[1]}\\s*=\\s*["']([0-9a-f]{12})["']`));
|
| 36 |
+
if (t) token = t[1];
|
| 37 |
+
}
|
| 38 |
+
if (!token) {
|
| 39 |
+
const g = js.match(/["']([0-9a-f]{12})["']/); // lone 12-hex literal
|
| 40 |
+
if (g) token = g[1];
|
| 41 |
+
}
|
| 42 |
+
// salt: the 64-hex literal at the head of the `<salt>:${...}` template.
|
| 43 |
+
const sm = js.match(/([0-9a-f]{64}):\$\{/);
|
| 44 |
+
const salt = sm ? sm[1] : null;
|
| 45 |
+
if (token && salt) return { token, salt };
|
| 46 |
+
return null;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
async function extractCreds(): Promise<CineSuCreds | null> {
|
| 50 |
+
const headers = { "User-Agent": UA };
|
| 51 |
+
const page = await fetch(`${BASE_URL}/en/watch-movie/1339713`, {
|
| 52 |
+
headers,
|
| 53 |
+
cache: "no-store",
|
| 54 |
+
});
|
| 55 |
+
if (!page.ok) return null;
|
| 56 |
+
const html = await page.text();
|
| 57 |
+
const chunks = [
|
| 58 |
+
...new Set(
|
| 59 |
+
[...html.matchAll(/\/_next\/static\/chunks\/[a-z0-9]+\.js/g)].map(
|
| 60 |
+
(x) => x[0]
|
| 61 |
+
)
|
| 62 |
+
),
|
| 63 |
+
];
|
| 64 |
+
const bodies = await Promise.all(
|
| 65 |
+
chunks.map((p) =>
|
| 66 |
+
fetch(`${BASE_URL}${p}`, { headers, cache: "no-store" })
|
| 67 |
+
.then((r) => (r.ok ? r.text() : null))
|
| 68 |
+
.catch(() => null)
|
| 69 |
+
)
|
| 70 |
+
);
|
| 71 |
+
for (const js of bodies) {
|
| 72 |
+
const c = js && credsFromJs(js);
|
| 73 |
+
if (c) return c;
|
| 74 |
+
}
|
| 75 |
+
return null;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
async function getApiCreds(force = false): Promise<CineSuCreds> {
|
| 79 |
+
if (!force && credsCache && Date.now() - credsCache.at < CREDS_TTL) {
|
| 80 |
+
return credsCache.creds;
|
| 81 |
+
}
|
| 82 |
+
const c = await extractCreds().catch(() => null);
|
| 83 |
+
if (c) {
|
| 84 |
+
credsCache = { creds: c, at: Date.now() };
|
| 85 |
+
return c;
|
| 86 |
+
}
|
| 87 |
+
return credsCache?.creds ?? { token: FALLBACK_TOKEN, salt: FALLBACK_SALT };
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function sha256(buf: Buffer): Buffer {
|
| 91 |
+
return crypto.createHash("sha256").update(buf).digest();
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// Salted SHA-256 (salt rotates per build).
|
| 95 |
+
function hs(text: string, salt: string): Buffer {
|
| 96 |
+
return sha256(Buffer.concat([Buffer.from(`${salt}:`), Buffer.from(text, "utf-8")]));
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Keystream: SHA-256(baseHash || counter) blocks concatenated to `length`.
|
| 100 |
+
function hr(text: string, length: number, salt: string): Buffer {
|
| 101 |
+
const baseHash = hs(text, salt);
|
| 102 |
+
const out = Buffer.alloc(length);
|
| 103 |
+
let s = 0;
|
| 104 |
+
let a = 0;
|
| 105 |
+
while (s < length) {
|
| 106 |
+
const counter = Buffer.alloc(4);
|
| 107 |
+
counter.writeUInt32BE(a >>> 0, 0);
|
| 108 |
+
const h = sha256(Buffer.concat([baseHash, counter]));
|
| 109 |
+
const take = Math.min(h.length, length - s);
|
| 110 |
+
h.copy(out, s, 0, take);
|
| 111 |
+
s += h.length; // advance a full block even on the final partial copy
|
| 112 |
+
a += 1;
|
| 113 |
+
}
|
| 114 |
+
return out;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// Decrypt the octet-stream: subtract the rolling mask, then XOR the keystream.
|
| 118 |
+
function hi(data: Buffer, tStr: string, salt: string): Buffer {
|
| 119 |
+
const iStream = hr(tStr, data.length, salt);
|
| 120 |
+
const rHash = hs(`mask:${tStr}`, salt);
|
| 121 |
+
const rLen = rHash.length;
|
| 122 |
+
const out = Buffer.alloc(data.length);
|
| 123 |
+
for (let t = 0; t < data.length; t++) {
|
| 124 |
+
const mask = (17 * t + rHash[t % rLen]) & 255;
|
| 125 |
+
let val = (data[t] - mask) & 255;
|
| 126 |
+
val = (val ^ iStream[t]) & 255;
|
| 127 |
+
out[t] = val;
|
| 128 |
+
}
|
| 129 |
+
return out;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
export interface CineSuVariant {
|
| 133 |
+
label: string;
|
| 134 |
+
base: string;
|
| 135 |
+
init?: string;
|
| 136 |
+
target: number;
|
| 137 |
+
segs: { n: string; d?: number; x?: boolean }[];
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// Fetch + decrypt the artifact and return the raw HLS variants. Exported so the
|
| 141 |
+
// /api/cinesu playlist route can rebuild the M3U8 (the segments are PNG-wrapped
|
| 142 |
+
// MPEG-TS, so they must be proxied + de-wrapped — see that route).
|
| 143 |
+
export async function cineSuVariants(
|
| 144 |
+
tmdbId: number,
|
| 145 |
+
season: number,
|
| 146 |
+
episode: number
|
| 147 |
+
): Promise<CineSuVariant[]> {
|
| 148 |
+
const isMovie = !season || !episode;
|
| 149 |
+
const mType = isMovie ? "movie" : "tv";
|
| 150 |
+
const sVal = isMovie ? 0 : season;
|
| 151 |
+
const eVal = isMovie ? 0 : episode;
|
| 152 |
+
|
| 153 |
+
const payload = { m: mType, t: tmdbId, s: sVal, e: eVal };
|
| 154 |
+
const blob = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
| 155 |
+
const referer = `${BASE_URL}/en/watch-${mType}/${tmdbId}`;
|
| 156 |
+
const tStr = `artifact:${payload.m}:${payload.t}:${payload.s}:${payload.e}`;
|
| 157 |
+
|
| 158 |
+
const attempt = (apiToken: string) =>
|
| 159 |
+
fetch(`${BASE_URL}/v1/s/${apiToken}/m/${blob}`, {
|
| 160 |
+
headers: {
|
| 161 |
+
"User-Agent": UA,
|
| 162 |
+
accept: "application/octet-stream,*/*",
|
| 163 |
+
"accept-language": "en-GB,en;q=0.5",
|
| 164 |
+
Referer: referer,
|
| 165 |
+
},
|
| 166 |
+
cache: "no-store",
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
let creds = await getApiCreds();
|
| 170 |
+
let res = await attempt(creds.token);
|
| 171 |
+
// A 404 usually means the cached creds went stale (site redeployed) — scrape
|
| 172 |
+
// fresh ones and retry once.
|
| 173 |
+
if (res.status === 404) {
|
| 174 |
+
const fresh = await getApiCreds(true);
|
| 175 |
+
if (fresh.token !== creds.token) {
|
| 176 |
+
creds = fresh;
|
| 177 |
+
res = await attempt(creds.token);
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
if (!res.ok) return [];
|
| 181 |
+
|
| 182 |
+
const encData = Buffer.from(await res.arrayBuffer());
|
| 183 |
+
const decrypted = hi(encData, tStr, creds.salt);
|
| 184 |
+
|
| 185 |
+
let data: any;
|
| 186 |
+
try {
|
| 187 |
+
data = JSON.parse(decrypted.toString("utf-8"));
|
| 188 |
+
} catch {
|
| 189 |
+
return [];
|
| 190 |
+
}
|
| 191 |
+
// The decrypted payload echoes the token back as `tag`.
|
| 192 |
+
if (data.tag !== creds.token || !Array.isArray(data.variants)) return [];
|
| 193 |
+
|
| 194 |
+
return data.variants.map((v: any) => ({
|
| 195 |
+
label: cleanLabel(v.label),
|
| 196 |
+
base: v.base || "",
|
| 197 |
+
init: v.init || "",
|
| 198 |
+
target: v.target || 6,
|
| 199 |
+
segs: v.segs || [],
|
| 200 |
+
}));
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// Strip the PNG-polyglot wrapper to expose the embedded MPEG-TS. The real TS
|
| 204 |
+
// starts right after the PNG IEND chunk (IEND + its 4-byte CRC).
|
| 205 |
+
export function stripPngWrapper(buf: Buffer): Buffer {
|
| 206 |
+
const i = buf.indexOf("IEND");
|
| 207 |
+
if (i >= 0) {
|
| 208 |
+
const ts = buf.subarray(i + 8);
|
| 209 |
+
if (ts[0] === 0x47) return ts; // valid TS sync byte
|
| 210 |
+
}
|
| 211 |
+
return buf;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// Build the M3U8 for one variant, routing each PNG-wrapped segment through a
|
| 215 |
+
// proxy that de-wraps it. `proxySeg(url)` returns a same-origin TS url.
|
| 216 |
+
export function buildVariantM3u8(
|
| 217 |
+
variant: CineSuVariant,
|
| 218 |
+
proxySeg: (url: string) => string
|
| 219 |
+
): string {
|
| 220 |
+
const { base, init, target, segs } = variant;
|
| 221 |
+
const lines = [
|
| 222 |
+
"#EXTM3U",
|
| 223 |
+
"#EXT-X-VERSION:6",
|
| 224 |
+
`#EXT-X-TARGETDURATION:${Math.round(target)}`,
|
| 225 |
+
"#EXT-X-PLAYLIST-TYPE:VOD",
|
| 226 |
+
"#EXT-X-MEDIA-SEQUENCE:1",
|
| 227 |
+
];
|
| 228 |
+
if (init) lines.push(`#EXT-X-MAP:URI="${proxySeg(base + init)}"`);
|
| 229 |
+
for (const seg of segs) {
|
| 230 |
+
if (seg.x) continue;
|
| 231 |
+
const dur = seg.d && seg.d > 0 ? seg.d : target;
|
| 232 |
+
lines.push(`#EXTINF:${dur.toFixed(3)},`);
|
| 233 |
+
lines.push(proxySeg(base + (seg.n || "")));
|
| 234 |
+
}
|
| 235 |
+
lines.push("#EXT-X-ENDLIST");
|
| 236 |
+
return lines.join("\n") + "\n";
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// "1920x1080" -> "1080", "720p" -> "720", else the raw label (or "auto").
|
| 240 |
+
function cleanLabel(label: any): string {
|
| 241 |
+
const s = String(label || "auto");
|
| 242 |
+
const wxh = s.match(/\d+\s*[x×]\s*(\d+)/i);
|
| 243 |
+
if (wxh) return wxh[1];
|
| 244 |
+
return s.replace(/p$/i, "");
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
export const cinesu: SourceModule = {
|
| 248 |
+
id: "cinesu",
|
| 249 |
+
name: "Alex",
|
| 250 |
+
label: "HLS · reconstructed",
|
| 251 |
+
active: true,
|
| 252 |
+
rank: 3,
|
| 253 |
+
async fetch(ctx) {
|
| 254 |
+
const variants = await cineSuVariants(
|
| 255 |
+
ctx.tmdbId,
|
| 256 |
+
ctx.season,
|
| 257 |
+
ctx.episode
|
| 258 |
+
).catch(() => []);
|
| 259 |
+
if (!variants.length) return null;
|
| 260 |
+
// Each variant's playlist (and its PNG-wrapped segments) is served by the
|
| 261 |
+
// /api/cinesu route, so the encrypted sources payload stays small.
|
| 262 |
+
const qs = (v: number) =>
|
| 263 |
+
`m=${ctx.season && ctx.episode ? "tv" : "movie"}&t=${ctx.tmdbId}&s=${
|
| 264 |
+
ctx.season || 0
|
| 265 |
+
}&e=${ctx.episode || 0}&v=${v}`;
|
| 266 |
+
const streams: SourceStream[] = variants.map((variant, i) => ({
|
| 267 |
+
file: `${ctx.origin}/api/cinesu?${qs(i)}`,
|
| 268 |
+
label: variant.label,
|
| 269 |
+
type: "hls" as const,
|
| 270 |
+
}));
|
| 271 |
+
streams.sort(
|
| 272 |
+
(a, b) => (parseInt(b.label, 10) || 0) - (parseInt(a.label, 10) || 0)
|
| 273 |
+
);
|
| 274 |
+
return { streams };
|
| 275 |
+
},
|
| 276 |
+
};
|
lib/scraper/flixhq.ts
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from "crypto";
|
| 2 |
+
|
| 3 |
+
// Server-only FlixHQ source resolver. Searches flixhq.one for a TMDB title,
|
| 4 |
+
// locates the watch page (and episode for TV), picks a non-videasy/vidking
|
| 5 |
+
// server embed, then runs the megacloud/rabbitstream access flow
|
| 6 |
+
// (challenge → attest → captcha PoW → playback) and AES-GCM-decrypts the
|
| 7 |
+
// playback payload to recover the master HLS url + caption tracks.
|
| 8 |
+
//
|
| 9 |
+
// The resulting stream plays directly (no proxying required).
|
| 10 |
+
|
| 11 |
+
const MAIN_URL = "https://flixhq.one";
|
| 12 |
+
const UA =
|
| 13 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
| 14 |
+
|
| 15 |
+
export interface FlixHqSource {
|
| 16 |
+
file: string;
|
| 17 |
+
label: string;
|
| 18 |
+
type: string;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export interface FlixHqSub {
|
| 22 |
+
url: string;
|
| 23 |
+
label: string;
|
| 24 |
+
language: string;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export interface FlixHqResult {
|
| 28 |
+
sources: FlixHqSource[];
|
| 29 |
+
subtitles: FlixHqSub[];
|
| 30 |
+
provider: string;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
interface TmdbInfo {
|
| 34 |
+
title: string;
|
| 35 |
+
mediaType: "movie" | "tv";
|
| 36 |
+
year: number | null;
|
| 37 |
+
runtime: number | null;
|
| 38 |
+
season: number;
|
| 39 |
+
episode: number;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
interface SearchResult {
|
| 43 |
+
title: string;
|
| 44 |
+
link: string;
|
| 45 |
+
year: number;
|
| 46 |
+
runtime: number;
|
| 47 |
+
mediaType: string;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// ---------------------------------------------------------------------------
|
| 51 |
+
// base64url helpers
|
| 52 |
+
// ---------------------------------------------------------------------------
|
| 53 |
+
function decodeB64Url(s: string): Buffer {
|
| 54 |
+
return Buffer.from(s, "base64url");
|
| 55 |
+
}
|
| 56 |
+
function encodeB64Url(b: Buffer): string {
|
| 57 |
+
return b.toString("base64url");
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// ---------------------------------------------------------------------------
|
| 61 |
+
// Proof-of-work hash (port of the megacloud captcha solver)
|
| 62 |
+
// ---------------------------------------------------------------------------
|
| 63 |
+
const BE = 512;
|
| 64 |
+
const LT = 511;
|
| 65 |
+
const DR = 2;
|
| 66 |
+
const LR = 2654435761;
|
| 67 |
+
const HR = 2246822519;
|
| 68 |
+
|
| 69 |
+
function yeFast(t: number[]): void {
|
| 70 |
+
t[0] = (t[0] + t[1]) >>> 0;
|
| 71 |
+
let x = (t[3] ^ t[0]) >>> 0;
|
| 72 |
+
t[3] = ((x << 16) | (x >>> 16)) >>> 0;
|
| 73 |
+
t[2] = (t[2] + t[3]) >>> 0;
|
| 74 |
+
x = (t[1] ^ t[2]) >>> 0;
|
| 75 |
+
t[1] = ((x << 12) | (x >>> 20)) >>> 0;
|
| 76 |
+
t[0] = (t[0] + t[1]) >>> 0;
|
| 77 |
+
x = (t[3] ^ t[0]) >>> 0;
|
| 78 |
+
t[3] = ((x << 8) | (x >>> 24)) >>> 0;
|
| 79 |
+
t[2] = (t[2] + t[3]) >>> 0;
|
| 80 |
+
x = (t[1] ^ t[2]) >>> 0;
|
| 81 |
+
t[1] = ((x << 7) | (x >>> 25)) >>> 0;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function gr(input: Buffer): number[] {
|
| 85 |
+
const e = [1779033703, 3144134277, 1013904242, 2773480762];
|
| 86 |
+
for (const b of input) {
|
| 87 |
+
e[0] = (e[0] + b) >>> 0;
|
| 88 |
+
e[0] = ((e[0] << 7) | (e[0] >>> 25)) >>> 0;
|
| 89 |
+
yeFast(e);
|
| 90 |
+
}
|
| 91 |
+
for (let i = 0; i < 8; i++) yeFast(e);
|
| 92 |
+
|
| 93 |
+
const r = new Array<number>(BE);
|
| 94 |
+
for (let i = 0; i < BE; i++) {
|
| 95 |
+
yeFast(e);
|
| 96 |
+
r[i] = (e[0] ^ e[2]) >>> 0;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
for (let d = 0; d < DR; d++) {
|
| 100 |
+
for (let s = 0; s < BE; s++) {
|
| 101 |
+
const a = r[s] & LT;
|
| 102 |
+
let c = (r[s] + r[a]) >>> 0;
|
| 103 |
+
c = ((c << 13) | (c >>> 19)) >>> 0;
|
| 104 |
+
c = (c ^ (Math.imul(r[(s + 1) & LT], LR) >>> 0)) >>> 0;
|
| 105 |
+
r[s] = c;
|
| 106 |
+
e[0] = (e[0] ^ c) >>> 0;
|
| 107 |
+
yeFast(e);
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const n = new Array<number>(8);
|
| 112 |
+
const o = BE / 8;
|
| 113 |
+
for (let i = 0; i < 8; i++) {
|
| 114 |
+
yeFast(e);
|
| 115 |
+
let sVal = e[0] >>> 0;
|
| 116 |
+
const a = i * o;
|
| 117 |
+
for (let c = 0; c < o; c++) {
|
| 118 |
+
const d = r[a + c];
|
| 119 |
+
sVal = (sVal + d) >>> 0;
|
| 120 |
+
sVal = ((sVal << 5) | (sVal >>> 27)) >>> 0;
|
| 121 |
+
sVal = (sVal ^ (Math.imul(d, HR) >>> 0)) >>> 0;
|
| 122 |
+
}
|
| 123 |
+
n[i] = (sVal ^ e[2]) >>> 0;
|
| 124 |
+
}
|
| 125 |
+
return n;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// Count leading zero bits across the 8-word digest (difficulty check).
|
| 129 |
+
function wr(t: number[]): number {
|
| 130 |
+
let eVal = 0;
|
| 131 |
+
for (const n of t) {
|
| 132 |
+
if (n === 0) eVal += 32;
|
| 133 |
+
else return eVal + Math.clz32(n);
|
| 134 |
+
}
|
| 135 |
+
return eVal;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// ---------------------------------------------------------------------------
|
| 139 |
+
// Lightweight string similarity (Jaro-Winkler) for title scoring.
|
| 140 |
+
// ---------------------------------------------------------------------------
|
| 141 |
+
function jaro(a: string, b: string): number {
|
| 142 |
+
if (a === b) return 1;
|
| 143 |
+
const la = a.length;
|
| 144 |
+
const lb = b.length;
|
| 145 |
+
if (la === 0 || lb === 0) return 0;
|
| 146 |
+
const range = Math.max(0, Math.floor(Math.max(la, lb) / 2) - 1);
|
| 147 |
+
const aMatch = new Array<boolean>(la).fill(false);
|
| 148 |
+
const bMatch = new Array<boolean>(lb).fill(false);
|
| 149 |
+
let matches = 0;
|
| 150 |
+
for (let i = 0; i < la; i++) {
|
| 151 |
+
const start = Math.max(0, i - range);
|
| 152 |
+
const end = Math.min(i + range + 1, lb);
|
| 153 |
+
for (let j = start; j < end; j++) {
|
| 154 |
+
if (bMatch[j] || a[i] !== b[j]) continue;
|
| 155 |
+
aMatch[i] = true;
|
| 156 |
+
bMatch[j] = true;
|
| 157 |
+
matches++;
|
| 158 |
+
break;
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
if (matches === 0) return 0;
|
| 162 |
+
let t = 0;
|
| 163 |
+
let k = 0;
|
| 164 |
+
for (let i = 0; i < la; i++) {
|
| 165 |
+
if (!aMatch[i]) continue;
|
| 166 |
+
while (!bMatch[k]) k++;
|
| 167 |
+
if (a[i] !== b[k]) t++;
|
| 168 |
+
k++;
|
| 169 |
+
}
|
| 170 |
+
t /= 2;
|
| 171 |
+
return (matches / la + matches / lb + (matches - t) / matches) / 3;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function jaroWinkler(a: string, b: string): number {
|
| 175 |
+
const j = jaro(a, b);
|
| 176 |
+
let prefix = 0;
|
| 177 |
+
for (let i = 0; i < Math.min(4, a.length, b.length); i++) {
|
| 178 |
+
if (a[i] === b[i]) prefix++;
|
| 179 |
+
else break;
|
| 180 |
+
}
|
| 181 |
+
return j + prefix * 0.1 * (1 - j);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
// ---------------------------------------------------------------------------
|
| 185 |
+
// Best-effort 2-letter language code from a caption track label.
|
| 186 |
+
// ---------------------------------------------------------------------------
|
| 187 |
+
function guessLang(label: string): string {
|
| 188 |
+
const n = (label || "").toLowerCase();
|
| 189 |
+
if (n.includes("english")) return "en";
|
| 190 |
+
if (n.includes("spanish") || n.includes("español")) return "es";
|
| 191 |
+
if (n.includes("french") || n.includes("français")) return "fr";
|
| 192 |
+
if (n.includes("german") || n.includes("deutsch")) return "de";
|
| 193 |
+
if (n.includes("portuguese") || n.includes("português")) return "pt";
|
| 194 |
+
if (n.includes("italian")) return "it";
|
| 195 |
+
if (n.includes("arabic")) return "ar";
|
| 196 |
+
if (n.includes("russian")) return "ru";
|
| 197 |
+
if (n.includes("japanese")) return "ja";
|
| 198 |
+
if (n.includes("korean")) return "ko";
|
| 199 |
+
if (n.includes("chinese") || n.includes("mandarin")) return "zh";
|
| 200 |
+
if (n.includes("hindi")) return "hi";
|
| 201 |
+
return "";
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
export class FlixHqScraper {
|
| 205 |
+
private tmdbKey =
|
| 206 |
+
process.env.TMDB_API_KEY || "";
|
| 207 |
+
|
| 208 |
+
async fetchSources(
|
| 209 |
+
tmdbId: number,
|
| 210 |
+
season = 0,
|
| 211 |
+
episode = 0
|
| 212 |
+
): Promise<FlixHqResult | null> {
|
| 213 |
+
try {
|
| 214 |
+
const mediaType: "movie" | "tv" =
|
| 215 |
+
season > 0 && episode > 0 ? "tv" : "movie";
|
| 216 |
+
|
| 217 |
+
const tmdb = await this.fetchTmdbInfo(tmdbId, mediaType, season, episode);
|
| 218 |
+
const results = await this.search(tmdb.title);
|
| 219 |
+
const scored = this.scoreResults(tmdb, results);
|
| 220 |
+
if (!scored.length) return null;
|
| 221 |
+
|
| 222 |
+
let watchUrl = scored[0].link;
|
| 223 |
+
|
| 224 |
+
if (mediaType === "tv") {
|
| 225 |
+
const ep = await this.extractEpisodeLink(watchUrl, season, episode);
|
| 226 |
+
if (!ep) return null;
|
| 227 |
+
watchUrl = ep;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
const servers = await this.getServers(watchUrl, mediaType === "tv");
|
| 231 |
+
let embed: string | null = null;
|
| 232 |
+
for (const srv of servers) {
|
| 233 |
+
const name = String(srv?.name || "").toLowerCase();
|
| 234 |
+
if (!name.includes("videasy") && !name.includes("vidking")) {
|
| 235 |
+
embed = srv?.link || null;
|
| 236 |
+
if (embed) break;
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
if (!embed) return null;
|
| 240 |
+
|
| 241 |
+
return await this.extractRabbitstream(embed);
|
| 242 |
+
} catch (e: any) {
|
| 243 |
+
console.error("FlixHqScraper Error:", e?.message || e);
|
| 244 |
+
return null;
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// ---- internals -------------------------------------------------------
|
| 249 |
+
|
| 250 |
+
private async httpGet(url: string, init?: RequestInit): Promise<string> {
|
| 251 |
+
for (let i = 0; i < 2; i++) {
|
| 252 |
+
try {
|
| 253 |
+
const r = await fetch(url, {
|
| 254 |
+
...init,
|
| 255 |
+
headers: { "User-Agent": UA, ...(init?.headers || {}) },
|
| 256 |
+
});
|
| 257 |
+
if (r.ok) return await r.text();
|
| 258 |
+
} catch {
|
| 259 |
+
/* retry */
|
| 260 |
+
}
|
| 261 |
+
await new Promise((res) => setTimeout(res, 500));
|
| 262 |
+
}
|
| 263 |
+
throw new Error(`Failed to fetch ${url}`);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
private async fetchTmdbInfo(
|
| 267 |
+
tmdbId: number,
|
| 268 |
+
mediaType: "movie" | "tv",
|
| 269 |
+
season: number,
|
| 270 |
+
episode: number
|
| 271 |
+
): Promise<TmdbInfo> {
|
| 272 |
+
const r = await fetch(
|
| 273 |
+
`https://api.themoviedb.org/3/${mediaType}/${tmdbId}?api_key=${this.tmdbKey}&language=en-US`
|
| 274 |
+
);
|
| 275 |
+
if (!r.ok) throw new Error("Failed to fetch data from TMDB.");
|
| 276 |
+
const d = await r.json();
|
| 277 |
+
const title: string = d.title || d.name || "";
|
| 278 |
+
const dateStr: string = d.release_date || d.first_air_date || "";
|
| 279 |
+
const year = dateStr.length >= 4 ? parseInt(dateStr.slice(0, 4), 10) : null;
|
| 280 |
+
const runtime =
|
| 281 |
+
mediaType === "movie"
|
| 282 |
+
? d.runtime ?? null
|
| 283 |
+
: Array.isArray(d.episode_run_time)
|
| 284 |
+
? d.episode_run_time[0] ?? null
|
| 285 |
+
: null;
|
| 286 |
+
return { title, mediaType, year, runtime, season, episode };
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
private async search(query: string): Promise<SearchResult[]> {
|
| 290 |
+
const html = await this.httpGet(
|
| 291 |
+
`${MAIN_URL}/search?keyword=${encodeURIComponent(query)}`
|
| 292 |
+
);
|
| 293 |
+
const results: SearchResult[] = [];
|
| 294 |
+
|
| 295 |
+
// Each result lives in a .flw-item block; split on the class marker.
|
| 296 |
+
const chunks = html.split("flw-item");
|
| 297 |
+
for (let i = 1; i < chunks.length; i++) {
|
| 298 |
+
const chunk = chunks[i];
|
| 299 |
+
const nameMatch = chunk.match(
|
| 300 |
+
/class="film-name"[^>]*>\s*<a\s+href="([^"]+)"[^>]*title="([^"]*)"/
|
| 301 |
+
);
|
| 302 |
+
if (!nameMatch) continue;
|
| 303 |
+
let link = nameMatch[1];
|
| 304 |
+
const title = decodeEntities(nameMatch[2]).trim();
|
| 305 |
+
if (!link.startsWith("http")) link = `${MAIN_URL}${link}`;
|
| 306 |
+
|
| 307 |
+
let year = 0;
|
| 308 |
+
let runtime = 0;
|
| 309 |
+
const infoRe = /class="fdi-item[^"]*"[^>]*>\s*([^<]+?)\s*</g;
|
| 310 |
+
let m: RegExpExecArray | null;
|
| 311 |
+
while ((m = infoRe.exec(chunk))) {
|
| 312 |
+
const txt = m[1].trim();
|
| 313 |
+
if (/^\d{4}$/.test(txt)) year = parseInt(txt, 10);
|
| 314 |
+
else if (/^\d+m$/.test(txt)) runtime = parseInt(txt.slice(0, -1), 10);
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
const typeMatch = chunk.match(/class="fdi-type"[^>]*>\s*([^<]+?)\s*</);
|
| 318 |
+
const mediaType = typeMatch ? typeMatch[1].trim().toLowerCase() : "movie";
|
| 319 |
+
|
| 320 |
+
results.push({ title, link, year, runtime, mediaType });
|
| 321 |
+
}
|
| 322 |
+
return results;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
private scoreResults(tmdb: TmdbInfo, results: SearchResult[]): SearchResult[] {
|
| 326 |
+
const dur = tmdb.runtime || 0;
|
| 327 |
+
const scored: { score: number; res: SearchResult }[] = [];
|
| 328 |
+
results.forEach((res, idx) => {
|
| 329 |
+
if (
|
| 330 |
+
tmdb.mediaType === "movie" &&
|
| 331 |
+
dur > 0 &&
|
| 332 |
+
res.runtime > 0 &&
|
| 333 |
+
Math.abs(dur - res.runtime) > 60
|
| 334 |
+
) {
|
| 335 |
+
return;
|
| 336 |
+
}
|
| 337 |
+
let score =
|
| 338 |
+
jaroWinkler(tmdb.title.toLowerCase(), res.title.toLowerCase()) * 2000;
|
| 339 |
+
if (tmdb.mediaType === res.mediaType) score += 500;
|
| 340 |
+
score += 100 - Math.min(idx, 50);
|
| 341 |
+
if (tmdb.year && res.year > 0) {
|
| 342 |
+
if (tmdb.year === res.year) score += 800;
|
| 343 |
+
else if (Math.abs(tmdb.year - res.year) <= 1) score += 300;
|
| 344 |
+
}
|
| 345 |
+
if (
|
| 346 |
+
tmdb.mediaType === "movie" &&
|
| 347 |
+
dur > 0 &&
|
| 348 |
+
res.runtime > 0 &&
|
| 349 |
+
Math.abs(dur - res.runtime) <= 10
|
| 350 |
+
) {
|
| 351 |
+
score += 1000;
|
| 352 |
+
}
|
| 353 |
+
scored.push({ score, res });
|
| 354 |
+
});
|
| 355 |
+
scored.sort((a, b) => b.score - a.score);
|
| 356 |
+
return scored.map((s) => s.res);
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
private async extractEpisodeLink(
|
| 360 |
+
seriesUrl: string,
|
| 361 |
+
season: number,
|
| 362 |
+
episode: number
|
| 363 |
+
): Promise<string | null> {
|
| 364 |
+
const html = await this.httpGet(seriesUrl);
|
| 365 |
+
const target = `s${String(season).padStart(2, "0")}-e${String(
|
| 366 |
+
episode
|
| 367 |
+
).padStart(2, "0")}`;
|
| 368 |
+
const re = /class="eps-item[^"]*"[^>]*href="([^"]+)"|href="([^"]+)"[^>]*class="eps-item/g;
|
| 369 |
+
let m: RegExpExecArray | null;
|
| 370 |
+
while ((m = re.exec(html))) {
|
| 371 |
+
const link = m[1] || m[2];
|
| 372 |
+
if (link && link.includes(target)) {
|
| 373 |
+
return link.startsWith("http") ? link : `${MAIN_URL}${link}`;
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
return null;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
private async getServers(
|
| 380 |
+
watchUrl: string,
|
| 381 |
+
isTv: boolean
|
| 382 |
+
): Promise<any[]> {
|
| 383 |
+
const html = await this.httpGet(watchUrl);
|
| 384 |
+
|
| 385 |
+
let token: string | null = null;
|
| 386 |
+
const sel = isTv ? "series-player" : "main-wrapper";
|
| 387 |
+
const selMatch = html.match(
|
| 388 |
+
new RegExp(`id="${sel}"[^>]*data-token="([^"]+)"`)
|
| 389 |
+
);
|
| 390 |
+
if (selMatch) token = selMatch[1];
|
| 391 |
+
if (!token) {
|
| 392 |
+
const generic = html.match(/data-token=["']([^"']+)["']/);
|
| 393 |
+
if (generic) token = generic[1];
|
| 394 |
+
}
|
| 395 |
+
if (!token) throw new Error("No data-token found.");
|
| 396 |
+
|
| 397 |
+
const body = new URLSearchParams();
|
| 398 |
+
body.set(isTv ? "players_show" : "players", token);
|
| 399 |
+
|
| 400 |
+
const r = await fetch(`${MAIN_URL}/ajax/ajax.php`, {
|
| 401 |
+
method: "POST",
|
| 402 |
+
headers: {
|
| 403 |
+
"User-Agent": UA,
|
| 404 |
+
"X-Requested-With": "XMLHttpRequest",
|
| 405 |
+
Referer: watchUrl,
|
| 406 |
+
"Content-Type": "application/x-www-form-urlencoded",
|
| 407 |
+
},
|
| 408 |
+
body: body.toString(),
|
| 409 |
+
});
|
| 410 |
+
const data = await r.json();
|
| 411 |
+
return Array.isArray(data) ? data : [data];
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
private async extractRabbitstream(
|
| 415 |
+
embedUrl: string
|
| 416 |
+
): Promise<FlixHqResult | null> {
|
| 417 |
+
const parsed = new URL(embedUrl);
|
| 418 |
+
const baseApi = `${parsed.protocol}//${parsed.host}`;
|
| 419 |
+
const idMatch = embedUrl.match(/\/e\/([a-zA-Z0-9_-]+)/);
|
| 420 |
+
if (!idMatch) throw new Error("No video id in embed url");
|
| 421 |
+
const videoId = idMatch[1];
|
| 422 |
+
|
| 423 |
+
// 1. Challenge
|
| 424 |
+
const r1 = await fetch(`${baseApi}/api/videos/access/challenge`, {
|
| 425 |
+
method: "POST",
|
| 426 |
+
headers: {
|
| 427 |
+
"User-Agent": UA,
|
| 428 |
+
Referer: embedUrl,
|
| 429 |
+
"X-Requested-With": "XMLHttpRequest",
|
| 430 |
+
"Content-Type": "application/json",
|
| 431 |
+
},
|
| 432 |
+
});
|
| 433 |
+
const j1 = await r1.json();
|
| 434 |
+
const nonce: string = j1.nonce || "";
|
| 435 |
+
const challengeId: string = j1.challenge_id || "";
|
| 436 |
+
|
| 437 |
+
// 2. Attest — ECDSA P-256 signature over the nonce + JWK public key.
|
| 438 |
+
const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", {
|
| 439 |
+
namedCurve: "P-256",
|
| 440 |
+
});
|
| 441 |
+
const sigB64 = encodeB64Url(
|
| 442 |
+
crypto.sign("sha256", Buffer.from(nonce), privateKey)
|
| 443 |
+
);
|
| 444 |
+
const pubJwk = publicKey.export({ format: "jwk" }) as {
|
| 445 |
+
x: string;
|
| 446 |
+
y: string;
|
| 447 |
+
};
|
| 448 |
+
const jwk = {
|
| 449 |
+
crv: "P-256",
|
| 450 |
+
ext: true,
|
| 451 |
+
key_ops: ["verify"],
|
| 452 |
+
kty: "EC",
|
| 453 |
+
x: pubJwk.x,
|
| 454 |
+
y: pubJwk.y,
|
| 455 |
+
};
|
| 456 |
+
|
| 457 |
+
const ua = UA;
|
| 458 |
+
const clientPayload = {
|
| 459 |
+
user_agent: ua,
|
| 460 |
+
architecture: "x86",
|
| 461 |
+
bitness: "64",
|
| 462 |
+
platform: "Windows",
|
| 463 |
+
platform_version: "10.0.0",
|
| 464 |
+
model: "",
|
| 465 |
+
ua_full_version: "124.0.0.0",
|
| 466 |
+
brand_full_versions: [{ brand: "Chromium", version: "124.0.0.0" }],
|
| 467 |
+
pixel_ratio: 1,
|
| 468 |
+
screen_width: 1920,
|
| 469 |
+
screen_height: 1080,
|
| 470 |
+
color_depth: 24,
|
| 471 |
+
languages: ["en-US"],
|
| 472 |
+
timezone: "UTC",
|
| 473 |
+
hardware_concurrency: 8,
|
| 474 |
+
device_memory: 8,
|
| 475 |
+
touch_points: 0,
|
| 476 |
+
webgl_vendor: "Google Inc. (Google)",
|
| 477 |
+
webgl_renderer:
|
| 478 |
+
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
|
| 479 |
+
canvas_hash: "_xjcrc8La-Vnxpr6a6vNFOOdnRcHHQ0tzgT_V3atRqo",
|
| 480 |
+
audio_hash: "RyBmlOc4cA7XhqmvkyO40eo8sOa5q-CFlrTnf70qADY",
|
| 481 |
+
pointer_type: "fine,hover",
|
| 482 |
+
extra: { vendor: "Google Inc." },
|
| 483 |
+
};
|
| 484 |
+
|
| 485 |
+
const vId = crypto.randomUUID().replace(/-/g, "");
|
| 486 |
+
const dId = crypto.randomUUID().replace(/-/g, "");
|
| 487 |
+
|
| 488 |
+
const r2 = await fetch(`${baseApi}/api/videos/access/attest`, {
|
| 489 |
+
method: "POST",
|
| 490 |
+
headers: {
|
| 491 |
+
"User-Agent": ua,
|
| 492 |
+
Referer: embedUrl,
|
| 493 |
+
"X-Requested-With": "XMLHttpRequest",
|
| 494 |
+
"Content-Type": "application/json",
|
| 495 |
+
},
|
| 496 |
+
body: JSON.stringify({
|
| 497 |
+
viewer_id: vId,
|
| 498 |
+
device_id: dId,
|
| 499 |
+
challenge_id: challengeId,
|
| 500 |
+
nonce,
|
| 501 |
+
signature: sigB64,
|
| 502 |
+
public_key: jwk,
|
| 503 |
+
client: clientPayload,
|
| 504 |
+
storage: {},
|
| 505 |
+
attributes: { entropy: "high" },
|
| 506 |
+
}),
|
| 507 |
+
});
|
| 508 |
+
const att = await r2.json();
|
| 509 |
+
const token: string = att.token || "";
|
| 510 |
+
const conf: number = att.confidence ?? 0;
|
| 511 |
+
const avId: string = att.viewer_id || "";
|
| 512 |
+
const adId: string = att.device_id || "";
|
| 513 |
+
|
| 514 |
+
// 3. Captcha — solve the proof-of-work challenge if present.
|
| 515 |
+
const sessionId = crypto.randomUUID().replace(/-/g, "");
|
| 516 |
+
const capHeaders = {
|
| 517 |
+
"User-Agent": ua,
|
| 518 |
+
Origin: baseApi,
|
| 519 |
+
Referer: embedUrl,
|
| 520 |
+
"X-Requested-With": "XMLHttpRequest",
|
| 521 |
+
"X-Playback-Session-Id": sessionId,
|
| 522 |
+
"Content-Type": "application/json",
|
| 523 |
+
};
|
| 524 |
+
const rCap = await fetch(
|
| 525 |
+
`${baseApi}/api/videos/${videoId}/embed/captcha`,
|
| 526 |
+
{
|
| 527 |
+
method: "POST",
|
| 528 |
+
headers: capHeaders,
|
| 529 |
+
body: JSON.stringify({
|
| 530 |
+
fingerprint: {
|
| 531 |
+
token,
|
| 532 |
+
viewer_id: vId,
|
| 533 |
+
device_id: dId,
|
| 534 |
+
confidence: conf,
|
| 535 |
+
},
|
| 536 |
+
}),
|
| 537 |
+
}
|
| 538 |
+
);
|
| 539 |
+
const cap = await rCap.json();
|
| 540 |
+
|
| 541 |
+
let newToken = token;
|
| 542 |
+
if (cap.pow_nonce && cap.pow_token) {
|
| 543 |
+
const difficulty = cap.pow_difficulty ?? 2;
|
| 544 |
+
let solution = 0;
|
| 545 |
+
while (wr(gr(Buffer.from(`${cap.pow_nonce}:${solution}`))) < difficulty) {
|
| 546 |
+
solution++;
|
| 547 |
+
}
|
| 548 |
+
const rVer = await fetch(
|
| 549 |
+
`${baseApi}/api/videos/${videoId}/embed/captcha/verify`,
|
| 550 |
+
{
|
| 551 |
+
method: "POST",
|
| 552 |
+
headers: capHeaders,
|
| 553 |
+
body: JSON.stringify({
|
| 554 |
+
pow_token: cap.pow_token,
|
| 555 |
+
solution: String(solution),
|
| 556 |
+
fingerprint: {
|
| 557 |
+
token,
|
| 558 |
+
viewer_id: avId,
|
| 559 |
+
device_id: adId,
|
| 560 |
+
confidence: conf,
|
| 561 |
+
},
|
| 562 |
+
}),
|
| 563 |
+
}
|
| 564 |
+
);
|
| 565 |
+
const ver = await rVer.json();
|
| 566 |
+
newToken = ver.token || token;
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
// 4. Playback
|
| 570 |
+
const pbHeaders: Record<string, string> = {
|
| 571 |
+
"User-Agent": ua,
|
| 572 |
+
Origin: baseApi,
|
| 573 |
+
Referer: embedUrl,
|
| 574 |
+
"X-Requested-With": "XMLHttpRequest",
|
| 575 |
+
"X-Playback-Session-Id": sessionId,
|
| 576 |
+
"Content-Type": "application/json",
|
| 577 |
+
};
|
| 578 |
+
if (newToken !== token) pbHeaders["X-Captcha-Token"] = newToken;
|
| 579 |
+
|
| 580 |
+
const r3 = await fetch(
|
| 581 |
+
`${baseApi}/api/videos/${videoId}/embed/playback`,
|
| 582 |
+
{
|
| 583 |
+
method: "POST",
|
| 584 |
+
headers: pbHeaders,
|
| 585 |
+
body: JSON.stringify({
|
| 586 |
+
fingerprint: {
|
| 587 |
+
token,
|
| 588 |
+
viewer_id: avId,
|
| 589 |
+
device_id: adId,
|
| 590 |
+
confidence: conf,
|
| 591 |
+
},
|
| 592 |
+
}),
|
| 593 |
+
}
|
| 594 |
+
);
|
| 595 |
+
const j3 = await r3.json();
|
| 596 |
+
const playback = j3.playback;
|
| 597 |
+
if (!playback) throw new Error("No playback in response");
|
| 598 |
+
|
| 599 |
+
// 5. Decrypt the AES-GCM payload.
|
| 600 |
+
const kParts: string[] = playback.key_parts || [];
|
| 601 |
+
const vStr = String(playback.version ?? "").trim();
|
| 602 |
+
let keyBytes: Buffer;
|
| 603 |
+
if (/^\d+$/.test(vStr) && kParts.length) {
|
| 604 |
+
const v = parseInt(vStr, 10);
|
| 605 |
+
if (v >= 1 && v <= kParts.length && 31 - v >= 1 && 31 - v <= kParts.length) {
|
| 606 |
+
keyBytes = Buffer.concat([
|
| 607 |
+
decodeB64Url(kParts[v - 1]),
|
| 608 |
+
decodeB64Url(kParts[31 - v - 1]),
|
| 609 |
+
]);
|
| 610 |
+
} else {
|
| 611 |
+
keyBytes = Buffer.concat(kParts.map(decodeB64Url));
|
| 612 |
+
}
|
| 613 |
+
} else {
|
| 614 |
+
keyBytes = Buffer.concat(kParts.map(decodeB64Url));
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
const iv = decodeB64Url(playback.iv || "");
|
| 618 |
+
const payload = decodeB64Url(playback.payload || "");
|
| 619 |
+
const tag = payload.subarray(payload.length - 16);
|
| 620 |
+
const ciphertext = payload.subarray(0, payload.length - 16);
|
| 621 |
+
|
| 622 |
+
const decipher = crypto.createDecipheriv("aes-256-gcm", keyBytes, iv);
|
| 623 |
+
decipher.setAuthTag(tag);
|
| 624 |
+
const decrypted = Buffer.concat([
|
| 625 |
+
decipher.update(ciphertext),
|
| 626 |
+
decipher.final(),
|
| 627 |
+
]);
|
| 628 |
+
const data = JSON.parse(decrypted.toString("utf-8"));
|
| 629 |
+
|
| 630 |
+
const masterUrl: string | undefined = data?.sources?.[0]?.url;
|
| 631 |
+
if (!masterUrl) throw new Error("No stream url in decrypted payload");
|
| 632 |
+
|
| 633 |
+
const type = masterUrl.includes(".m3u8")
|
| 634 |
+
? "hls"
|
| 635 |
+
: masterUrl.includes(".mpd")
|
| 636 |
+
? "dash"
|
| 637 |
+
: "mp4";
|
| 638 |
+
|
| 639 |
+
const subtitles: FlixHqSub[] = (data.tracks || [])
|
| 640 |
+
.filter((t: any) => t.kind === "captions" && t.file)
|
| 641 |
+
.map((t: any) => ({
|
| 642 |
+
url: t.file,
|
| 643 |
+
label: t.label || "",
|
| 644 |
+
language: guessLang(t.label || ""),
|
| 645 |
+
}));
|
| 646 |
+
|
| 647 |
+
return {
|
| 648 |
+
sources: [{ file: masterUrl, label: "auto", type }],
|
| 649 |
+
subtitles,
|
| 650 |
+
provider: "FlixHQ",
|
| 651 |
+
};
|
| 652 |
+
}
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
// Minimal HTML entity decode for titles pulled from attributes.
|
| 656 |
+
function decodeEntities(s: string): string {
|
| 657 |
+
return s
|
| 658 |
+
.replace(/&/g, "&")
|
| 659 |
+
.replace(/'/g, "'")
|
| 660 |
+
.replace(/'/g, "'")
|
| 661 |
+
.replace(/"/g, '"')
|
| 662 |
+
.replace(/</g, "<")
|
| 663 |
+
.replace(/>/g, ">");
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
import type { SourceModule } from "./types";
|
| 667 |
+
|
| 668 |
+
const _flixhq = new FlixHqScraper();
|
| 669 |
+
|
| 670 |
+
// megacloud/rabbitstream streams + captions play directly (no proxy needed).
|
| 671 |
+
export const flixhq: SourceModule = {
|
| 672 |
+
id: "flixhq",
|
| 673 |
+
name: "Efe",
|
| 674 |
+
label: "HLS · built-in subs",
|
| 675 |
+
active: true,
|
| 676 |
+
rank: 6,
|
| 677 |
+
async fetch(ctx) {
|
| 678 |
+
const r = await _flixhq
|
| 679 |
+
.fetchSources(ctx.tmdbId, ctx.season, ctx.episode)
|
| 680 |
+
.catch(() => null);
|
| 681 |
+
if (!r || !r.sources.length) return null;
|
| 682 |
+
return {
|
| 683 |
+
streams: r.sources as any,
|
| 684 |
+
subtitles: r.subtitles.map((s) => ({
|
| 685 |
+
url: s.url,
|
| 686 |
+
display: s.label,
|
| 687 |
+
language: s.language,
|
| 688 |
+
source: "Efe",
|
| 689 |
+
})),
|
| 690 |
+
};
|
| 691 |
+
},
|
| 692 |
+
};
|
lib/scraper/index.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ─────────────────────────────────────────────────────────────────────────
|
| 2 |
+
// Source registry
|
| 3 |
+
//
|
| 4 |
+
// To add a source: create a file here that exports a `SourceModule` (id, name,
|
| 5 |
+
// label, active, rank, fetch), then add one import + one array entry below.
|
| 6 |
+
// Everything else (the /api/sources route, the player's server list, failover)
|
| 7 |
+
// is driven off this list.
|
| 8 |
+
//
|
| 9 |
+
// Display names are overridable per-source via env, e.g.
|
| 10 |
+
// SOURCE_ONEROOM_NAME="Server 1" SOURCE_FLIXHQ_NAME="Server 2"
|
| 11 |
+
// (uppercase the source id). Handy for rebranding without touching code.
|
| 12 |
+
// ─────────────────────────────────────────────────────────────────────────
|
| 13 |
+
|
| 14 |
+
import type { SourceModule, SourceMeta } from "./types";
|
| 15 |
+
import { oneroom } from "./oneroom";
|
| 16 |
+
import { insertunit } from "./insertunit";
|
| 17 |
+
import { flixhq } from "./flixhq";
|
| 18 |
+
import { castle } from "./castle";
|
| 19 |
+
import { cinesu } from "./cinesu";
|
| 20 |
+
import { vidnest } from "./vidnest";
|
| 21 |
+
import { vidrock } from "./vidrock";
|
| 22 |
+
import { vixsrc } from "./vixsrc";
|
| 23 |
+
|
| 24 |
+
const REGISTERED: SourceModule[] = [
|
| 25 |
+
oneroom,
|
| 26 |
+
insertunit,
|
| 27 |
+
flixhq,
|
| 28 |
+
castle,
|
| 29 |
+
cinesu,
|
| 30 |
+
vidnest,
|
| 31 |
+
vidrock,
|
| 32 |
+
vixsrc,
|
| 33 |
+
];
|
| 34 |
+
|
| 35 |
+
/** Env display-name override for a source id (SOURCE_<ID>_NAME). */
|
| 36 |
+
function nameFor(id: string, fallback: string): string {
|
| 37 |
+
return process.env[`SOURCE_${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_NAME`] || fallback;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const byRank = (a: SourceModule, b: SourceModule) =>
|
| 41 |
+
(a.rank ?? 100) - (b.rank ?? 100);
|
| 42 |
+
|
| 43 |
+
/** All active sources, ordered by rank (used for failover + default order). */
|
| 44 |
+
export function activeSources(): SourceModule[] {
|
| 45 |
+
return REGISTERED.filter((s) => s.active).sort(byRank);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Resolve a source by id. Accepts a plain id ("oneroom"), a parent id, or a
|
| 50 |
+
* "parent:child" id. The returned module's name honours any env override.
|
| 51 |
+
*/
|
| 52 |
+
export function getSource(id: string): SourceModule | undefined {
|
| 53 |
+
const wanted = id || activeSources()[0]?.id;
|
| 54 |
+
if (!wanted) return undefined;
|
| 55 |
+
const [parentId, childId] = wanted.split(":");
|
| 56 |
+
const parent = REGISTERED.find((s) => s.id === parentId && s.active);
|
| 57 |
+
if (!parent) return undefined;
|
| 58 |
+
if (!childId) {
|
| 59 |
+
return { ...parent, name: nameFor(parent.id, parent.name) };
|
| 60 |
+
}
|
| 61 |
+
const child = parent.children?.find((c) => c.id === childId);
|
| 62 |
+
if (!child) return undefined;
|
| 63 |
+
return {
|
| 64 |
+
id: wanted,
|
| 65 |
+
name: `${nameFor(parent.id, parent.name)} · ${child.name}`,
|
| 66 |
+
label: parent.label,
|
| 67 |
+
active: true,
|
| 68 |
+
fetch: child.fetch,
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/** Client-safe metadata for building the player's server list. */
|
| 73 |
+
export function sourceList(): SourceMeta[] {
|
| 74 |
+
return activeSources().map((s) => ({
|
| 75 |
+
id: s.id,
|
| 76 |
+
name: nameFor(s.id, s.name),
|
| 77 |
+
label: s.label,
|
| 78 |
+
children: s.children?.map((c) => ({ id: c.id, name: c.name })),
|
| 79 |
+
}));
|
| 80 |
+
}
|
lib/scraper/insertunit.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Server-only Insertunit source resolver (api.insertunit.ws). Resolves a TMDB
|
| 2 |
+
// id to an IMDB id, scrapes the embed HTML, and extracts the dash/hls streams,
|
| 3 |
+
// audio-track names and cc (subtitle) list. Streams live on interkh.com and
|
| 4 |
+
// require the insertunit Referer, so they must be proxied.
|
| 5 |
+
|
| 6 |
+
export interface InsertunitResult {
|
| 7 |
+
dash: string | null;
|
| 8 |
+
dasha: string | null;
|
| 9 |
+
hls: string | null;
|
| 10 |
+
audio: { names: string[]; order: number[] } | null;
|
| 11 |
+
cc: { url: string; name: string }[] | null;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const HEADERS: Record<string, string> = {
|
| 15 |
+
accept:
|
| 16 |
+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
| 17 |
+
"accept-language": "en-GB,en;q=0.9",
|
| 18 |
+
"cache-control": "max-age=0",
|
| 19 |
+
"sec-ch-ua":
|
| 20 |
+
'"Chromium";v="148", "Brave";v="148", "Not/A)Brand";v="99"',
|
| 21 |
+
"sec-ch-ua-mobile": "?0",
|
| 22 |
+
"sec-ch-ua-platform": '"macOS"',
|
| 23 |
+
"sec-fetch-dest": "document",
|
| 24 |
+
"sec-fetch-mode": "navigate",
|
| 25 |
+
"sec-fetch-site": "none",
|
| 26 |
+
"sec-fetch-user": "?1",
|
| 27 |
+
"upgrade-insecure-requests": "1",
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
export const INSERTUNIT_REFERER = "https://api.insertunit.ws/";
|
| 31 |
+
|
| 32 |
+
function safeJsonLoad<T = any>(s: string): T | null {
|
| 33 |
+
try {
|
| 34 |
+
return JSON.parse(s) as T;
|
| 35 |
+
} catch {
|
| 36 |
+
// strip trailing commas from sloppy JS objects, retry
|
| 37 |
+
const clean = s.replace(/,\s*}/g, "}").replace(/,\s*\]/g, "]");
|
| 38 |
+
try {
|
| 39 |
+
return JSON.parse(clean) as T;
|
| 40 |
+
} catch {
|
| 41 |
+
return null;
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export class InsertunitScraper {
|
| 47 |
+
private tmdbKey =
|
| 48 |
+
process.env.TMDB_API_KEY || "";
|
| 49 |
+
|
| 50 |
+
private async getImdbId(tmdbId: number, isTv: boolean): Promise<string | null> {
|
| 51 |
+
const mt = isTv ? "tv" : "movie";
|
| 52 |
+
try {
|
| 53 |
+
const r = await fetch(
|
| 54 |
+
`https://api.themoviedb.org/3/${mt}/${tmdbId}/external_ids?api_key=${this.tmdbKey}`
|
| 55 |
+
);
|
| 56 |
+
if (!r.ok) return null;
|
| 57 |
+
const d = await r.json();
|
| 58 |
+
return d.imdb_id || null;
|
| 59 |
+
} catch {
|
| 60 |
+
return null;
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// Finds the dynamic token suffix appended to media URLs to bypass 403s.
|
| 65 |
+
private extractDynamicToken(html: string): string {
|
| 66 |
+
const varMatch = html.match(
|
| 67 |
+
/o\[k\]\s*\+=\s*'&'\s*\+\s*([a-zA-Z0-9_]+);/
|
| 68 |
+
);
|
| 69 |
+
if (!varMatch) return "";
|
| 70 |
+
const name = varMatch[1];
|
| 71 |
+
const valMatch = html.match(
|
| 72 |
+
new RegExp(`${name}\\s*=\\s*"([^"]+)"`)
|
| 73 |
+
);
|
| 74 |
+
if (!valMatch) return "";
|
| 75 |
+
return `&${valMatch[1]}`;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
async fetchSources(
|
| 79 |
+
tmdbId: number,
|
| 80 |
+
season = 0,
|
| 81 |
+
episode = 0
|
| 82 |
+
): Promise<InsertunitResult | null> {
|
| 83 |
+
const isTv = !!(season && episode);
|
| 84 |
+
const imdb = await this.getImdbId(tmdbId, isTv);
|
| 85 |
+
if (!imdb) return null;
|
| 86 |
+
|
| 87 |
+
let html: string;
|
| 88 |
+
try {
|
| 89 |
+
const r = await fetch(`https://api.insertunit.ws/embed/imdb/${imdb}`, {
|
| 90 |
+
headers: HEADERS,
|
| 91 |
+
cache: "no-store", // fresh tokens each resolve
|
| 92 |
+
});
|
| 93 |
+
if (!r.ok) return null;
|
| 94 |
+
html = await r.text();
|
| 95 |
+
} catch {
|
| 96 |
+
return null;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
return isTv
|
| 100 |
+
? this.parseTv(html, season, episode)
|
| 101 |
+
: this.parseMovie(html);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
private parseMovie(html: string): InsertunitResult | null {
|
| 105 |
+
const m = html.match(/source:\s*({[\s\S]*?})/);
|
| 106 |
+
if (!m) return null;
|
| 107 |
+
const src = m[1];
|
| 108 |
+
const tok = this.extractDynamicToken(html);
|
| 109 |
+
|
| 110 |
+
const dash = src.match(/dash:\s*"(.*?)"/);
|
| 111 |
+
const dasha = src.match(/dasha:\s*"(.*?)"/);
|
| 112 |
+
const hls = src.match(/hls:\s*"(.*?)"/);
|
| 113 |
+
const audio = src.match(/audio:\s*({[\s\S]*?})/);
|
| 114 |
+
const cc = src.match(/cc:\s*(\[[\s\S]*?\])/);
|
| 115 |
+
|
| 116 |
+
return {
|
| 117 |
+
dash: dash ? dash[1] + tok : null,
|
| 118 |
+
dasha: dasha ? dasha[1] + tok : null,
|
| 119 |
+
hls: hls ? hls[1] + tok : null,
|
| 120 |
+
audio: audio ? safeJsonLoad(audio[1]) : null,
|
| 121 |
+
cc: cc ? safeJsonLoad(cc[1]) : null,
|
| 122 |
+
};
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
private parseTv(
|
| 126 |
+
html: string,
|
| 127 |
+
season: number,
|
| 128 |
+
episode: number
|
| 129 |
+
): InsertunitResult | null {
|
| 130 |
+
// Isolate the season block to avoid cross-season episode mismatches.
|
| 131 |
+
const seasonRe = new RegExp(
|
| 132 |
+
`"season":\\s*${season}\\s*,\\s*"blocked"[\\s\\S]*?(?="season":\\s*\\d+\\s*,|$)`
|
| 133 |
+
);
|
| 134 |
+
const sm = html.match(seasonRe);
|
| 135 |
+
if (!sm) return null;
|
| 136 |
+
const seasonHtml = sm[0];
|
| 137 |
+
|
| 138 |
+
const epRe = new RegExp(
|
| 139 |
+
`"episode":\\s*"${episode}"[\\s\\S]*?(?="episode":\\s*"\\d+"|$)`
|
| 140 |
+
);
|
| 141 |
+
const em = seasonHtml.match(epRe);
|
| 142 |
+
if (!em) return null;
|
| 143 |
+
const eh = em[0];
|
| 144 |
+
const tok = this.extractDynamicToken(html);
|
| 145 |
+
|
| 146 |
+
const dash = eh.match(/"dash":\s*"(.*?)"/);
|
| 147 |
+
const dasha = eh.match(/"dasha":\s*"(.*?)"/);
|
| 148 |
+
const hls = eh.match(/"hls":\s*"(.*?)"/);
|
| 149 |
+
const audio = eh.match(/"audio":\s*({[\s\S]*?})/);
|
| 150 |
+
const cc = eh.match(/"cc":\s*(\[[\s\S]*?\])/);
|
| 151 |
+
|
| 152 |
+
return {
|
| 153 |
+
dash: dash ? dash[1] + tok : null,
|
| 154 |
+
dasha: dasha ? dasha[1] + tok : null,
|
| 155 |
+
hls: hls ? hls[1] + tok : null,
|
| 156 |
+
audio: audio ? safeJsonLoad(audio[1]) : null,
|
| 157 |
+
cc: cc ? safeJsonLoad(cc[1]) : null,
|
| 158 |
+
};
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
import type { SourceModule } from "./types";
|
| 163 |
+
|
| 164 |
+
// Best-effort language code from an Insertunit cc track name.
|
| 165 |
+
function guessLang(name: string): string {
|
| 166 |
+
const n = (name || "").toLowerCase();
|
| 167 |
+
if (n.includes("eng") || n.includes("english")) return "en";
|
| 168 |
+
if (n.includes("рус") || n.includes("rus")) return "ru";
|
| 169 |
+
if (n.includes("укр") || n.includes("ukr")) return "uk";
|
| 170 |
+
if (n.includes("span") || n.includes("esp")) return "es";
|
| 171 |
+
if (n.includes("fr")) return "fr";
|
| 172 |
+
if (n.includes("de") || n.includes("ger")) return "de";
|
| 173 |
+
return "";
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const _insertunit = new InsertunitScraper();
|
| 177 |
+
|
| 178 |
+
// interkh streams need the insertunit Referer, so both streams and cc subs are
|
| 179 |
+
// proxied. Prefer DASH (its <BaseURL>[0] is a real CDN host; the HLS variant
|
| 180 |
+
// uses an "x-bc" placeholder host that 410s).
|
| 181 |
+
export const insertunit: SourceModule = {
|
| 182 |
+
id: "insertunit",
|
| 183 |
+
name: "Ari",
|
| 184 |
+
label: "Multi-audio · built-in subs",
|
| 185 |
+
active: true,
|
| 186 |
+
rank: 2,
|
| 187 |
+
async fetch(ctx) {
|
| 188 |
+
const r = await _insertunit
|
| 189 |
+
.fetchSources(ctx.tmdbId, ctx.season, ctx.episode)
|
| 190 |
+
.catch(() => null);
|
| 191 |
+
if (!r) return null;
|
| 192 |
+
const ref = INSERTUNIT_REFERER;
|
| 193 |
+
const streams = [];
|
| 194 |
+
if (r.dash) {
|
| 195 |
+
streams.push({
|
| 196 |
+
file: ctx.proxyStream(r.dash, ref),
|
| 197 |
+
label: "auto",
|
| 198 |
+
type: "dash" as const,
|
| 199 |
+
});
|
| 200 |
+
} else if (r.hls) {
|
| 201 |
+
streams.push({
|
| 202 |
+
file: ctx.proxyStream(r.hls, ref),
|
| 203 |
+
label: "auto",
|
| 204 |
+
type: "hls" as const,
|
| 205 |
+
});
|
| 206 |
+
}
|
| 207 |
+
if (!streams.length) return null;
|
| 208 |
+
const subtitles = (r.cc || []).map((c) => ({
|
| 209 |
+
url: ctx.proxySub(c.url, ref),
|
| 210 |
+
display: c.name,
|
| 211 |
+
language: guessLang(c.name),
|
| 212 |
+
source: "Ari",
|
| 213 |
+
}));
|
| 214 |
+
return { streams, subtitles };
|
| 215 |
+
},
|
| 216 |
+
};
|
lib/scraper/oneroom.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { SourceModule } from "./types";
|
| 2 |
+
|
| 3 |
+
// Server-only source resolver (MovieBox / aoneroom backend). The real stream
|
| 4 |
+
// URLs are hotlink-locked to a themoviebox.org referer, so they're routed
|
| 5 |
+
// through this app's own /api/stream proxy (ctx.proxyStream) — everything flows
|
| 6 |
+
// through your VPS, no third-party proxy or worker.
|
| 7 |
+
const MAIN_URL = "https://themoviebox.org";
|
| 8 |
+
const API_BASE = "https://h5-api.aoneroom.com";
|
| 9 |
+
const REFERER = "https://themoviebox.org/";
|
| 10 |
+
const TMDB_KEY = process.env.TMDB_API_KEY || "";
|
| 11 |
+
|
| 12 |
+
let bearerToken: string | null = null;
|
| 13 |
+
|
| 14 |
+
function clientTimeToken(): string {
|
| 15 |
+
const crypto = require("crypto") as typeof import("crypto");
|
| 16 |
+
const ts = Math.floor(Date.now() / 1000);
|
| 17 |
+
const rev = ts.toString().split("").reverse().join("");
|
| 18 |
+
const md5 = crypto.createHash("md5").update(rev).digest("hex");
|
| 19 |
+
return `${ts},${md5}`;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function baseHeaders() {
|
| 23 |
+
return {
|
| 24 |
+
Accept: "application/json",
|
| 25 |
+
"User-Agent":
|
| 26 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36",
|
| 27 |
+
"X-Client-Info": JSON.stringify({ timezone: "Asia/Jakarta" }),
|
| 28 |
+
"Content-Type": "application/json",
|
| 29 |
+
};
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
async function getBearerToken(): Promise<string> {
|
| 33 |
+
if (bearerToken) return bearerToken;
|
| 34 |
+
try {
|
| 35 |
+
const resp = await fetch(
|
| 36 |
+
`${API_BASE}/wefeed-h5api-bff/home?host=themoviebox.org`,
|
| 37 |
+
{
|
| 38 |
+
headers: {
|
| 39 |
+
...baseHeaders(),
|
| 40 |
+
Authorization: "",
|
| 41 |
+
"X-Request-Lang": "en",
|
| 42 |
+
"X-Client-Token": clientTimeToken(),
|
| 43 |
+
Referer: `${MAIN_URL}/`,
|
| 44 |
+
},
|
| 45 |
+
}
|
| 46 |
+
);
|
| 47 |
+
const xUser = resp.headers.get("x-user");
|
| 48 |
+
if (xUser) {
|
| 49 |
+
const userData = JSON.parse(xUser);
|
| 50 |
+
if (userData.token) {
|
| 51 |
+
bearerToken = userData.token;
|
| 52 |
+
return bearerToken!;
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
} catch {
|
| 56 |
+
/* ignore */
|
| 57 |
+
}
|
| 58 |
+
return "";
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
async function fetchSources(tmdbId: number, season = 0, episode = 0) {
|
| 62 |
+
const isTv = season > 0;
|
| 63 |
+
const mediaType = isTv ? "tv" : "movie";
|
| 64 |
+
const tmdbResp = await fetch(
|
| 65 |
+
`https://api.themoviedb.org/3/${mediaType}/${tmdbId}?api_key=${TMDB_KEY}`
|
| 66 |
+
);
|
| 67 |
+
const tmdbData = await tmdbResp.json();
|
| 68 |
+
const title = tmdbData.title || tmdbData.name;
|
| 69 |
+
if (!title) return null;
|
| 70 |
+
|
| 71 |
+
const token = await getBearerToken();
|
| 72 |
+
const searchResp = await fetch(
|
| 73 |
+
`${API_BASE}/wefeed-h5api-bff/subject/search`,
|
| 74 |
+
{
|
| 75 |
+
method: "POST",
|
| 76 |
+
headers: {
|
| 77 |
+
...baseHeaders(),
|
| 78 |
+
Authorization: token ? `Bearer ${token}` : "",
|
| 79 |
+
"X-Request-Lang": "en",
|
| 80 |
+
"X-Client-Token": clientTimeToken(),
|
| 81 |
+
Referer: `${MAIN_URL}/`,
|
| 82 |
+
},
|
| 83 |
+
body: JSON.stringify({ keyword: title, page: 1, perPage: 28, subjectType: 0 }),
|
| 84 |
+
}
|
| 85 |
+
);
|
| 86 |
+
const searchData = await searchResp.json();
|
| 87 |
+
const items = searchData?.data?.items || [];
|
| 88 |
+
|
| 89 |
+
const targetType = isTv ? [2, 3] : [1];
|
| 90 |
+
const match = items.find(
|
| 91 |
+
(item: any) =>
|
| 92 |
+
targetType.includes(parseInt(item.subjectType)) &&
|
| 93 |
+
item.title.toLowerCase().includes(title.toLowerCase())
|
| 94 |
+
);
|
| 95 |
+
if (!match) return null;
|
| 96 |
+
|
| 97 |
+
const detailPath = match.detailPath;
|
| 98 |
+
const playResp = await fetch(
|
| 99 |
+
`${API_BASE}/wefeed-h5api-bff/subject/play?subjectId=${match.subjectId}&se=${season}&ep=${episode}&detailPath=${detailPath}`,
|
| 100 |
+
{
|
| 101 |
+
headers: {
|
| 102 |
+
...baseHeaders(),
|
| 103 |
+
"X-Request-Lang": "en",
|
| 104 |
+
"X-Client-Token": clientTimeToken(),
|
| 105 |
+
Referer: `${MAIN_URL}/movies/${detailPath}`,
|
| 106 |
+
},
|
| 107 |
+
}
|
| 108 |
+
);
|
| 109 |
+
const playData = await playResp.json();
|
| 110 |
+
if (!playData?.data?.hasResource) return null;
|
| 111 |
+
|
| 112 |
+
const streams = [
|
| 113 |
+
...(playData.data.streams || []),
|
| 114 |
+
...(playData.data.hls || []),
|
| 115 |
+
...(playData.data.dash || []),
|
| 116 |
+
];
|
| 117 |
+
|
| 118 |
+
return streams
|
| 119 |
+
.filter((s: any) => s.url?.startsWith("http"))
|
| 120 |
+
.map((item: any) => {
|
| 121 |
+
const res = item.resolutions || "720";
|
| 122 |
+
const url: string = item.url;
|
| 123 |
+
return {
|
| 124 |
+
url, // raw upstream URL — proxied through /api/stream below
|
| 125 |
+
label: res.toString().replace("p", ""),
|
| 126 |
+
type: url.includes(".m3u8") ? "hls" : url.includes(".mpd") ? "dash" : "mp4",
|
| 127 |
+
};
|
| 128 |
+
})
|
| 129 |
+
.sort((a: any, b: any) => parseInt(b.label) - parseInt(a.label));
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
export const oneroom: SourceModule = {
|
| 133 |
+
id: "oneroom",
|
| 134 |
+
name: "Sunny",
|
| 135 |
+
label: "Multi-quality",
|
| 136 |
+
active: true,
|
| 137 |
+
rank: 1,
|
| 138 |
+
async fetch(ctx) {
|
| 139 |
+
const raw = await fetchSources(ctx.tmdbId, ctx.season, ctx.episode).catch(
|
| 140 |
+
() => null
|
| 141 |
+
);
|
| 142 |
+
if (!raw || !raw.length) return null;
|
| 143 |
+
// Route every stream through the VPS proxy with the required referer.
|
| 144 |
+
const streams = raw.map((s: any) => ({
|
| 145 |
+
file: ctx.proxyStream(s.url, REFERER),
|
| 146 |
+
label: s.label,
|
| 147 |
+
type: s.type,
|
| 148 |
+
}));
|
| 149 |
+
return { streams };
|
| 150 |
+
},
|
| 151 |
+
};
|
lib/scraper/types.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Standard contract every source in this folder implements. Drop a new file
|
| 2 |
+
// that exports a `SourceModule`, register it in ./index.ts, and it shows up in
|
| 3 |
+
// the player automatically — no route or UI changes needed.
|
| 4 |
+
|
| 5 |
+
export interface SourceStream {
|
| 6 |
+
file: string;
|
| 7 |
+
/** Quality label, e.g. "1080", "720", or "auto". */
|
| 8 |
+
label: string;
|
| 9 |
+
type: "hls" | "dash" | "mp4";
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export interface SourceSubtitle {
|
| 13 |
+
url: string;
|
| 14 |
+
display: string;
|
| 15 |
+
language: string;
|
| 16 |
+
hi?: boolean;
|
| 17 |
+
source?: string;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export interface SourceResult {
|
| 21 |
+
streams: SourceStream[];
|
| 22 |
+
/** The source's OWN built-in subtitles. Wyzie is added as a default on top
|
| 23 |
+
* by the route, so most sources can leave this empty. */
|
| 24 |
+
subtitles?: SourceSubtitle[];
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export interface SourceContext {
|
| 28 |
+
tmdbId: number;
|
| 29 |
+
/** 0 for movies. */
|
| 30 |
+
season: number;
|
| 31 |
+
/** 0 for movies. */
|
| 32 |
+
episode: number;
|
| 33 |
+
/** This deployment's origin, e.g. "https://host" — for sources that need to
|
| 34 |
+
* point at their own custom proxy route. */
|
| 35 |
+
origin: string;
|
| 36 |
+
/** Wrap an upstream stream URL so it's fetched via our /api/stream proxy
|
| 37 |
+
* (use when the CDN needs a Referer or hides behind hotlink protection). */
|
| 38 |
+
proxyStream(url: string, referer?: string): string;
|
| 39 |
+
/** Wrap an upstream subtitle URL so it's served WebVTT via our /api/sub proxy. */
|
| 40 |
+
proxySub(url: string, referer?: string): string;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// A sub-provider inside a parent source (e.g. one of VidNest's upstreams).
|
| 44 |
+
export interface SubSource {
|
| 45 |
+
/** Stable id within the parent, e.g. "klikxxi". */
|
| 46 |
+
id: string;
|
| 47 |
+
name: string;
|
| 48 |
+
fetch(ctx: SourceContext): Promise<SourceResult | null>;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export interface SourceModule {
|
| 52 |
+
/** Stable id used in URLs + persistence, e.g. "oneroom". */
|
| 53 |
+
id: string;
|
| 54 |
+
/** Display name shown in the player, e.g. "MovieBox". */
|
| 55 |
+
name: string;
|
| 56 |
+
/** Short line under the name in the server list. */
|
| 57 |
+
label: string;
|
| 58 |
+
/** Flip to false to hide a source without deleting the file. */
|
| 59 |
+
active: boolean;
|
| 60 |
+
/** Lower = listed first and tried first during failover. Defaults to 100. */
|
| 61 |
+
rank?: number;
|
| 62 |
+
/** Resolve playable streams (+ optional built-in subs) for a title. For a
|
| 63 |
+
* parent source this aggregates/tries its children. */
|
| 64 |
+
fetch(ctx: SourceContext): Promise<SourceResult | null>;
|
| 65 |
+
/** Present on "parent" sources — selectable sub-providers. The parent's own
|
| 66 |
+
* fetch tries them all; children can also be picked individually. */
|
| 67 |
+
children?: SubSource[];
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// Lightweight metadata sent to the client to build the server list.
|
| 71 |
+
export interface SourceMeta {
|
| 72 |
+
id: string;
|
| 73 |
+
name: string;
|
| 74 |
+
label: string;
|
| 75 |
+
children?: { id: string; name: string }[];
|
| 76 |
+
}
|
lib/scraper/vidnest.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { SourceModule, SubSource, SourceContext, SourceStream, SourceSubtitle } from "./types";
|
| 2 |
+
|
| 3 |
+
// Server-only VidNest resolver (vidnest.fun). VidNest is a *parent* source: it
|
| 4 |
+
// proxies ~11 upstream providers, each with its own JSON schema. Each provider
|
| 5 |
+
// is exposed as a sub-source; the parent tries them all (concurrently) and
|
| 6 |
+
// aggregates whatever resolves.
|
| 7 |
+
|
| 8 |
+
const BASE_URL = "https://vidnest.fun";
|
| 9 |
+
const API_BASE = "https://new.vidnest.fun";
|
| 10 |
+
|
| 11 |
+
const HEADERS: Record<string, string> = {
|
| 12 |
+
"User-Agent":
|
| 13 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/150 Safari/537.36",
|
| 14 |
+
Accept: "application/json, text/javascript, */*; q=0.01",
|
| 15 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 16 |
+
Referer: `${BASE_URL}/`,
|
| 17 |
+
Origin: BASE_URL,
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
// VidNest scrambles its base64 with a custom alphabet.
|
| 21 |
+
const VIDNEST_ALPHABET =
|
| 22 |
+
"RB0fpH8ZEyVLkv7c2i6MAJ5u3IKFDxlS1NTsnGaqmXYdUrtzjwObCgQP94hoeW+/";
|
| 23 |
+
const STANDARD_ALPHABET =
|
| 24 |
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
| 25 |
+
const TRANSLATE = new Map<string, string>();
|
| 26 |
+
for (let i = 0; i < VIDNEST_ALPHABET.length; i++) {
|
| 27 |
+
TRANSLATE.set(VIDNEST_ALPHABET[i], STANDARD_ALPHABET[i]);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
interface ServerDef {
|
| 31 |
+
path: string;
|
| 32 |
+
name: string;
|
| 33 |
+
query: string;
|
| 34 |
+
audio?: string;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// Upstream providers wrapped by VidNest.
|
| 38 |
+
const SERVERS: ServerDef[] = [
|
| 39 |
+
{ path: "moviebox", name: "MovieBox", query: "" },
|
| 40 |
+
{ path: "allmovies", name: "AllMovies", query: "" },
|
| 41 |
+
{ path: "catflix", name: "CatFlix", query: "" },
|
| 42 |
+
{ path: "purstream", name: "PurStream", query: "", audio: "French" },
|
| 43 |
+
{ path: "hollymoviehd", name: "HollyMovieHD", query: "" },
|
| 44 |
+
{ path: "lamda", name: "Lamda", query: "" },
|
| 45 |
+
{ path: "flixhq", name: "FlixHQ", query: "" },
|
| 46 |
+
{ path: "vidlink", name: "VidLink", query: "" },
|
| 47 |
+
{ path: "onehd", name: "OneHD", query: "?server=upcloud" },
|
| 48 |
+
{ path: "klikxxi", name: "KlikXXI", query: "" },
|
| 49 |
+
{ path: "delta", name: "Delta", query: "" },
|
| 50 |
+
];
|
| 51 |
+
|
| 52 |
+
function decodePayload(payload: string): any {
|
| 53 |
+
let p = payload;
|
| 54 |
+
const pad = p.length % 4;
|
| 55 |
+
if (pad) p += "=".repeat(4 - pad);
|
| 56 |
+
let std = "";
|
| 57 |
+
for (const ch of p) std += TRANSLATE.get(ch) ?? ch;
|
| 58 |
+
const json = Buffer.from(std, "base64").toString("utf-8");
|
| 59 |
+
return JSON.parse(json);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// Wrap a raw stream URL in VidNest's omss proxy (carries the required headers).
|
| 63 |
+
function proxyUrl(url: string): string {
|
| 64 |
+
if (!url) return url;
|
| 65 |
+
const data = encodeURIComponent(
|
| 66 |
+
JSON.stringify({ url, headers: HEADERS })
|
| 67 |
+
);
|
| 68 |
+
return `https://omss.fstream.app/v1/proxy?data=${data}`;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function inferType(typeStr: string, url: string): SourceStream["type"] {
|
| 72 |
+
const t = String(typeStr || "").toLowerCase();
|
| 73 |
+
const u = (url || "").toLowerCase();
|
| 74 |
+
if (t === "hls" || u.includes(".m3u8")) return "hls";
|
| 75 |
+
if (t === "dash" || u.includes(".mpd")) return "dash";
|
| 76 |
+
if (u.includes(".mp4")) return "mp4";
|
| 77 |
+
return "hls";
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function inferSubFmt(url: string): string {
|
| 81 |
+
const u = (url || "").toLowerCase();
|
| 82 |
+
if (u.includes(".srt")) return "srt";
|
| 83 |
+
if (u.includes(".ass")) return "ass";
|
| 84 |
+
return "vtt";
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
interface RawSource {
|
| 88 |
+
url: string;
|
| 89 |
+
type: string;
|
| 90 |
+
quality: string;
|
| 91 |
+
audio: string;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// Map a decrypted provider payload to a normalized {sources, subtitles} using
|
| 95 |
+
// that provider's specific schema (ported from the reference scraper).
|
| 96 |
+
function mapServer(path: string, data: any): {
|
| 97 |
+
sources: RawSource[];
|
| 98 |
+
subtitles: { url: string; lang: string }[];
|
| 99 |
+
} {
|
| 100 |
+
const sources: RawSource[] = [];
|
| 101 |
+
const subtitles: { url: string; lang: string }[] = [];
|
| 102 |
+
try {
|
| 103 |
+
if (path === "klikxxi") {
|
| 104 |
+
for (const s of data.sources || [])
|
| 105 |
+
sources.push({ url: s.url, type: inferType(s.type, s.url), quality: s.quality || "Auto", audio: "English" });
|
| 106 |
+
} else if (path === "allmovies" || path === "delta") {
|
| 107 |
+
for (const s of data.streams || [])
|
| 108 |
+
sources.push({ url: s.url, type: inferType(s.type, s.url), quality: "Auto", audio: s.language || "Unknown" });
|
| 109 |
+
} else if (path === "onehd") {
|
| 110 |
+
if (data.url) sources.push({ url: data.url, type: inferType("", data.url), quality: "Auto", audio: "English" });
|
| 111 |
+
for (const sub of data.subtitles || []) subtitles.push({ url: sub.url, lang: sub.lang });
|
| 112 |
+
} else if (path === "hollymoviehd") {
|
| 113 |
+
for (const s of data.sources || [])
|
| 114 |
+
sources.push({ url: s.file, type: inferType(s.type, s.file), quality: s.label || "Auto", audio: "English" });
|
| 115 |
+
} else if (path === "vidlink") {
|
| 116 |
+
const stream = data?.data?.stream || {};
|
| 117 |
+
if (stream.playlist)
|
| 118 |
+
sources.push({ url: stream.playlist, type: inferType(stream.type, stream.playlist), quality: "Auto", audio: "English" });
|
| 119 |
+
for (const cap of stream.captions || []) subtitles.push({ url: cap.url, lang: cap.language });
|
| 120 |
+
} else if (path === "purstream") {
|
| 121 |
+
for (const s of data.sources || [])
|
| 122 |
+
sources.push({ url: s.url, type: inferType(s.format, s.url), quality: s.name || "Auto", audio: "French" });
|
| 123 |
+
} else if (path === "moviebox") {
|
| 124 |
+
for (const u of data.url || [])
|
| 125 |
+
sources.push({ url: u.link, type: inferType(u.type, u.link), quality: "Auto", audio: u.lang || "Unknown" });
|
| 126 |
+
} else if (path === "catflix" || path === "lamda" || path === "flixhq") {
|
| 127 |
+
// Generic shapes seen across the simpler providers.
|
| 128 |
+
const list = data.sources || data.streams || [];
|
| 129 |
+
for (const s of list)
|
| 130 |
+
sources.push({
|
| 131 |
+
url: s.url || s.file || s.link,
|
| 132 |
+
type: inferType(s.type || s.format, s.url || s.file || s.link || ""),
|
| 133 |
+
quality: s.quality || s.label || s.name || "Auto",
|
| 134 |
+
audio: s.language || s.lang || "Unknown",
|
| 135 |
+
});
|
| 136 |
+
}
|
| 137 |
+
} catch {
|
| 138 |
+
/* unexpected payload shape — skip */
|
| 139 |
+
}
|
| 140 |
+
return { sources: sources.filter((s) => s.url), subtitles: subtitles.filter((s) => s.url) };
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
async function fetchProvider(
|
| 144 |
+
def: ServerDef,
|
| 145 |
+
ctx: SourceContext
|
| 146 |
+
): Promise<{ sources: RawSource[]; subtitles: { url: string; lang: string }[] }> {
|
| 147 |
+
const isMovie = !ctx.season || !ctx.episode;
|
| 148 |
+
const path = isMovie
|
| 149 |
+
? `${API_BASE}/${def.path}/movie/${ctx.tmdbId}${def.query}`
|
| 150 |
+
: `${API_BASE}/${def.path}/tv/${ctx.tmdbId}/${ctx.season}/${ctx.episode}${def.query}`;
|
| 151 |
+
try {
|
| 152 |
+
const res = await fetch(path, {
|
| 153 |
+
headers: HEADERS,
|
| 154 |
+
cache: "no-store",
|
| 155 |
+
signal: AbortSignal.timeout(8000),
|
| 156 |
+
});
|
| 157 |
+
if (!res.ok) return { sources: [], subtitles: [] };
|
| 158 |
+
const json = await res.json();
|
| 159 |
+
if (!json?.data) return { sources: [], subtitles: [] };
|
| 160 |
+
return mapServer(def.path, decodePayload(json.data));
|
| 161 |
+
} catch {
|
| 162 |
+
return { sources: [], subtitles: [] };
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function toResult(
|
| 167 |
+
raw: { sources: RawSource[]; subtitles: { url: string; lang: string }[] },
|
| 168 |
+
label: (s: RawSource, i: number) => string,
|
| 169 |
+
serverName: string
|
| 170 |
+
): { streams: SourceStream[]; subtitles: SourceSubtitle[] } {
|
| 171 |
+
const streams: SourceStream[] = raw.sources.map((s, i) => ({
|
| 172 |
+
file: proxyUrl(s.url),
|
| 173 |
+
label: label(s, i),
|
| 174 |
+
type: s.type as SourceStream["type"],
|
| 175 |
+
}));
|
| 176 |
+
const subtitles: SourceSubtitle[] = raw.subtitles.map((s) => ({
|
| 177 |
+
url: proxyUrl(s.url),
|
| 178 |
+
display: s.lang || "Subtitle",
|
| 179 |
+
language: (s.lang || "").slice(0, 2).toLowerCase(),
|
| 180 |
+
source: serverName,
|
| 181 |
+
}));
|
| 182 |
+
return { streams, subtitles };
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// A meaningful per-stream label for a single provider's result.
|
| 186 |
+
function childLabel(s: RawSource, i: number): string {
|
| 187 |
+
if (/\d{3,4}/.test(s.quality)) return s.quality.replace(/p$/i, "");
|
| 188 |
+
if (s.audio && s.audio !== "Unknown") return s.audio;
|
| 189 |
+
return s.quality && s.quality !== "Auto" ? s.quality : `Source ${i + 1}`;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// Each provider becomes a selectable sub-source.
|
| 193 |
+
const children: SubSource[] = SERVERS.map((def) => ({
|
| 194 |
+
id: def.path,
|
| 195 |
+
name: def.name,
|
| 196 |
+
async fetch(ctx) {
|
| 197 |
+
const raw = await fetchProvider(def, ctx);
|
| 198 |
+
if (!raw.sources.length) return null;
|
| 199 |
+
return toResult(raw, childLabel, def.name);
|
| 200 |
+
},
|
| 201 |
+
}));
|
| 202 |
+
|
| 203 |
+
export const vidnest: SourceModule = {
|
| 204 |
+
id: "vidnest",
|
| 205 |
+
name: "Makena",
|
| 206 |
+
label: "11 providers · auto-pick",
|
| 207 |
+
active: true,
|
| 208 |
+
rank: 4,
|
| 209 |
+
children,
|
| 210 |
+
// Parent: query every provider concurrently and aggregate. Each stream is
|
| 211 |
+
// labelled by its provider so the quality menu doubles as a source picker.
|
| 212 |
+
async fetch(ctx) {
|
| 213 |
+
const results = await Promise.all(
|
| 214 |
+
SERVERS.map(async (def) => {
|
| 215 |
+
const raw = await fetchProvider(def, ctx);
|
| 216 |
+
return raw.sources.length ? { def, raw } : null;
|
| 217 |
+
})
|
| 218 |
+
);
|
| 219 |
+
const streams: SourceStream[] = [];
|
| 220 |
+
const subtitles: SourceSubtitle[] = [];
|
| 221 |
+
for (const r of results) {
|
| 222 |
+
if (!r) continue;
|
| 223 |
+
const mapped = toResult(r.raw, () => r.def.name, r.def.name);
|
| 224 |
+
streams.push(...mapped.streams);
|
| 225 |
+
subtitles.push(...mapped.subtitles);
|
| 226 |
+
}
|
| 227 |
+
if (!streams.length) return null;
|
| 228 |
+
return { streams, subtitles };
|
| 229 |
+
},
|
| 230 |
+
};
|
lib/scraper/vidrock.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from "crypto";
|
| 2 |
+
import type { SourceModule, SourceStream, SourceSubtitle } from "./types";
|
| 3 |
+
|
| 4 |
+
// Server-only VidRock resolver (vidrock.net). The item id is AES-CBC encrypted
|
| 5 |
+
// (key + IV derived from a fixed passphrase) to build the API path; streams are
|
| 6 |
+
// then wrapped in VidRock's omss proxy. Some entries point at a CDN-selector
|
| 7 |
+
// (hls2.vdrk.site) that has to be expanded into per-resolution streams.
|
| 8 |
+
|
| 9 |
+
const BASE_URL = "https://vidrock.net/";
|
| 10 |
+
const SUB_BASE = "https://sub.vdrk.site";
|
| 11 |
+
const PROXY_PREFIX = "https://proxy.vidrock.store/";
|
| 12 |
+
const PASSPHRASE = "x7k9mPqT2rWvY8zA5bC3nF6hJ2lK4mN9"; // 32 bytes → AES-256
|
| 13 |
+
|
| 14 |
+
const HEADERS: Record<string, string> = {
|
| 15 |
+
"User-Agent":
|
| 16 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150 Safari/537.36",
|
| 17 |
+
Accept: "application/json, text/javascript, */*; q=0.01",
|
| 18 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 19 |
+
Referer: BASE_URL,
|
| 20 |
+
Origin: BASE_URL.replace(/\/$/, ""),
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
function encryptItemId(itemId: string): string {
|
| 24 |
+
const key = Buffer.from(PASSPHRASE, "utf-8");
|
| 25 |
+
const iv = key.subarray(0, 16);
|
| 26 |
+
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); // PKCS7 default
|
| 27 |
+
const enc = Buffer.concat([cipher.update(itemId, "utf-8"), cipher.final()]);
|
| 28 |
+
return enc.toString("base64url"); // url-safe, no padding
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// VidRock plays through its own omss proxy (carries the required headers).
|
| 32 |
+
function proxyUrl(url: string): string {
|
| 33 |
+
if (!url) return url;
|
| 34 |
+
const data = encodeURIComponent(JSON.stringify({ url, headers: HEADERS }));
|
| 35 |
+
return `https://omss.fstream.app/v1/proxy?data=${data}`;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
interface RawSource {
|
| 39 |
+
url: string;
|
| 40 |
+
type: SourceStream["type"];
|
| 41 |
+
quality: string;
|
| 42 |
+
audio: string;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
async function resolve(
|
| 46 |
+
tmdbId: number,
|
| 47 |
+
season: number,
|
| 48 |
+
episode: number
|
| 49 |
+
): Promise<{ sources: RawSource[]; subs: { file: string; label: string }[] }> {
|
| 50 |
+
const isMovie = !season || !episode;
|
| 51 |
+
const itemId = isMovie ? `${tmdbId}` : `${tmdbId}_${season}_${episode}`;
|
| 52 |
+
const mediaType = isMovie ? "movie" : "tv";
|
| 53 |
+
const apiUrl = `${BASE_URL}api/${mediaType}/${encryptItemId(itemId)}`;
|
| 54 |
+
|
| 55 |
+
const sources: RawSource[] = [];
|
| 56 |
+
const subs = await fetchSubs(tmdbId, season, episode);
|
| 57 |
+
|
| 58 |
+
let data: any;
|
| 59 |
+
try {
|
| 60 |
+
const res = await fetch(apiUrl, {
|
| 61 |
+
headers: HEADERS,
|
| 62 |
+
cache: "no-store",
|
| 63 |
+
signal: AbortSignal.timeout(10000),
|
| 64 |
+
});
|
| 65 |
+
if (!res.ok) return { sources, subs };
|
| 66 |
+
data = await res.json();
|
| 67 |
+
} catch {
|
| 68 |
+
return { sources, subs };
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
for (const stream of Object.values<any>(data || {})) {
|
| 72 |
+
if (!stream || typeof stream !== "object") continue;
|
| 73 |
+
const streamUrl = stream.url;
|
| 74 |
+
if (!streamUrl) continue;
|
| 75 |
+
const audio = stream.language === "English" ? "eng" : stream.language || "Unknown";
|
| 76 |
+
|
| 77 |
+
if (String(streamUrl).includes("hls2.vdrk.site")) {
|
| 78 |
+
// CDN selector — expand into one stream per resolution.
|
| 79 |
+
try {
|
| 80 |
+
const cdnRes = await fetch(streamUrl, {
|
| 81 |
+
headers: HEADERS,
|
| 82 |
+
cache: "no-store",
|
| 83 |
+
signal: AbortSignal.timeout(8000),
|
| 84 |
+
});
|
| 85 |
+
if (!cdnRes.ok) continue;
|
| 86 |
+
const cdnData = await cdnRes.json();
|
| 87 |
+
for (const obj of cdnData || []) {
|
| 88 |
+
let finalUrl: string = obj.url || "";
|
| 89 |
+
if (finalUrl.startsWith(PROXY_PREFIX)) {
|
| 90 |
+
finalUrl = decodeURIComponent(
|
| 91 |
+
finalUrl.slice(PROXY_PREFIX.length).replace(/^\/+/, "")
|
| 92 |
+
);
|
| 93 |
+
}
|
| 94 |
+
if (!finalUrl) continue;
|
| 95 |
+
sources.push({
|
| 96 |
+
url: finalUrl,
|
| 97 |
+
type: finalUrl.toLowerCase().includes(".mp4") ? "mp4" : "hls",
|
| 98 |
+
quality: `${obj.resolution || "Unknown"}`,
|
| 99 |
+
audio,
|
| 100 |
+
});
|
| 101 |
+
}
|
| 102 |
+
} catch {
|
| 103 |
+
continue;
|
| 104 |
+
}
|
| 105 |
+
} else {
|
| 106 |
+
sources.push({ url: streamUrl, type: "hls", quality: "1080", audio });
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
return { sources, subs };
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
async function fetchSubs(
|
| 114 |
+
tmdbId: number,
|
| 115 |
+
season: number,
|
| 116 |
+
episode: number
|
| 117 |
+
): Promise<{ file: string; label: string }[]> {
|
| 118 |
+
const isMovie = !season || !episode;
|
| 119 |
+
const url = isMovie
|
| 120 |
+
? `${SUB_BASE}/v2/movie/${tmdbId}`
|
| 121 |
+
: `${SUB_BASE}/v2/tv/${tmdbId}/${season}/${episode}`;
|
| 122 |
+
try {
|
| 123 |
+
const res = await fetch(url, {
|
| 124 |
+
headers: HEADERS,
|
| 125 |
+
cache: "no-store",
|
| 126 |
+
signal: AbortSignal.timeout(8000),
|
| 127 |
+
});
|
| 128 |
+
if (!res.ok) return [];
|
| 129 |
+
const data = await res.json();
|
| 130 |
+
return (Array.isArray(data) ? data : [])
|
| 131 |
+
.filter((s: any) => s.file && s.label)
|
| 132 |
+
.map((s: any) => ({ file: s.file, label: s.label }));
|
| 133 |
+
} catch {
|
| 134 |
+
return [];
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
function langCode(label: string): string {
|
| 139 |
+
const map: Record<string, string> = {
|
| 140 |
+
english: "en", arabic: "ar", bengali: "bn", filipino: "tl", french: "fr",
|
| 141 |
+
indonesian: "id", malay: "ms", portuguese: "pt", russian: "ru", spanish: "es",
|
| 142 |
+
german: "de", italian: "it", turkish: "tr", hindi: "hi", korean: "ko",
|
| 143 |
+
japanese: "ja", chinese: "zh", dutch: "nl", polish: "pl", thai: "th",
|
| 144 |
+
vietnamese: "vi",
|
| 145 |
+
};
|
| 146 |
+
return map[label.toLowerCase()] || label.slice(0, 2).toLowerCase();
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
export const vidrock: SourceModule = {
|
| 150 |
+
id: "vidrock",
|
| 151 |
+
name: "Kai",
|
| 152 |
+
label: "Multi-CDN · built-in subs",
|
| 153 |
+
active: true,
|
| 154 |
+
rank: 7,
|
| 155 |
+
async fetch(ctx) {
|
| 156 |
+
const { sources, subs } = await resolve(
|
| 157 |
+
ctx.tmdbId,
|
| 158 |
+
ctx.season,
|
| 159 |
+
ctx.episode
|
| 160 |
+
);
|
| 161 |
+
if (!sources.length) return null;
|
| 162 |
+
const streams: SourceStream[] = sources.map((s) => ({
|
| 163 |
+
file: proxyUrl(s.url),
|
| 164 |
+
label: /^\d{3,4}$/.test(s.quality) ? s.quality : "auto",
|
| 165 |
+
type: s.type,
|
| 166 |
+
}));
|
| 167 |
+
// De-dupe by resolution, keep highest first.
|
| 168 |
+
streams.sort(
|
| 169 |
+
(a, b) => (parseInt(b.label, 10) || 0) - (parseInt(a.label, 10) || 0)
|
| 170 |
+
);
|
| 171 |
+
const subtitles: SourceSubtitle[] = subs.map((s) => ({
|
| 172 |
+
url: ctx.proxySub(s.file),
|
| 173 |
+
display: s.label,
|
| 174 |
+
language: langCode(s.label),
|
| 175 |
+
source: "Kai",
|
| 176 |
+
}));
|
| 177 |
+
return { streams, subtitles };
|
| 178 |
+
},
|
| 179 |
+
};
|
lib/scraper/vixsrc.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { SourceModule, SourceStream } from "./types";
|
| 2 |
+
|
| 3 |
+
// Server-only VixSrc resolver (vixsrc.to). Hits the JSON API for a TMDB title to
|
| 4 |
+
// get the embed iframe path, scrapes a short-lived token/expires/playlist out of
|
| 5 |
+
// the embed HTML, then builds the signed master playlist URL. The master playlist
|
| 6 |
+
// is parsed for the best resolution and its CDN needs a Referer, so it's wrapped
|
| 7 |
+
// in our /api/stream proxy for playback.
|
| 8 |
+
|
| 9 |
+
const BASE_URL = "https://vixsrc.to";
|
| 10 |
+
|
| 11 |
+
const HEADERS: Record<string, string> = {
|
| 12 |
+
"User-Agent":
|
| 13 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150 Safari/537.36",
|
| 14 |
+
Accept: "application/json, text/javascript, */*; q=0.01",
|
| 15 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 16 |
+
Referer: BASE_URL,
|
| 17 |
+
Origin: BASE_URL,
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
interface TokenData {
|
| 21 |
+
token: string;
|
| 22 |
+
expires: string;
|
| 23 |
+
playlist: string;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// JS Date.now() works in ms; VixSrc's `expires` is a unix-seconds string.
|
| 27 |
+
function isTokenExpired(expires: string): boolean {
|
| 28 |
+
const expiration = parseInt(expires, 10);
|
| 29 |
+
if (!Number.isFinite(expiration)) return true;
|
| 30 |
+
return expiration * 1000 - 60000 < Date.now();
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function extractTokenData(html: string): TokenData | null {
|
| 34 |
+
const token = html.match(/token["']\s*:\s*["']([^"']+)/)?.[1];
|
| 35 |
+
const expires = html.match(/expires["']\s*:\s*["']([^"']+)/)?.[1];
|
| 36 |
+
const playlist = html.match(/url\s*:\s*["']([^"']+)/)?.[1];
|
| 37 |
+
|
| 38 |
+
if (!token || !expires || !playlist) return null;
|
| 39 |
+
if (isTokenExpired(expires)) return null;
|
| 40 |
+
|
| 41 |
+
return { token, expires, playlist };
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Pull the highest RESOLUTION=NxH variant out of the HLS master manifest.
|
| 45 |
+
function bestResolution(manifest: string): number | null {
|
| 46 |
+
let best: number | null = null;
|
| 47 |
+
const re = /#EXT-X-STREAM-INF:[^\n]*RESOLUTION=\d+x(\d+)/g;
|
| 48 |
+
let m: RegExpExecArray | null;
|
| 49 |
+
while ((m = re.exec(manifest))) {
|
| 50 |
+
const h = parseInt(m[1], 10);
|
| 51 |
+
if (Number.isFinite(h) && (best === null || h > best)) best = h;
|
| 52 |
+
}
|
| 53 |
+
return best;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
async function resolveMaster(
|
| 57 |
+
tmdbId: number,
|
| 58 |
+
season: number,
|
| 59 |
+
episode: number
|
| 60 |
+
): Promise<{ masterUrl: string; referer: string; height: number | null } | null> {
|
| 61 |
+
const isMovie = !season || !episode;
|
| 62 |
+
const apiUrl = isMovie
|
| 63 |
+
? `${BASE_URL}/api/movie/${tmdbId}`
|
| 64 |
+
: `${BASE_URL}/api/tv/${tmdbId}/${season}/${episode}`;
|
| 65 |
+
|
| 66 |
+
const get = (url: string, headers: Record<string, string> = HEADERS) =>
|
| 67 |
+
fetch(url, {
|
| 68 |
+
headers,
|
| 69 |
+
cache: "no-store",
|
| 70 |
+
signal: AbortSignal.timeout(8000),
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
// 1. API → embed iframe path.
|
| 74 |
+
const apiRes = await get(apiUrl);
|
| 75 |
+
if (!apiRes.ok) return null;
|
| 76 |
+
const apiData = await apiRes.json().catch(() => null);
|
| 77 |
+
const sublink: string | undefined = apiData?.src;
|
| 78 |
+
if (!sublink) return null;
|
| 79 |
+
|
| 80 |
+
// 2. Embed HTML → token/expires/playlist.
|
| 81 |
+
const embedUrl = BASE_URL + sublink;
|
| 82 |
+
const htmlRes = await get(embedUrl);
|
| 83 |
+
if (!htmlRes.ok) return null;
|
| 84 |
+
const tokenData = extractTokenData(await htmlRes.text());
|
| 85 |
+
if (!tokenData) return null;
|
| 86 |
+
|
| 87 |
+
// 3. Build the signed master playlist URL.
|
| 88 |
+
const sep = tokenData.playlist.includes("?") ? "&" : "?";
|
| 89 |
+
const masterUrl = `${tokenData.playlist}${sep}token=${tokenData.token}&expires=${tokenData.expires}&h=1`;
|
| 90 |
+
|
| 91 |
+
// 4. Fetch the master to confirm it resolves + pick the best resolution.
|
| 92 |
+
const playlistRes = await get(masterUrl, { ...HEADERS, Referer: apiUrl });
|
| 93 |
+
if (!playlistRes.ok) return null;
|
| 94 |
+
const height = bestResolution(await playlistRes.text());
|
| 95 |
+
if (height === null) return null;
|
| 96 |
+
|
| 97 |
+
return { masterUrl, referer: apiUrl, height };
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// VixSrc's CDN requires the Referer used to mint the playlist, so playback goes
|
| 101 |
+
// through our /api/stream proxy. Subtitles are broken upstream, so we lean on the
|
| 102 |
+
// route's default Wyzie/OpenSubtitles layer instead.
|
| 103 |
+
export const vixsrc: SourceModule = {
|
| 104 |
+
id: "vixsrc",
|
| 105 |
+
name: "Noor",
|
| 106 |
+
label: "HLS · proxied",
|
| 107 |
+
active: true,
|
| 108 |
+
rank: 8,
|
| 109 |
+
async fetch(ctx) {
|
| 110 |
+
const resolved = await resolveMaster(
|
| 111 |
+
ctx.tmdbId,
|
| 112 |
+
ctx.season,
|
| 113 |
+
ctx.episode
|
| 114 |
+
).catch(() => null);
|
| 115 |
+
if (!resolved) return null;
|
| 116 |
+
|
| 117 |
+
const stream: SourceStream = {
|
| 118 |
+
file: ctx.proxyStream(resolved.masterUrl, resolved.referer),
|
| 119 |
+
label: resolved.height ? String(resolved.height) : "auto",
|
| 120 |
+
type: "hls",
|
| 121 |
+
};
|
| 122 |
+
return { streams: [stream] };
|
| 123 |
+
},
|
| 124 |
+
};
|
lib/security/tokens.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from "crypto";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Server-side signing for the stream/subtitle proxy. Upstream URLs are wrapped
|
| 5 |
+
* in a short-lived HMAC-signed token so /api/stream only ever proxies URLs this
|
| 6 |
+
* server itself minted — it can't be abused as an open proxy. The secret
|
| 7 |
+
* (APP_SECRET) never leaves the server.
|
| 8 |
+
*/
|
| 9 |
+
const SECRET =
|
| 10 |
+
process.env.APP_SECRET ||
|
| 11 |
+
"dev-only-insecure-secret-change-me-please-0123456789";
|
| 12 |
+
|
| 13 |
+
function b64url(buf: Buffer): string {
|
| 14 |
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
| 15 |
+
}
|
| 16 |
+
function b64urlToBuf(s: string): Buffer {
|
| 17 |
+
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
|
| 18 |
+
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
|
| 19 |
+
}
|
| 20 |
+
function hmac(data: string): string {
|
| 21 |
+
return b64url(crypto.createHmac("sha256", SECRET).update(data).digest());
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function sign(payload: Record<string, unknown>, ttlSeconds: number): string {
|
| 25 |
+
const body = { ...payload, exp: Math.floor(Date.now() / 1000) + ttlSeconds };
|
| 26 |
+
const p = b64url(Buffer.from(JSON.stringify(body)));
|
| 27 |
+
return `${p}.${hmac(p)}`;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export function verify<T = Record<string, any>>(token: string | null): T | null {
|
| 31 |
+
if (!token || token.indexOf(".") < 0) return null;
|
| 32 |
+
const [p, sig] = token.split(".");
|
| 33 |
+
if (!p || !sig) return null;
|
| 34 |
+
const expected = hmac(p);
|
| 35 |
+
if (
|
| 36 |
+
sig.length !== expected.length ||
|
| 37 |
+
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
|
| 38 |
+
) {
|
| 39 |
+
return null;
|
| 40 |
+
}
|
| 41 |
+
let body: any;
|
| 42 |
+
try {
|
| 43 |
+
body = JSON.parse(b64urlToBuf(p).toString("utf8"));
|
| 44 |
+
} catch {
|
| 45 |
+
return null;
|
| 46 |
+
}
|
| 47 |
+
if (typeof body.exp !== "number" || body.exp < Math.floor(Date.now() / 1000)) {
|
| 48 |
+
return null;
|
| 49 |
+
}
|
| 50 |
+
return body as T;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export interface StreamClaim {
|
| 54 |
+
k: "stream";
|
| 55 |
+
url: string;
|
| 56 |
+
ref?: string; // Referer to send upstream (some CDNs hotlink-lock manifests)
|
| 57 |
+
exp: number;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/** Wrap a real upstream stream/subtitle URL so it's proxied, not exposed. */
|
| 61 |
+
export function mintStreamToken(url: string, ref?: string): string {
|
| 62 |
+
return sign({ k: "stream", url, ...(ref ? { ref } : {}) }, 60 * 60 * 3); // 3h
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export interface DashBaseClaim {
|
| 66 |
+
k: "dbase";
|
| 67 |
+
url: string; // the real DASH <BaseURL>, ending in "/"
|
| 68 |
+
ref?: string;
|
| 69 |
+
exp: number;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/** Wrap a DASH BaseURL so segment paths can be appended and proxied. */
|
| 73 |
+
export function mintDashBase(base: string, ref?: string): string {
|
| 74 |
+
return sign({ k: "dbase", url: base, ...(ref ? { ref } : {}) }, 60 * 60 * 3);
|
| 75 |
+
}
|
lib/subtitles/flags.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Maps a subtitle language code (ISO 639-1, optionally with a region suffix
|
| 2 |
+
// like "pt-BR" / "en_US") to a country flag image served by flagsapi.com.
|
| 3 |
+
|
| 4 |
+
const LANG_TO_COUNTRY: Record<string, string> = {
|
| 5 |
+
en: "GB", es: "ES", pt: "PT", fr: "FR", de: "DE", it: "IT", ru: "RU",
|
| 6 |
+
ja: "JP", ko: "KR", zh: "CN", ar: "SA", nl: "NL", sv: "SE", no: "NO",
|
| 7 |
+
nb: "NO", nn: "NO", da: "DK", fi: "FI", pl: "PL", tr: "TR", el: "GR",
|
| 8 |
+
cs: "CZ", hu: "HU", ro: "RO", th: "TH", vi: "VN", id: "ID", he: "IL",
|
| 9 |
+
iw: "IL", hi: "IN", uk: "UA", sr: "RS", hr: "HR", sk: "SK", sl: "SI",
|
| 10 |
+
bg: "BG", is: "IS", et: "EE", lv: "LV", lt: "LT", fa: "IR", ms: "MY",
|
| 11 |
+
tl: "PH", fil: "PH", ca: "ES", eu: "ES", gl: "ES", ga: "IE", cy: "GB",
|
| 12 |
+
sq: "AL", mk: "MK", bs: "BA", be: "BY", ka: "GE", hy: "AM", az: "AZ",
|
| 13 |
+
kk: "KZ", uz: "UZ", mn: "MN", ne: "NP", si: "LK", ta: "IN", te: "IN",
|
| 14 |
+
ml: "IN", kn: "IN", bn: "BD", ur: "PK", pa: "IN", mr: "IN", gu: "IN",
|
| 15 |
+
af: "ZA", sw: "KE", am: "ET", zu: "ZA", la: "VA",
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export type FlagSize = 16 | 24 | 32 | 48 | 64;
|
| 19 |
+
|
| 20 |
+
export function flagUrl(
|
| 21 |
+
language?: string | null,
|
| 22 |
+
size: FlagSize = 24
|
| 23 |
+
): string | null {
|
| 24 |
+
if (!language) return null;
|
| 25 |
+
const lc = language.toLowerCase().trim();
|
| 26 |
+
// Explicit region suffix wins, e.g. "pt-br" -> BR, "en_us" -> US.
|
| 27 |
+
const m = lc.match(/[-_]([a-z]{2})$/);
|
| 28 |
+
let cc = m ? m[1].toUpperCase() : undefined;
|
| 29 |
+
if (!cc) cc = LANG_TO_COUNTRY[lc.slice(0, 2)];
|
| 30 |
+
if (!cc) return null;
|
| 31 |
+
return `https://flagsapi.com/${cc}/flat/${size}.png`;
|
| 32 |
+
}
|
lib/subtitles/opensubtitles.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Stremio OpenSubtitles v3 add-on. Keyed by IMDB id, so we first resolve the
|
| 2 |
+
// TMDB id to an IMDB id. Returned as a shared subtitle layer (merged with Wyzie
|
| 3 |
+
// in /api/sources) — these are the "built-in" OpenSubtitles tracks.
|
| 4 |
+
|
| 5 |
+
const TMDB_KEY = process.env.TMDB_API_KEY || "";
|
| 6 |
+
|
| 7 |
+
export interface OsSub {
|
| 8 |
+
url: string;
|
| 9 |
+
display: string;
|
| 10 |
+
language: string; // 2-letter best-effort
|
| 11 |
+
source: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
async function tmdbToImdb(
|
| 15 |
+
tmdbId: number,
|
| 16 |
+
isTv: boolean
|
| 17 |
+
): Promise<string | null> {
|
| 18 |
+
try {
|
| 19 |
+
const mt = isTv ? "tv" : "movie";
|
| 20 |
+
const r = await fetch(
|
| 21 |
+
`https://api.themoviedb.org/3/${mt}/${tmdbId}/external_ids?api_key=${TMDB_KEY}`,
|
| 22 |
+
{ cache: "no-store" }
|
| 23 |
+
);
|
| 24 |
+
if (!r.ok) return null;
|
| 25 |
+
const d = await r.json();
|
| 26 |
+
return d.imdb_id || null;
|
| 27 |
+
} catch {
|
| 28 |
+
return null;
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// 3-letter (ISO 639-2) → 2-letter (ISO 639-1), best effort.
|
| 33 |
+
const THREE_TO_TWO: Record<string, string> = {
|
| 34 |
+
eng: "en", spa: "es", spl: "es", por: "pt", pob: "pt", fre: "fr", fra: "fr",
|
| 35 |
+
ger: "de", deu: "de", ita: "it", rus: "ru", ara: "ar", jpn: "ja", kor: "ko",
|
| 36 |
+
zho: "zh", chi: "zh", dut: "nl", nld: "nl", pol: "pl", tur: "tr", swe: "sv",
|
| 37 |
+
nor: "no", dan: "da", fin: "fi", hun: "hu", ron: "ro", rum: "ro", ell: "el",
|
| 38 |
+
ukr: "uk", bul: "bg", hrv: "hr", srp: "sr", slv: "sl", slo: "sk", slk: "sk",
|
| 39 |
+
cze: "cs", ces: "cs", heb: "he", hin: "hi", tha: "th", vie: "vi", ind: "id",
|
| 40 |
+
may: "ms", msa: "ms", fil: "tl", per: "fa", fas: "fa", est: "et", bos: "bs",
|
| 41 |
+
mac: "mk", mkd: "mk", alb: "sq", sqi: "sq", ben: "bn", sin: "si", khm: "km",
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
function langName(code2: string): string {
|
| 45 |
+
try {
|
| 46 |
+
return (
|
| 47 |
+
new Intl.DisplayNames(["en"], { type: "language" }).of(code2) || code2
|
| 48 |
+
);
|
| 49 |
+
} catch {
|
| 50 |
+
return code2;
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export async function fetchOpenSubtitles(
|
| 55 |
+
tmdbId: number,
|
| 56 |
+
season?: number,
|
| 57 |
+
episode?: number
|
| 58 |
+
): Promise<{ url: string; display: string; language: string }[]> {
|
| 59 |
+
const isTv = !!(season && episode);
|
| 60 |
+
const imdb = await tmdbToImdb(tmdbId, isTv);
|
| 61 |
+
if (!imdb) return [];
|
| 62 |
+
|
| 63 |
+
const url = isTv
|
| 64 |
+
? `https://opensubtitles-v3.strem.io/subtitles/series/${imdb}:${season}:${episode}.json`
|
| 65 |
+
: `https://opensubtitles-v3.strem.io/subtitles/movie/${imdb}.json`;
|
| 66 |
+
|
| 67 |
+
try {
|
| 68 |
+
const r = await fetch(url, {
|
| 69 |
+
cache: "no-store",
|
| 70 |
+
signal: AbortSignal.timeout(8000),
|
| 71 |
+
});
|
| 72 |
+
if (!r.ok) return [];
|
| 73 |
+
const data = await r.json();
|
| 74 |
+
const list: any[] = data?.subtitles || [];
|
| 75 |
+
return list
|
| 76 |
+
.filter((s) => s.url && s.lang)
|
| 77 |
+
.map((s) => {
|
| 78 |
+
const two = THREE_TO_TWO[String(s.lang).toLowerCase()] || s.lang;
|
| 79 |
+
return {
|
| 80 |
+
url: s.url as string,
|
| 81 |
+
display: langName(two),
|
| 82 |
+
language: two,
|
| 83 |
+
};
|
| 84 |
+
});
|
| 85 |
+
} catch {
|
| 86 |
+
return [];
|
| 87 |
+
}
|
| 88 |
+
}
|
lib/subtitles/vdrk.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Server-side subtitle lookup via sub.vdrk.site (keyed by TMDB id). Returns the
|
| 2 |
+
// raw upstream .vtt/.srt URLs; the caller is responsible for proxying them.
|
| 3 |
+
export interface VdrkSub {
|
| 4 |
+
label: string;
|
| 5 |
+
language: string;
|
| 6 |
+
file: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export async function fetchVdrkSubs(
|
| 10 |
+
type: "movie" | "tv",
|
| 11 |
+
tmdbId: number,
|
| 12 |
+
season = 0,
|
| 13 |
+
episode = 0
|
| 14 |
+
): Promise<VdrkSub[]> {
|
| 15 |
+
const upstream =
|
| 16 |
+
type === "tv"
|
| 17 |
+
? `https://sub.vdrk.site/v1/tv/${tmdbId}/${season || 1}/${episode || 1}`
|
| 18 |
+
: `https://sub.vdrk.site/v1/movie/${tmdbId}`;
|
| 19 |
+
|
| 20 |
+
try {
|
| 21 |
+
const res = await fetch(upstream, { cache: "no-store" });
|
| 22 |
+
if (!res.ok) return [];
|
| 23 |
+
const data = await res.json();
|
| 24 |
+
if (!Array.isArray(data)) return [];
|
| 25 |
+
return data
|
| 26 |
+
.filter((s: any) => s && s.file && s.label)
|
| 27 |
+
.map((s: any) => ({
|
| 28 |
+
label: s.label,
|
| 29 |
+
language: guessLang(s.label),
|
| 30 |
+
file: s.file,
|
| 31 |
+
}));
|
| 32 |
+
} catch {
|
| 33 |
+
return [];
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// Best-effort language code (optionally with a region, e.g. "pt-BR") from a
|
| 38 |
+
// vdrk subtitle label. Region suffixes drive the flag shown in the player.
|
| 39 |
+
function guessLang(label: string): string {
|
| 40 |
+
const n = (label || "").toLowerCase();
|
| 41 |
+
if (n.includes("brazil")) return "pt-BR";
|
| 42 |
+
if (n.includes("english")) return "en";
|
| 43 |
+
if (n.includes("spanish") || n.includes("español") || n.includes("castilian"))
|
| 44 |
+
return "es";
|
| 45 |
+
if (n.includes("latino") || n.includes("latin")) return "es-MX";
|
| 46 |
+
if (n.includes("portuguese") || n.includes("português")) return "pt";
|
| 47 |
+
if (n.includes("french") || n.includes("français")) return "fr";
|
| 48 |
+
if (n.includes("german") || n.includes("deutsch")) return "de";
|
| 49 |
+
if (n.includes("italian")) return "it";
|
| 50 |
+
if (n.includes("russian")) return "ru";
|
| 51 |
+
if (n.includes("dutch")) return "nl";
|
| 52 |
+
if (n.includes("polish")) return "pl";
|
| 53 |
+
if (n.includes("turkish")) return "tr";
|
| 54 |
+
if (n.includes("arabic")) return "ar";
|
| 55 |
+
if (n.includes("chinese") || n.includes("mandarin")) return "zh";
|
| 56 |
+
if (n.includes("japanese")) return "ja";
|
| 57 |
+
if (n.includes("korean")) return "ko";
|
| 58 |
+
if (n.includes("hebrew")) return "he";
|
| 59 |
+
if (n.includes("hindi")) return "hi";
|
| 60 |
+
if (n.includes("thai")) return "th";
|
| 61 |
+
if (n.includes("vietnamese")) return "vi";
|
| 62 |
+
if (n.includes("indonesian")) return "id";
|
| 63 |
+
if (n.includes("persian") || n.includes("farsi")) return "fa";
|
| 64 |
+
if (n.includes("hungarian")) return "hu";
|
| 65 |
+
if (n.includes("romanian")) return "ro";
|
| 66 |
+
if (n.includes("ukrainian")) return "uk";
|
| 67 |
+
if (n.includes("bulgarian")) return "bg";
|
| 68 |
+
if (n.includes("croatian")) return "hr";
|
| 69 |
+
if (n.includes("slovak")) return "sk";
|
| 70 |
+
if (n.includes("danish")) return "da";
|
| 71 |
+
if (n.includes("swedish")) return "sv";
|
| 72 |
+
if (n.includes("norwegian")) return "no";
|
| 73 |
+
if (n.includes("finnish")) return "fi";
|
| 74 |
+
if (n.includes("greek")) return "el";
|
| 75 |
+
if (n.includes("czech")) return "cs";
|
| 76 |
+
if (n.includes("serbian")) return "sr";
|
| 77 |
+
if (n.includes("slovenian")) return "sl";
|
| 78 |
+
if (n.includes("icelandic")) return "is";
|
| 79 |
+
if (n.includes("estonian")) return "et";
|
| 80 |
+
if (n.includes("latvian")) return "lv";
|
| 81 |
+
if (n.includes("lithuanian")) return "lt";
|
| 82 |
+
return "";
|
| 83 |
+
}
|
lib/tmdb.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// TMDB helper. The API key is read server-side from env, with a fallback to the
|
| 2 |
+
// key provided for this demo so the player page works out of the box.
|
| 3 |
+
const TMDB_KEY =
|
| 4 |
+
process.env.TMDB_API_KEY || "";
|
| 5 |
+
|
| 6 |
+
const BASE = "https://api.themoviedb.org/3";
|
| 7 |
+
|
| 8 |
+
export const img = {
|
| 9 |
+
poster: (
|
| 10 |
+
path: string | null,
|
| 11 |
+
size: "w185" | "w342" | "w500" | "w780" | "original" = "w342"
|
| 12 |
+
) => (path ? `https://image.tmdb.org/t/p/${size}${path}` : null),
|
| 13 |
+
backdrop: (path: string | null, size: "w780" | "w1280" | "original" = "w1280") =>
|
| 14 |
+
path ? `https://image.tmdb.org/t/p/${size}${path}` : null,
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export type MediaType = "movie" | "tv";
|
| 18 |
+
|
| 19 |
+
export interface TmdbItem {
|
| 20 |
+
id: number;
|
| 21 |
+
title: string;
|
| 22 |
+
year: string;
|
| 23 |
+
poster: string | null;
|
| 24 |
+
backdrop: string | null;
|
| 25 |
+
overview: string;
|
| 26 |
+
rating: number;
|
| 27 |
+
runtime: number | null;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function normalize(r: any): TmdbItem {
|
| 31 |
+
const date = r.release_date || r.first_air_date || "";
|
| 32 |
+
return {
|
| 33 |
+
id: r.id,
|
| 34 |
+
title: r.title || r.name || "Untitled",
|
| 35 |
+
year: date ? date.slice(0, 4) : "",
|
| 36 |
+
poster: r.poster_path,
|
| 37 |
+
backdrop: r.backdrop_path,
|
| 38 |
+
overview: r.overview || "",
|
| 39 |
+
rating: r.vote_average || 0,
|
| 40 |
+
runtime: r.runtime ?? (Array.isArray(r.episode_run_time) ? r.episode_run_time[0] : null) ?? null,
|
| 41 |
+
};
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Title logo (transparent PNG) for the player overlay. Prefers an English logo.
|
| 45 |
+
export async function getLogo(
|
| 46 |
+
type: "movie" | "tv",
|
| 47 |
+
id: string
|
| 48 |
+
): Promise<string | null> {
|
| 49 |
+
try {
|
| 50 |
+
const data = await tmdb(`/${type}/${id}/images`, {
|
| 51 |
+
include_image_language: "en,null",
|
| 52 |
+
});
|
| 53 |
+
const logos: any[] = data.logos || [];
|
| 54 |
+
const pick =
|
| 55 |
+
logos.find((l) => l.iso_639_1 === "en") || logos[0] || null;
|
| 56 |
+
return pick?.file_path
|
| 57 |
+
? `https://image.tmdb.org/t/p/w500${pick.file_path}`
|
| 58 |
+
: null;
|
| 59 |
+
} catch {
|
| 60 |
+
return null;
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
async function tmdb(path: string, params: Record<string, string> = {}) {
|
| 65 |
+
const url = new URL(BASE + path);
|
| 66 |
+
url.searchParams.set("api_key", TMDB_KEY);
|
| 67 |
+
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
| 68 |
+
const res = await fetch(url.toString(), { next: { revalidate: 3600 } });
|
| 69 |
+
if (!res.ok) throw new Error(`TMDB ${res.status}`);
|
| 70 |
+
return res.json();
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export async function getPopular(type: "movie" | "tv"): Promise<TmdbItem[]> {
|
| 74 |
+
const data = await tmdb(`/${type}/popular`);
|
| 75 |
+
return (data.results || []).map(normalize);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export async function getById(
|
| 79 |
+
type: "movie" | "tv",
|
| 80 |
+
id: string
|
| 81 |
+
): Promise<TmdbItem | null> {
|
| 82 |
+
try {
|
| 83 |
+
const data = await tmdb(`/${type}/${id}`);
|
| 84 |
+
return normalize(data);
|
| 85 |
+
} catch {
|
| 86 |
+
return null;
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
export async function search(
|
| 91 |
+
type: "movie" | "tv",
|
| 92 |
+
query: string
|
| 93 |
+
): Promise<TmdbItem[]> {
|
| 94 |
+
if (!query.trim()) return [];
|
| 95 |
+
const data = await tmdb(`/search/${type}`, { query });
|
| 96 |
+
return (data.results || []).map(normalize);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
export interface TvSeasonInfo {
|
| 100 |
+
season: number;
|
| 101 |
+
episodeCount: number;
|
| 102 |
+
name: string;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// Season list for a TV show (used by the next-episode + episode-selector
|
| 106 |
+
// features). Excludes specials (season 0).
|
| 107 |
+
export async function getTvSeasons(id: string): Promise<TvSeasonInfo[]> {
|
| 108 |
+
try {
|
| 109 |
+
const data = await tmdb(`/tv/${id}`);
|
| 110 |
+
return (data.seasons || [])
|
| 111 |
+
.filter((s: any) => s.season_number >= 1 && s.episode_count > 0)
|
| 112 |
+
.map((s: any) => ({
|
| 113 |
+
season: s.season_number,
|
| 114 |
+
episodeCount: s.episode_count,
|
| 115 |
+
name: s.name || `Season ${s.season_number}`,
|
| 116 |
+
}));
|
| 117 |
+
} catch {
|
| 118 |
+
return [];
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
export interface TvEpisode {
|
| 123 |
+
episode: number;
|
| 124 |
+
name: string;
|
| 125 |
+
still: string | null;
|
| 126 |
+
overview: string;
|
| 127 |
+
runtime: number | null;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
export async function getSeasonEpisodes(
|
| 131 |
+
id: string,
|
| 132 |
+
season: number
|
| 133 |
+
): Promise<TvEpisode[]> {
|
| 134 |
+
try {
|
| 135 |
+
const data = await tmdb(`/tv/${id}/season/${season}`);
|
| 136 |
+
return (data.episodes || []).map((e: any) => ({
|
| 137 |
+
episode: e.episode_number,
|
| 138 |
+
name: e.name || `Episode ${e.episode_number}`,
|
| 139 |
+
still: e.still_path,
|
| 140 |
+
overview: e.overview || "",
|
| 141 |
+
runtime: e.runtime ?? null,
|
| 142 |
+
}));
|
| 143 |
+
} catch {
|
| 144 |
+
return [];
|
| 145 |
+
}
|
| 146 |
+
}
|
next-env.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="next" />
|
| 2 |
+
/// <reference types="next/image-types/global" />
|
| 3 |
+
import "./.next/types/routes.d.ts";
|
| 4 |
+
|
| 5 |
+
// NOTE: This file should not be edited
|
| 6 |
+
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
next.config.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
reactStrictMode: true,
|
| 4 |
+
// Lean, self-contained output for Docker / simple VPS deploys.
|
| 5 |
+
output: "standalone",
|
| 6 |
+
images: {
|
| 7 |
+
remotePatterns: [{ protocol: "https", hostname: "image.tmdb.org" }],
|
| 8 |
+
},
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export default nextConfig;
|
package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "vidsuper",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "next lint"
|
| 10 |
+
},
|
| 11 |
+
"engines": {
|
| 12 |
+
"node": ">=20"
|
| 13 |
+
},
|
| 14 |
+
"dependencies": {
|
| 15 |
+
"dashjs": "^4.7.4",
|
| 16 |
+
"hls.js": "^1.5.13",
|
| 17 |
+
"next": "^16.2.9",
|
| 18 |
+
"react": "^19.2.7",
|
| 19 |
+
"react-dom": "^19.2.7"
|
| 20 |
+
},
|
| 21 |
+
"devDependencies": {
|
| 22 |
+
"@types/node": "^20",
|
| 23 |
+
"@types/react": "^19.2.17",
|
| 24 |
+
"@types/react-dom": "^19.2.3",
|
| 25 |
+
"autoprefixer": "^10.4.19",
|
| 26 |
+
"postcss": "^8.4.39",
|
| 27 |
+
"tailwindcss": "^3.4.6",
|
| 28 |
+
"typescript": "^5"
|
| 29 |
+
}
|
| 30 |
+
}
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('postcss-load-config').Config} */
|
| 2 |
+
const config = {
|
| 3 |
+
plugins: {
|
| 4 |
+
tailwindcss: {},
|
| 5 |
+
autoprefixer: {},
|
| 6 |
+
},
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export default config;
|
public/logo.svg
ADDED
|
|
tailwind.config.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from "tailwindcss";
|
| 2 |
+
|
| 3 |
+
const config: Config = {
|
| 4 |
+
content: [
|
| 5 |
+
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
| 6 |
+
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
| 7 |
+
],
|
| 8 |
+
theme: {
|
| 9 |
+
extend: {
|
| 10 |
+
fontFamily: {
|
| 11 |
+
sans: [
|
| 12 |
+
"var(--font-montserrat)",
|
| 13 |
+
"var(--font-inter)",
|
| 14 |
+
"ui-sans-serif",
|
| 15 |
+
"system-ui",
|
| 16 |
+
"sans-serif",
|
| 17 |
+
],
|
| 18 |
+
},
|
| 19 |
+
colors: {
|
| 20 |
+
brand: {
|
| 21 |
+
pink: "#f59ff1",
|
| 22 |
+
purple: "#8b5cf6",
|
| 23 |
+
violet: "#a855f7",
|
| 24 |
+
},
|
| 25 |
+
},
|
| 26 |
+
keyframes: {
|
| 27 |
+
"fade-in": {
|
| 28 |
+
"0%": { opacity: "0", transform: "translateY(12px)" },
|
| 29 |
+
"100%": { opacity: "1", transform: "translateY(0)" },
|
| 30 |
+
},
|
| 31 |
+
"fade-in-slow": {
|
| 32 |
+
"0%": { opacity: "0" },
|
| 33 |
+
"100%": { opacity: "1" },
|
| 34 |
+
},
|
| 35 |
+
float: {
|
| 36 |
+
"0%, 100%": { transform: "translateY(0)" },
|
| 37 |
+
"50%": { transform: "translateY(-8px)" },
|
| 38 |
+
},
|
| 39 |
+
"pulse-glow": {
|
| 40 |
+
"0%, 100%": { opacity: "0.5" },
|
| 41 |
+
"50%": { opacity: "0.9" },
|
| 42 |
+
},
|
| 43 |
+
shimmer: {
|
| 44 |
+
"100%": { transform: "translateX(100%)" },
|
| 45 |
+
},
|
| 46 |
+
},
|
| 47 |
+
animation: {
|
| 48 |
+
"fade-in": "fade-in 0.7s ease-out both",
|
| 49 |
+
"fade-in-slow": "fade-in-slow 1s ease-out both",
|
| 50 |
+
float: "float 6s ease-in-out infinite",
|
| 51 |
+
"pulse-glow": "pulse-glow 4s ease-in-out infinite",
|
| 52 |
+
shimmer: "shimmer 2s infinite",
|
| 53 |
+
},
|
| 54 |
+
},
|
| 55 |
+
},
|
| 56 |
+
plugins: [],
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
export default config;
|
tsconfig.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "es2020",
|
| 4 |
+
"downlevelIteration": true,
|
| 5 |
+
"lib": [
|
| 6 |
+
"dom",
|
| 7 |
+
"dom.iterable",
|
| 8 |
+
"esnext"
|
| 9 |
+
],
|
| 10 |
+
"allowJs": true,
|
| 11 |
+
"skipLibCheck": true,
|
| 12 |
+
"strict": true,
|
| 13 |
+
"noEmit": true,
|
| 14 |
+
"esModuleInterop": true,
|
| 15 |
+
"module": "esnext",
|
| 16 |
+
"moduleResolution": "bundler",
|
| 17 |
+
"resolveJsonModule": true,
|
| 18 |
+
"isolatedModules": true,
|
| 19 |
+
"jsx": "react-jsx",
|
| 20 |
+
"incremental": true,
|
| 21 |
+
"plugins": [
|
| 22 |
+
{
|
| 23 |
+
"name": "next"
|
| 24 |
+
}
|
| 25 |
+
],
|
| 26 |
+
"paths": {
|
| 27 |
+
"@/*": [
|
| 28 |
+
"./*"
|
| 29 |
+
]
|
| 30 |
+
}
|
| 31 |
+
},
|
| 32 |
+
"include": [
|
| 33 |
+
"next-env.d.ts",
|
| 34 |
+
"**/*.ts",
|
| 35 |
+
"**/*.tsx",
|
| 36 |
+
".next/types/**/*.ts",
|
| 37 |
+
".next/dev/types/**/*.ts"
|
| 38 |
+
],
|
| 39 |
+
"exclude": [
|
| 40 |
+
"node_modules",
|
| 41 |
+
"wasm"
|
| 42 |
+
]
|
| 43 |
+
}
|