From 94c668c452c7f451214bc3730d0d3b9ae6479c86 Mon Sep 17 00:00:00 2001 From: katelya Date: Thu, 21 May 2026 21:00:12 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20HLS=20?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=B7=A5=E5=85=B7=E4=B8=8E=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 hls-utils.ts,用于处理 HLS 播放类型、策略及 URL 验证 - 实现 playback-resolver.ts,以结合健康检查与代理策略解析出可播放的 URL - 创建 source-quality-store.ts,用于管理视频源质量指标并跟踪播放成功/失败状态 - 开发 stream-health.ts,用于对播放流进行健康检查,包括 Manifest 索引文件和分片验证 --- .env.example | 17 + README.md | 22 + src/app/admin/page.tsx | 54 +- src/app/api/admin/source/route.ts | 18 + src/app/api/playback/health/route.ts | 90 ++++ src/app/api/playback/report/route.ts | 75 +++ src/app/api/playback/resolve/route.ts | 89 ++++ src/app/api/proxy/m3u8-asset/route.ts | 75 +-- src/app/api/proxy/m3u8-filter/route.ts | 490 ++--------------- src/app/api/proxy/m3u8/route.ts | 390 +++++++------- src/app/api/server-config/route.ts | 2 + src/app/layout.tsx | 2 + src/app/play/page.tsx | 615 +++++++++++++++++++++- src/components/EpisodeSelector.tsx | 64 ++- src/components/UserMenu.tsx | 91 ++++ src/lib/admin.types.ts | 10 + src/lib/config.ts | 106 +++- src/lib/m3u8-proxy.ts | 224 ++++++++ src/lib/player/hls-utils.ts | 374 +++++++++++++ src/lib/player/playback-resolver.ts | 157 ++++++ src/lib/player/source-quality-store.ts | 163 ++++++ src/lib/player/stream-health.ts | 695 +++++++++++++++++++++++++ src/lib/utils.ts | 136 ++++- 23 files changed, 3239 insertions(+), 720 deletions(-) create mode 100644 src/app/api/playback/health/route.ts create mode 100644 src/app/api/playback/report/route.ts create mode 100644 src/app/api/playback/resolve/route.ts create mode 100644 src/lib/player/hls-utils.ts create mode 100644 src/lib/player/playback-resolver.ts create mode 100644 src/lib/player/source-quality-store.ts create mode 100644 src/lib/player/stream-health.ts diff --git a/.env.example b/.env.example index b12f17bac..95cfc4bf1 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,23 @@ NEXT_PUBLIC_DOUBAN_PROXY= NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE=auto NEXT_PUBLIC_DOUBAN_IMAGE_PROXY= +# Playback resolver and HLS proxy +# NEXT_PUBLIC_PLAYBACK_PROXY_MODE: +# - smart: try direct first, then manifest proxy, then asset proxy +# - direct: force browser direct playback +# - proxy: force server proxy playback +# - off: disable DecoTV playback proxy +NEXT_PUBLIC_PLAYBACK_PROXY_MODE=smart +PLAYBACK_HEALTH_CHECK=true +PLAYBACK_HEALTH_TIMEOUT_MS=8000 +PLAYBACK_FIRST_SEGMENT_TEST=true +# Keep segment proxy disabled by default. Do not send all ts/m4s traffic through +# a US VPS or serverless function unless you own enough bandwidth. +PLAYBACK_PROXY_SEGMENTS=false +PLAYBACK_MAX_AUTO_SWITCH=3 +PLAYBACK_BAD_SOURCE_TTL=1800 +PLAYBACK_GOOD_SOURCE_TTL=86400 + # 可选:外部元数据服务 # 豆瓣 API Key(未配置时保持现有降级行为) DOUBAN_API_KEY= diff --git a/README.md b/README.md index dfe8787c2..50ef4ce1c 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,28 @@ DecoTV 支持标准的苹果 CMS V10 API 格式。 dockge/komodo 等 docker compose UI 也有自动更新功能 +## 播放卡顿与部署地区说明 + +DecoTV 现在默认使用 `NEXT_PUBLIC_PLAYBACK_PROXY_MODE=smart`。播放页会先检测 m3u8、key、首个分片、Range 和浏览器 CORS,再决定直连、只代理播放列表,或在最后兜底代理 key/map/segment。 + +- 美国 Oracle Cloud 圣何塞 VPS:搜索和详情可能正常,但如果代理 ts/m4s 分片,所有视频流量都会绕到美国 VPS,亚洲资源站通常会明显变慢。推荐 `NEXT_PUBLIC_PLAYBACK_PROXY_MODE=smart`、`PLAYBACK_PROXY_SEGMENTS=false`,不要默认强制 `proxy`。 +- Vercel:Serverless 适合 API、详情和短 m3u8 manifest 处理,不适合长时间大量中转视频分片。推荐 smart,并保持 `PLAYBACK_PROXY_SEGMENTS=false`,避免函数超时和高流量。 +- 中国大陆用户:更适合香港、新加坡、日本、韩国或家庭宽带/NAS 部署。美国机房通常不适合作为视频分片中转,优先让浏览器 direct 播放。 +- 家庭 NAS / 飞牛 OS / 1Panel Docker:推荐 public 模式配合 smart 播放策略。局域网播放时优先直连,服务器主要负责搜索、详情和必要的 manifest 兼容处理。 + +常用播放环境变量: + +```bash +NEXT_PUBLIC_PLAYBACK_PROXY_MODE=smart +PLAYBACK_HEALTH_CHECK=true +PLAYBACK_HEALTH_TIMEOUT_MS=8000 +PLAYBACK_FIRST_SEGMENT_TEST=true +PLAYBACK_PROXY_SEGMENTS=false +PLAYBACK_MAX_AUTO_SWITCH=3 +PLAYBACK_BAD_SOURCE_TTL=1800 +PLAYBACK_GOOD_SOURCE_TTL=86400 +``` + ## 🌍 环境变量 ### 基础配置 diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 313dcb0a4..9bb2a7834 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -372,11 +372,42 @@ interface DataSource { api: string; detail?: string; disabled?: boolean; + proxyStrategy?: 'auto' | 'direct' | 'proxy' | 'manifest-only'; + ua?: string; + referer?: string; + origin?: string; + headers?: Record; + timeoutMs?: number; + priority?: number; + regionHint?: string; + adult?: boolean; + disabledReason?: string; is_adult?: boolean; // 标记是否为成人资源 disable_ad_filter?: boolean; // 该源不走 m3u8 广告过滤代理 from: 'config' | 'custom'; } +const ADULT_SOURCE_PATTERN = /🔞|成人|福利|倫理|伦理|18\+|adult|porn|xxx/i; + +function isProbablyAdultSourceName(name?: string) { + return ADULT_SOURCE_PATTERN.test(name || ''); +} + +function getSourcePlaybackFields(item: Record) { + const fields: Record = {}; + if (item.proxyStrategy) fields.proxyStrategy = item.proxyStrategy; + if (item.ua) fields.ua = item.ua; + if (item.referer) fields.referer = item.referer; + if (item.origin) fields.origin = item.origin; + if (item.headers && typeof item.headers === 'object') + fields.headers = item.headers; + if (item.timeoutMs) fields.timeoutMs = Number(item.timeoutMs); + if (item.priority !== undefined) fields.priority = Number(item.priority); + if (item.regionHint) fields.regionHint = item.regionHint; + if (item.disabledReason) fields.disabledReason = item.disabledReason; + return fields; +} + // 直播源数据类型 interface LiveDataSource { name: string; @@ -2708,6 +2739,7 @@ const VideoSourceConfig = ({ detail: payload.detail || '', disabled: false, is_adult: payload.is_adult || false, + ...getSourcePlaybackFields(payload), from: 'custom', }; sources.push(newSource); @@ -2904,6 +2936,7 @@ const VideoSourceConfig = ({ api: newSource.api, detail: newSource.detail, is_adult: newSource.is_adult || false, + ...getSourcePlaybackFields(newSource), }); setNewSource({ name: '', @@ -3255,6 +3288,10 @@ const VideoSourceConfig = ({ if (source.is_adult) { apiSiteObj[source.key].is_adult = source.is_adult; } + Object.assign( + apiSiteObj[source.key], + getSourcePlaybackFields(source), + ); }); exportData = { @@ -3375,7 +3412,12 @@ const VideoSourceConfig = ({ name: item.name, api: item.api, detail: item.detail || '', - is_adult: item.is_adult || false, + is_adult: + item.is_adult || + item.adult || + isProbablyAdultSourceName(item.name), + adult: item.adult, + ...getSourcePlaybackFields(item), }); result.success++; @@ -3383,6 +3425,10 @@ const VideoSourceConfig = ({ name: item.name, key: item.key, status: 'success', + reason: + !item.is_adult && isProbablyAdultSourceName(item.name) + ? '检测到疑似成人源,已自动标记成人内容' + : undefined, }); } catch (err) { result.failed++; @@ -4900,7 +4946,11 @@ const ConfigFileComponent = ({ name: site.name, api: site.api, detail: site.detail, - is_adult: site.is_adult || false, + is_adult: + site.is_adult || + (site as any).adult || + isProbablyAdultSourceName(site.name), + ...(getSourcePlaybackFields(site as any) as Partial), from: 'config' as const, disabled: false, }), diff --git a/src/app/api/admin/source/route.ts b/src/app/api/admin/source/route.ts index 0cd983114..f603b6249 100644 --- a/src/app/api/admin/source/route.ts +++ b/src/app/api/admin/source/route.ts @@ -25,6 +25,23 @@ interface BaseBody { action?: Action; } +function pickSourcePlaybackFields(body: Record) { + const fields: Record = {}; + if (body.proxyStrategy) fields.proxyStrategy = body.proxyStrategy; + if (body.ua) fields.ua = body.ua; + if (body.referer) fields.referer = body.referer; + if (body.origin) fields.origin = body.origin; + if (body.headers && typeof body.headers === 'object') { + fields.headers = body.headers; + } + if (body.timeoutMs) fields.timeoutMs = Number(body.timeoutMs); + if (body.priority !== undefined) fields.priority = Number(body.priority); + if (body.regionHint) fields.regionHint = body.regionHint; + if (body.adult !== undefined) fields.adult = Boolean(body.adult); + if (body.disabledReason) fields.disabledReason = body.disabledReason; + return fields; +} + export async function POST(request: NextRequest) { // 预检 auth secret,缺失时在开发/Docker 环境下给出警告 getAuthSecret(); @@ -110,6 +127,7 @@ export async function POST(request: NextRequest) { api, detail, is_adult: is_adult || false, + ...pickSourcePlaybackFields(body), from: 'custom', disabled: false, }); diff --git a/src/app/api/playback/health/route.ts b/src/app/api/playback/health/route.ts new file mode 100644 index 000000000..1319ba820 --- /dev/null +++ b/src/app/api/playback/health/route.ts @@ -0,0 +1,90 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie, verifyApiAuth } from '@/lib/auth'; +import { getAvailableApiSites } from '@/lib/config'; +import { checkPlaybackHealth } from '@/lib/player/stream-health'; + +export const runtime = 'nodejs'; + +function getRequestOrigin(request: NextRequest): string { + const forwardedProto = request.headers + .get('x-forwarded-proto') + ?.split(',')[0] + .trim(); + const forwardedHost = request.headers + .get('x-forwarded-host') + ?.split(',')[0] + .trim(); + const protocol = forwardedProto || request.nextUrl.protocol.replace(':', ''); + const host = + forwardedHost || request.headers.get('host') || request.nextUrl.host; + return `${protocol}://${host}`; +} + +async function readPayload(request: NextRequest): Promise> { + if (request.method === 'POST') { + return (await request.json().catch(() => ({}))) as Record; + } + return Object.fromEntries(request.nextUrl.searchParams.entries()); +} + +async function findSourceConfig(request: NextRequest, source?: string) { + if (!source) return undefined; + const authResult = verifyApiAuth(request); + const authInfo = getAuthInfoFromCookie(request); + const username = + authInfo?.username || (authResult.isLocalMode ? '__local__' : ''); + const sites = await getAvailableApiSites(username); + return sites.find((site) => site.key === source); +} + +async function handle(request: NextRequest) { + const authResult = verifyApiAuth(request); + if (!authResult.isValid) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const payload = await readPayload(request); + const source = String(payload.source || payload.sourceKey || '').trim(); + const episodeUrl = String( + payload.episodeUrl || payload.url || payload.playUrl || '', + ).trim(); + const sourceConfig = await findSourceConfig(request, source); + + const result = await checkPlaybackHealth({ + source, + sourceConfig, + episodeUrl, + strategy: payload.strategy || 'smart', + requestOrigin: String(payload.requestOrigin || getRequestOrigin(request)), + userAgent: request.headers.get('user-agent') || undefined, + referer: payload.referer, + title: payload.title, + episodeIndex: + payload.episodeIndex === undefined + ? undefined + : Number(payload.episodeIndex), + }); + + return NextResponse.json(result, { + status: 200, + headers: { + 'Cache-Control': 'no-store', + 'X-DecoTV-Playback-Strategy': result.recommendedStrategy, + 'X-DecoTV-Source': source, + 'X-DecoTV-Error-Reason': result.reason || '', + 'X-DecoTV-Upstream-Status': String(result.manifest?.status || ''), + 'X-DecoTV-Upstream-Duration': String(result.timings?.manifestMs || ''), + }, + }); +} + +export async function GET(request: NextRequest) { + return handle(request); +} + +export async function POST(request: NextRequest) { + return handle(request); +} diff --git a/src/app/api/playback/report/route.ts b/src/app/api/playback/report/route.ts new file mode 100644 index 000000000..401dc086c --- /dev/null +++ b/src/app/api/playback/report/route.ts @@ -0,0 +1,75 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { verifyApiAuth } from '@/lib/auth'; + +export const runtime = 'nodejs'; + +interface ServerQualityRecord { + sourceKey: string; + successCount: number; + failCount: number; + lastSuccessAt?: number; + lastFailAt?: number; + avgTtfb?: number; + reasons: Record; +} + +const records = new Map(); + +function rollingAverage(current: number | undefined, next: number): number { + if (!Number.isFinite(next) || next <= 0) return current || 0; + if (!current || current <= 0) return Math.round(next); + return Math.round(current * 0.7 + next * 0.3); +} + +export async function POST(request: NextRequest) { + const authResult = verifyApiAuth(request); + if (!authResult.isValid) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = (await request.json().catch(() => ({}))) as Record; + const sourceKey = String(body.sourceKey || body.source || '').trim(); + if (!sourceKey) { + return NextResponse.json({ error: 'Missing sourceKey' }, { status: 400 }); + } + + const status = + body.status === 'success' || body.ok === true ? 'success' : 'failure'; + const now = Date.now(); + const record = + records.get(sourceKey) || + ({ + sourceKey, + successCount: 0, + failCount: 0, + reasons: {}, + } satisfies ServerQualityRecord); + + if (status === 'success') { + record.successCount += 1; + record.lastSuccessAt = now; + const ttfb = Number(body.ttfbMs || body.firstSegmentMs); + if (Number.isFinite(ttfb) && ttfb > 0) { + record.avgTtfb = rollingAverage(record.avgTtfb, ttfb); + } + } else { + record.failCount += 1; + record.lastFailAt = now; + const reason = String(body.reason || 'unknown'); + record.reasons[reason] = (record.reasons[reason] || 0) + 1; + } + + records.set(sourceKey, record); + return NextResponse.json( + { ok: true, record }, + { + headers: { + 'Cache-Control': 'no-store', + 'X-DecoTV-Source': sourceKey, + }, + }, + ); +} diff --git a/src/app/api/playback/resolve/route.ts b/src/app/api/playback/resolve/route.ts new file mode 100644 index 000000000..f825e0af3 --- /dev/null +++ b/src/app/api/playback/resolve/route.ts @@ -0,0 +1,89 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie, verifyApiAuth } from '@/lib/auth'; +import { getAvailableApiSites } from '@/lib/config'; +import { resolvePlayableUrl } from '@/lib/player/playback-resolver'; + +export const runtime = 'nodejs'; + +function getRequestOrigin(request: NextRequest): string { + const forwardedProto = request.headers + .get('x-forwarded-proto') + ?.split(',')[0] + .trim(); + const forwardedHost = request.headers + .get('x-forwarded-host') + ?.split(',')[0] + .trim(); + const protocol = forwardedProto || request.nextUrl.protocol.replace(':', ''); + const host = + forwardedHost || request.headers.get('host') || request.nextUrl.host; + return `${protocol}://${host}`; +} + +async function readPayload(request: NextRequest): Promise> { + if (request.method === 'POST') { + return (await request.json().catch(() => ({}))) as Record; + } + return Object.fromEntries(request.nextUrl.searchParams.entries()); +} + +async function findSourceConfig(request: NextRequest, source?: string) { + if (!source) return undefined; + const authResult = verifyApiAuth(request); + const authInfo = getAuthInfoFromCookie(request); + const username = + authInfo?.username || (authResult.isLocalMode ? '__local__' : ''); + const sites = await getAvailableApiSites(username); + return sites.find((site) => site.key === source); +} + +async function handle(request: NextRequest) { + const authResult = verifyApiAuth(request); + if (!authResult.isValid) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const payload = await readPayload(request); + const sourceKey = String(payload.sourceKey || payload.source || '').trim(); + const sourceConfig = await findSourceConfig(request, sourceKey); + const result = await resolvePlayableUrl({ + sourceKey, + sourceName: payload.sourceName || sourceConfig?.name, + sourceConfig, + episodeUrl: String(payload.episodeUrl || payload.url || ''), + title: payload.title, + episodeIndex: + payload.episodeIndex === undefined + ? undefined + : Number(payload.episodeIndex), + requestOrigin: String(payload.requestOrigin || getRequestOrigin(request)), + userAgent: request.headers.get('user-agent') || undefined, + referer: payload.referer, + proxyMode: payload.proxyMode, + strategy: payload.strategy, + }); + + return NextResponse.json(result, { + headers: { + 'Cache-Control': 'no-store', + 'X-DecoTV-Playback-Strategy': result.strategy, + 'X-DecoTV-Source': sourceKey, + 'X-DecoTV-Error-Reason': result.health?.reason || '', + 'X-DecoTV-Upstream-Status': String(result.health?.manifest?.status || ''), + 'X-DecoTV-Upstream-Duration': String( + result.health?.timings?.manifestMs || '', + ), + }, + }); +} + +export async function GET(request: NextRequest) { + return handle(request); +} + +export async function POST(request: NextRequest) { + return handle(request); +} diff --git a/src/app/api/proxy/m3u8-asset/route.ts b/src/app/api/proxy/m3u8-asset/route.ts index d2ff56ccc..9480361a9 100644 --- a/src/app/api/proxy/m3u8-asset/route.ts +++ b/src/app/api/proxy/m3u8-asset/route.ts @@ -2,18 +2,16 @@ import { NextResponse } from 'next/server'; +import { getConfig } from '@/lib/config'; import { verifyM3U8ProxySignature } from '@/lib/m3u8-proxy'; +import { buildPlaybackRequestHeaders } from '@/lib/player/stream-health'; import { fetchWithValidatedRedirects, - normalizeHeaderUrl, validateProxyTargetUrl, } from '@/lib/proxy-security'; export const runtime = 'nodejs'; -const DEFAULT_UA = - 'Mozilla/5.0 (Linux; Android 10; AndroidTV) AppleWebKit/537.36 ' + - '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; const FETCH_TIMEOUT_MS = 15000; const MAX_REDIRECTS = 3; @@ -26,13 +24,25 @@ function withCorsHeaders(headers: Headers) { ); headers.set( 'Access-Control-Expose-Headers', - 'Content-Length, Content-Range, Accept-Ranges, Content-Type', + [ + 'Content-Length', + 'Content-Range', + 'Accept-Ranges', + 'Content-Type', + 'X-DecoTV-Playback-Strategy', + 'X-DecoTV-Source', + 'X-DecoTV-Upstream-Status', + 'X-DecoTV-Upstream-Duration', + 'X-DecoTV-Proxy-Cache', + 'X-DecoTV-Error-Reason', + ].join(', '), ); } function jsonError(error: string, status: number, details?: string) { const headers = new Headers(); withCorsHeaders(headers); + headers.set('X-DecoTV-Error-Reason', error); return NextResponse.json({ error, details }, { status, headers }); } @@ -75,27 +85,23 @@ function copyHeader( } } -function resolveReferer( - decodedUrl: string, - request: Request, - explicit?: string, -) { - const sanitizedExplicitReferer = normalizeHeaderUrl(explicit); - const inboundReferer = normalizeHeaderUrl(request.headers.get('referer')); - let fallbackReferer: string | undefined; - try { - fallbackReferer = new URL(decodedUrl).origin + '/'; - } catch { - fallbackReferer = undefined; - } - return sanitizedExplicitReferer || fallbackReferer || inboundReferer; +async function findSourceConfig(source: string | null) { + if (!source) return undefined; + const config = await getConfig(); + return ( + config.SourceConfig?.find((item: any) => item.key === source) || + config.LiveConfig?.find((item: any) => item.key === source) + ); } async function handleAssetRequest(request: Request, method: 'GET' | 'HEAD') { + const startedAt = Date.now(); const { searchParams } = new URL(request.url); const url = searchParams.get('url'); const referer = searchParams.get('referer') || undefined; const kind = searchParams.get('kind'); + const source = + searchParams.get('source') || searchParams.get('decotv-source') || null; if (!url) { return jsonError('Missing url', 400); @@ -116,25 +122,15 @@ async function handleAssetRequest(request: Request, method: 'GET' | 'HEAD') { return jsonError(e?.message || 'Invalid url', 400); } - const refererToSend = resolveReferer(decodedUrl, request, referer); - const requestHeaders: Record = { - Accept: '*/*', - 'User-Agent': request.headers.get('user-agent') || DEFAULT_UA, - }; - - if (refererToSend) { - requestHeaders.Referer = refererToSend; - try { - requestHeaders.Origin = new URL(refererToSend).origin; - } catch { - // ignore - } - } - + const sourceConfig = await findSourceConfig(source); const range = request.headers.get('range'); - if (range) { - requestHeaders.Range = range; - } + const requestHeaders = buildPlaybackRequestHeaders({ + url: decodedUrl, + sourceConfig, + userAgent: request.headers.get('user-agent') || undefined, + referer, + range: range || undefined, + }); let upstream: Response; try { @@ -162,6 +158,11 @@ async function handleAssetRequest(request: Request, method: 'GET' | 'HEAD') { kind === 'key' ? 'public, max-age=3600' : 'no-cache', ); headers.set('Vary', 'Range'); + headers.set('X-DecoTV-Playback-Strategy', 'asset-proxy'); + headers.set('X-DecoTV-Source', source || ''); + headers.set('X-DecoTV-Upstream-Status', String(upstream.status)); + headers.set('X-DecoTV-Upstream-Duration', String(Date.now() - startedAt)); + headers.set('X-DecoTV-Proxy-Cache', 'miss'); copyHeader(upstream.headers, headers, 'content-type', 'Content-Type'); copyHeader(upstream.headers, headers, 'content-length', 'Content-Length'); copyHeader(upstream.headers, headers, 'content-range', 'Content-Range'); diff --git a/src/app/api/proxy/m3u8-filter/route.ts b/src/app/api/proxy/m3u8-filter/route.ts index 77778fdd2..f6263ef64 100644 --- a/src/app/api/proxy/m3u8-filter/route.ts +++ b/src/app/api/proxy/m3u8-filter/route.ts @@ -1,468 +1,48 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import { NextResponse } from 'next/server'; - -import { DEFAULT_AD_FILTER_CONFIG, filterM3U8 } from '@/lib/ad-filter'; -import { getConfig } from '@/lib/config'; -import { getBaseUrl, resolveUrl } from '@/lib/live'; -import { - signM3U8ProxyRequest, - verifyM3U8ProxySignature, -} from '@/lib/m3u8-proxy'; -import { - fetchWithValidatedRedirects, - normalizeHeaderUrl, - validateProxyTargetUrl, -} from '@/lib/proxy-security'; - export const runtime = 'nodejs'; -const DEFAULT_UA = - 'Mozilla/5.0 (Linux; Android 10; AndroidTV) AppleWebKit/537.36 ' + - '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; - -const FETCH_TIMEOUT_MS = 8000; -const MAX_PLAYLIST_BYTES = 2 * 1024 * 1024; -const MAX_REDIRECTS = 3; - -/** - * 解析广告过滤是否启用:admin 后台开关 > 环境变量 > 默认开。 - * 后台未配置时回落到 ENABLE_AD_FILTER;都没配置时默认 true。 - */ -async function isAdFilterEnabled(): Promise { - try { - const cfg = await getConfig(); - if (typeof cfg?.AdFilterConfig?.enabled === 'boolean') { - return cfg.AdFilterConfig.enabled; - } - } catch { - // ignore - fallback to env - } - const flag = process.env.ENABLE_AD_FILTER; - if (flag === undefined) return true; - return flag === 'true' || flag === '1'; -} - -/** - * 高级用户可通过环境变量重载广告判定阈值(不在管理 UI 暴露): - * AD_FILTER_MIN_DURATION / AD_FILTER_MAX_DURATION / AD_FILTER_MAX_SEGMENTS - */ -function buildFilterConfigFromEnv() { - const parseNum = (v: string | undefined, fallback: number): number => { - if (!v) return fallback; - const n = Number(v); - return Number.isFinite(n) && n > 0 ? n : fallback; - }; - return { - ...DEFAULT_AD_FILTER_CONFIG, - minAdDuration: parseNum( - process.env.AD_FILTER_MIN_DURATION, - DEFAULT_AD_FILTER_CONFIG.minAdDuration, - ), - maxAdDuration: parseNum( - process.env.AD_FILTER_MAX_DURATION, - DEFAULT_AD_FILTER_CONFIG.maxAdDuration, - ), - maxConsecutiveAdSegments: parseNum( - process.env.AD_FILTER_MAX_SEGMENTS, - DEFAULT_AD_FILTER_CONFIG.maxConsecutiveAdSegments, - ), - }; -} - -function buildProxyUrl( - request: Request, - upstreamUrl: string, - referer?: string, -): string { - const host = request.headers.get('host'); - const protocol = - request.headers.get('x-forwarded-proto') || - (() => { - try { - return new URL(request.url).protocol.replace(':', ''); - } catch { - return 'http'; - } - })(); - const signature = signM3U8ProxyRequest(upstreamUrl, referer); - let qs = `url=${encodeURIComponent(upstreamUrl)}`; - if (referer) qs += `&referer=${encodeURIComponent(referer)}`; - if (signature) qs += `&sig=${encodeURIComponent(signature)}`; - return `${protocol}://${host}/api/proxy/m3u8-filter?${qs}`; -} - -function shouldProxyMediaAssets(): boolean { - const flag = process.env.M3U8_DIRECT_MEDIA; - return !(flag === 'true' || flag === '1'); -} - -function inferAssetKind(upstreamUrl: string): 'segment' | 'key' | 'map' { - const pathname = (() => { - try { - return new URL(upstreamUrl).pathname.toLowerCase(); - } catch { - return upstreamUrl.toLowerCase(); - } - })(); - - if (pathname.endsWith('.key')) return 'key'; - if ( - pathname.endsWith('.mp4') || - pathname.endsWith('.m4s') || - pathname.endsWith('.m4v') - ) { - return 'map'; - } - return 'segment'; -} - -function buildAssetProxyUrl( - request: Request, - upstreamUrl: string, - referer?: string, - kind: 'segment' | 'key' | 'map' = inferAssetKind(upstreamUrl), -): string { - const host = request.headers.get('host'); - const protocol = - request.headers.get('x-forwarded-proto') || - (() => { - try { - return new URL(request.url).protocol.replace(':', ''); - } catch { - return 'http'; - } - })(); - const signature = signM3U8ProxyRequest(upstreamUrl, referer); - if (!signature) return upstreamUrl; - - let qs = `url=${encodeURIComponent(upstreamUrl)}&kind=${kind}`; - if (referer) qs += `&referer=${encodeURIComponent(referer)}`; - qs += `&sig=${encodeURIComponent(signature)}`; - return `${protocol}://${host}/api/proxy/m3u8-asset?${qs}`; -} - -function rewriteUriAttribute( - line: string, - baseUrl: string, - request: Request, - referer: string | undefined, - target: 'playlist' | 'asset', - kind?: 'segment' | 'key' | 'map', -): string { - return line.replace(/URI="([^"]+)"/, (match, uri) => { - const resolvedUrl = resolveUrl(baseUrl, uri); - const rewrittenUrl = - target === 'playlist' - ? buildProxyUrl(request, resolvedUrl, referer) - : shouldProxyMediaAssets() - ? buildAssetProxyUrl(request, resolvedUrl, referer, kind) - : resolvedUrl; - return match.replace(uri, rewrittenUrl); - }); -} - -/** - * 主播放列表(含 #EXT-X-STREAM-INF):把每个变体 URL 改写为再次走本路由, - * 这样客户端最终拿到的变体也会被过滤。 - */ -function rewriteMasterPlaylist( - content: string, - baseUrl: string, - request: Request, - referer?: string, -): string { - const lines = content.split('\n'); - const out: string[] = []; - - for (let i = 0; i < lines.length; i++) { - let line = lines[i]; - - if ( - line.trim().startsWith('#EXT-X-MEDIA:') || - line.trim().startsWith('#EXT-X-I-FRAME-STREAM-INF:') - ) { - line = rewriteUriAttribute(line, baseUrl, request, referer, 'playlist'); - } - - out.push(line); - - if (line.trim().startsWith('#EXT-X-STREAM-INF:')) { - // 下一行是变体 URL - if (i + 1 < lines.length) { - const variantLine = lines[i + 1].trim(); - if (variantLine && !variantLine.startsWith('#')) { - const absolute = resolveUrl(baseUrl, variantLine); - out.push(buildProxyUrl(request, absolute, referer)); - i++; - continue; - } - } - } - } - - return out.join('\n'); -} - -/** - * 变体播放列表:默认把分片、初始化片段和 key 都改写为同源代理。 - * Firefox 对跨域 HLS 分片更严格;直连上游 CDN 会导致播放失败或偶发卡顿。 - * 高级用户如需节省服务端带宽,可设置 M3U8_DIRECT_MEDIA=true 回退直连。 - */ -function rewriteVariantPlaylist( - content: string, - baseUrl: string, - request: Request, - referer?: string, -): string { - const lines = content.split('\n'); - const proxyMedia = shouldProxyMediaAssets(); - - return lines - .map((rawLine) => { - const line = rawLine.trimEnd(); - - if (line.startsWith('#EXT-X-MAP:')) { - return rewriteUriAttribute( - line, - baseUrl, - request, - referer, - 'asset', - 'map', - ); - } - - if (line.startsWith('#EXT-X-KEY:')) { - return rewriteUriAttribute( - line, - baseUrl, - request, - referer, - 'asset', - 'key', - ); - } - - if ( - line.startsWith('#EXT-X-PART:') || - line.startsWith('#EXT-X-PRELOAD-HINT:') - ) { - return rewriteUriAttribute( - line, - baseUrl, - request, - referer, - 'asset', - 'segment', - ); - } - - if (line && !line.startsWith('#')) { - const resolvedUrl = resolveUrl(baseUrl, line); - return proxyMedia - ? buildAssetProxyUrl(request, resolvedUrl, referer, 'segment') - : resolvedUrl; - } - - return line; - }) - .join('\n'); +function withCorsHeaders(headers: Headers) { + headers.set('Access-Control-Allow-Origin', '*'); + headers.set('Access-Control-Allow-Methods', 'GET, OPTIONS'); + headers.set( + 'Access-Control-Allow-Headers', + 'Content-Type, Range, Origin, Accept', + ); + headers.set( + 'Access-Control-Expose-Headers', + 'Content-Length, Content-Range, Accept-Ranges, Content-Type', + ); } -async function readTextWithLimit( - response: Response, - maxBytes: number, -): Promise { - const declaredLength = Number(response.headers.get('content-length')); - if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { - throw new Error('Playlist too large'); - } - - if (!response.body) return ''; - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let received = 0; - let text = ''; - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - received += value.byteLength; - if (received > maxBytes) { - await reader.cancel(); - throw new Error('Playlist too large'); - } - text += decoder.decode(value, { stream: true }); - } - - text += decoder.decode(); - return text; +export async function OPTIONS() { + const headers = new Headers(); + withCorsHeaders(headers); + return new Response(null, { status: 204, headers }); } export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const url = searchParams.get('url'); - - if (!url) { - return NextResponse.json({ error: 'Missing url' }, { status: 400 }); - } - - const decodedUrl = url.trim(); - if (!decodedUrl) { - return NextResponse.json({ error: 'Invalid url' }, { status: 400 }); - } - - const explicitReferer = searchParams.get('referer') || undefined; - if ( - !verifyM3U8ProxySignature( - decodedUrl, - explicitReferer, - searchParams.get('sig'), - ) - ) { - return NextResponse.json({ error: 'Invalid signature' }, { status: 403 }); - } - - try { - await validateProxyTargetUrl(decodedUrl); - } catch (e: any) { - return NextResponse.json( - { error: e?.message || 'Invalid url' }, - { status: 400 }, - ); - } - - const ua = request.headers.get('user-agent') || DEFAULT_UA; - - // 上游资源站常按 Referer/Origin 做白名单校验。优先级: - // 1. URL 显式参数 ?referer=...(客户端已知最准确的来源) - // 2. 上游 URL 自身的 origin(很多源站允许同源 Referer) - // 3. 入站请求自带的 Referer(最后兜底) - const sanitizedExplicitReferer = normalizeHeaderUrl(explicitReferer); - const inboundReferer = normalizeHeaderUrl(request.headers.get('referer')); - let fallbackReferer: string | undefined; - try { - fallbackReferer = new URL(decodedUrl).origin + '/'; - } catch { - fallbackReferer = undefined; - } - const refererToSend = - sanitizedExplicitReferer || fallbackReferer || inboundReferer; - - const upstreamHeaders: Record = { 'User-Agent': ua }; - if (refererToSend) { - upstreamHeaders['Referer'] = refererToSend; - try { - upstreamHeaders['Origin'] = new URL(refererToSend).origin; - } catch { - // ignore - } - } - - let upstream: Response; - try { - upstream = await fetchWithValidatedRedirects( - decodedUrl, - { - cache: 'no-store', - headers: upstreamHeaders, - }, - { timeoutMs: FETCH_TIMEOUT_MS, maxRedirects: MAX_REDIRECTS }, - ); - } catch (e: any) { - return NextResponse.json( - { error: 'Upstream fetch failed', details: e?.message || 'unknown' }, - { status: 502 }, - ); - } - - if (!upstream.ok) { - return NextResponse.json( - { error: 'Upstream returned non-OK', status: upstream.status }, - { status: 502 }, - ); - } - - let content: string; - try { - content = await readTextWithLimit(upstream, MAX_PLAYLIST_BYTES); - } catch (e: any) { - return NextResponse.json( - { error: e?.message || 'Unable to read playlist' }, - { status: 502 }, - ); - } - - if (!content.trimStart().startsWith('#EXTM3U')) { - return NextResponse.json( - { error: 'Upstream is not an m3u8 playlist' }, - { status: 502 }, - ); - } - // 跟随重定向后实际拿到内容的 URL,作为相对路径解析的 baseUrl - const finalUrl = upstream.url || decodedUrl; - const baseUrl = getBaseUrl(finalUrl); - - let body: string; - let adsRemoved = 0; - let adsDuration = 0; - - if (content.includes('#EXT-X-STREAM-INF')) { - // 把当前请求用的 referer 透传到变体 URL 的代理参数里, - // 否则下一跳又会因为没有 Referer 被上游拒 - body = rewriteMasterPlaylist(content, baseUrl, request, refererToSend); - } else { - const rewritten = rewriteVariantPlaylist( - content, - baseUrl, - request, - refererToSend, + const current = new URL(request.url); + const target = new URL('/api/proxy/m3u8', current.origin); + + for (const [key, value] of Array.from(current.searchParams.entries())) { + target.searchParams.append(key, value); + } + + if (!target.searchParams.has('proxyAssets')) { + const directMedia = process.env.M3U8_DIRECT_MEDIA; + const proxySegments = process.env.PLAYBACK_PROXY_SEGMENTS; + target.searchParams.set( + 'proxyAssets', + proxySegments === 'true' || proxySegments === '1' + ? '1' + : directMedia === 'false' + ? '1' + : '0', ); - // 调试/对照场景:?adfilter=false 让代理只做 referer 透传 + 相对路径绝对化, - // 不删任何广告段,方便客户端拿到原始时间轴 - const queryDisable = - searchParams.get('adfilter') === 'false' || - searchParams.get('adfilter') === '0'; - if ((await isAdFilterEnabled()) && !queryDisable) { - const result = filterM3U8(rewritten, buildFilterConfigFromEnv()); - body = result.filtered; - adsRemoved = result.adsRemoved; - adsDuration = result.adsDuration; - } else { - body = rewritten; - } } const headers = new Headers(); - headers.set( - 'Content-Type', - upstream.headers.get('Content-Type') || 'application/vnd.apple.mpegurl', - ); + withCorsHeaders(headers); headers.set('Cache-Control', 'no-cache'); - headers.set('Access-Control-Allow-Origin', '*'); - headers.set('Access-Control-Allow-Methods', 'GET, OPTIONS'); - headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Accept'); - headers.set( - 'Access-Control-Expose-Headers', - 'Content-Length, Content-Range, X-Ads-Removed, X-Ads-Duration', - ); - if (adsRemoved > 0) { - headers.set('X-Ads-Removed', String(adsRemoved)); - headers.set('X-Ads-Duration', adsDuration.toFixed(1)); - } - - return new Response(body, { status: 200, headers }); -} - -export async function OPTIONS() { - return new Response(null, { - status: 204, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Range, Accept', - }, - }); + headers.set('Location', target.toString()); + return new Response(null, { status: 307, headers }); } diff --git a/src/app/api/proxy/m3u8/route.ts b/src/app/api/proxy/m3u8/route.ts index 16365b26e..8152c9b50 100644 --- a/src/app/api/proxy/m3u8/route.ts +++ b/src/app/api/proxy/m3u8/route.ts @@ -1,13 +1,20 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - import { NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; -import { getBaseUrl, resolveUrl } from '@/lib/live'; +import { rewriteHlsManifest, verifyM3U8ProxySignature } from '@/lib/m3u8-proxy'; +import { isLikelyM3U8Content } from '@/lib/player/hls-utils'; +import { buildPlaybackRequestHeaders } from '@/lib/player/stream-health'; +import { + fetchWithValidatedRedirects, + validateProxyTargetUrl, +} from '@/lib/proxy-security'; export const runtime = 'nodejs'; -const M3U8_CONTENT_TYPE = 'application/vnd.apple.mpegurl'; +const M3U8_CONTENT_TYPE = 'application/vnd.apple.mpegurl; charset=utf-8'; +const FETCH_TIMEOUT_MS = 12000; +const MAX_PLAYLIST_BYTES = 2 * 1024 * 1024; +const MAX_REDIRECTS = 3; function withCorsHeaders(headers: Headers) { headers.set('Access-Control-Allow-Origin', '*'); @@ -18,24 +25,31 @@ function withCorsHeaders(headers: Headers) { ); headers.set( 'Access-Control-Expose-Headers', - 'Content-Length, Content-Range, Accept-Ranges, Content-Type', + [ + 'Content-Length', + 'Content-Range', + 'Accept-Ranges', + 'Content-Type', + 'X-DecoTV-Playback-Strategy', + 'X-DecoTV-Source', + 'X-DecoTV-Upstream-Status', + 'X-DecoTV-Upstream-Duration', + 'X-DecoTV-Error-Reason', + ].join(', '), ); } -function jsonError(error: string, status: number) { - const headers = new Headers(); +function jsonError( + error: string, + status: number, + extraHeaders?: Record, +) { + const headers = new Headers(extraHeaders); withCorsHeaders(headers); + headers.set('X-DecoTV-Error-Reason', error); return NextResponse.json({ error }, { status, headers }); } -function decodeUpstreamUrl(rawUrl: string) { - try { - return decodeURIComponent(rawUrl); - } catch { - return rawUrl; - } -} - function getRequestOrigin(req: Request) { const requestUrl = new URL(req.url); const forwardedProto = req.headers @@ -51,43 +65,59 @@ function getRequestOrigin(req: Request) { return `${protocol}://${host}`; } -function isLikelyM3U8Url(rawUrl: string) { - return /\.m3u8(?:$|[?#])/i.test(rawUrl) || /\/m3u8(?:$|[/?#])/i.test(rawUrl); +function shouldProxyAssets(searchParams: URLSearchParams): boolean { + const explicit = + searchParams.get('proxyAssets') || + searchParams.get('proxySegments') || + searchParams.get('assetProxy'); + if (explicit !== null) { + return explicit === '1' || explicit === 'true' || explicit === 'yes'; + } + + const env = process.env.PLAYBACK_PROXY_SEGMENTS; + return env === 'true' || env === '1'; } -function shouldRewriteAsM3U8( - contentType: string, - requestUrl: string, - responseUrl: string, -) { - const lowerContentType = contentType.toLowerCase(); +async function readTextWithLimit(response: Response): Promise { + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_PLAYLIST_BYTES) { + throw new Error('Playlist too large'); + } + + if (!response.body) return ''; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let received = 0; + let text = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > MAX_PLAYLIST_BYTES) { + await reader.cancel().catch(() => undefined); + throw new Error('Playlist too large'); + } + text += decoder.decode(value, { stream: true }); + } + + text += decoder.decode(); + return text; +} + +async function findSourceConfig(source: string | null) { + if (!source) return undefined; + const config = await getConfig(); return ( - lowerContentType.includes('mpegurl') || - lowerContentType.includes('application/vnd.apple.mpegurl') || - lowerContentType.includes('application/x-mpegurl') || - lowerContentType.includes('audio/mpegurl') || - lowerContentType.includes('octet-stream') || - isLikelyM3U8Url(requestUrl) || - isLikelyM3U8Url(responseUrl) + config.SourceConfig?.find((item) => item.key === source) || + config.LiveConfig?.find((item) => item.key === source) ); } -function buildProxyUrl( - req: Request, - path: 'm3u8' | 'segment' | 'key', - upstreamUrl: string, - source: string | null, - options?: { allowCORS?: boolean }, -) { - const proxyUrl = new URL(`/api/proxy/${path}`, getRequestOrigin(req)); - proxyUrl.searchParams.set('url', upstreamUrl); - if (source) { - proxyUrl.searchParams.set('decotv-source', source); - } - if (options?.allowCORS) { - proxyUrl.searchParams.set('allowCORS', 'true'); - } - return proxyUrl.toString(); +async function isLegacyLiveProxyAllowed(source: string | null) { + if (!source) return false; + const config = await getConfig(); + return Boolean(config.LiveConfig?.some((item) => item.key === source)); } export async function OPTIONS() { @@ -97,185 +127,135 @@ export async function OPTIONS() { } export async function GET(request: Request) { + const startedAt = Date.now(); const { searchParams } = new URL(request.url); - const url = searchParams.get('url'); - const allowCORS = searchParams.get('allowCORS') === 'true'; - const source = searchParams.get('decotv-source'); + const url = (searchParams.get('url') || '').trim(); + const source = + searchParams.get('source') || searchParams.get('decotv-source') || null; + const referer = searchParams.get('referer') || undefined; + const signature = searchParams.get('sig'); + const proxyAssets = shouldProxyAssets(searchParams); + const adFilter = + searchParams.get('adfilter') === 'false' || + searchParams.get('adfilter') === '0' + ? false + : true; + if (!url) { return jsonError('Missing url', 400); } - const config = await getConfig(); - const liveSource = config.LiveConfig?.find((s: any) => s.key === source); - if (!liveSource) { - return jsonError('Source not found', 404); + const hasValidSignature = verifyM3U8ProxySignature(url, referer, signature); + const legacyLiveAllowed = await isLegacyLiveProxyAllowed(source); + if (!hasValidSignature && !legacyLiveAllowed) { + return jsonError('Invalid signature', 403); } - const ua = liveSource.ua || 'AptvPlayer/1.4.10'; - - let response: Response | null = null; - let responseUsed = false; try { - const decodedUrl = decodeUpstreamUrl(url); - - response = await fetch(decodedUrl, { - cache: 'no-cache', - redirect: 'follow', - headers: { - 'User-Agent': ua, - }, - }); - - if (!response.ok) { - return jsonError('Failed to fetch m3u8', response.status || 502); - } - - const contentType = response.headers.get('Content-Type') || ''; - if (shouldRewriteAsM3U8(contentType, decodedUrl, response.url)) { - const m3u8Content = await response.text(); - responseUsed = true; + await validateProxyTargetUrl(url); + } catch (error) { + return jsonError( + error instanceof Error ? error.message : 'Invalid url', + 400, + ); + } - if (!m3u8Content.trimStart().startsWith('#EXTM3U')) { - return jsonError('Upstream is not an m3u8 playlist', 502); - } + const sourceConfig = await findSourceConfig(source); + const headers = buildPlaybackRequestHeaders({ + url, + sourceConfig, + userAgent: request.headers.get('user-agent') || undefined, + referer, + }); - const baseUrl = getBaseUrl(response.url || decodedUrl); - const modifiedContent = rewriteM3U8Content( - m3u8Content, - baseUrl, - request, - source, - allowCORS, - ); + let upstream: Response; + try { + upstream = await fetchWithValidatedRedirects( + url, + { + cache: 'no-store', + headers, + method: 'GET', + }, + { timeoutMs: FETCH_TIMEOUT_MS, maxRedirects: MAX_REDIRECTS }, + ); + } catch (error) { + return jsonError( + error instanceof Error ? error.message : 'Upstream fetch failed', + 502, + { + 'X-DecoTV-Playback-Strategy': proxyAssets + ? 'asset-proxy' + : 'manifest-proxy', + 'X-DecoTV-Source': source || '', + }, + ); + } - const headers = new Headers(); - headers.set( - 'Content-Type', - contentType.toLowerCase().includes('mpegurl') - ? contentType - : M3U8_CONTENT_TYPE, - ); - withCorsHeaders(headers); - headers.set('Cache-Control', 'no-cache'); - return new Response(modifiedContent, { headers }); - } + const responseHeadersBase = { + 'X-DecoTV-Playback-Strategy': proxyAssets + ? 'asset-proxy' + : 'manifest-proxy', + 'X-DecoTV-Source': source || '', + 'X-DecoTV-Upstream-Status': String(upstream.status), + 'X-DecoTV-Upstream-Duration': String(Date.now() - startedAt), + }; + + if (!upstream.ok) { + return jsonError('Upstream returned non-OK', 502, responseHeadersBase); + } - const headers = new Headers(); - headers.set( - 'Content-Type', - response.headers.get('Content-Type') || 'application/octet-stream', - ); + const contentType = upstream.headers.get('content-type') || ''; + if ( + !isLikelyM3U8Content(contentType, url) && + !isLikelyM3U8Content(contentType, upstream.url) + ) { + const headers = new Headers(responseHeadersBase); withCorsHeaders(headers); + headers.set('Content-Type', contentType || 'application/octet-stream'); headers.set('Cache-Control', 'no-cache'); - responseUsed = true; - - return new Response(response.body, { - status: response.status, + return new Response(upstream.body, { + status: upstream.status, headers, }); - } catch { - return jsonError('Failed to fetch m3u8', 500); - } finally { - if (response && !responseUsed) { - try { - response.body?.cancel(); - } catch { - // ignore - } - } } -} -function rewriteM3U8Content( - content: string, - baseUrl: string, - req: Request, - source: string | null, - allowCORS: boolean, -) { - const lines = content.split('\n'); - const rewrittenLines: string[] = []; - - for (let i = 0; i < lines.length; i++) { - let line = lines[i].trim(); - - if (line && !line.startsWith('#')) { - const resolvedUrl = resolveUrl(baseUrl, line); - const proxyUrl = allowCORS - ? resolvedUrl - : buildProxyUrl(req, 'segment', resolvedUrl, source); - rewrittenLines.push(proxyUrl); - continue; - } - - if (line.startsWith('#EXT-X-MAP:')) { - line = rewriteMapUri(line, baseUrl, req, source, allowCORS); - } - - if (line.startsWith('#EXT-X-KEY:')) { - line = rewriteKeyUri(line, baseUrl, req, source, allowCORS); - } - - if (line.startsWith('#EXT-X-STREAM-INF:')) { - rewrittenLines.push(line); - if (i + 1 < lines.length) { - i++; - const nextLine = lines[i].trim(); - if (nextLine && !nextLine.startsWith('#')) { - const resolvedUrl = resolveUrl(baseUrl, nextLine); - const proxyUrl = allowCORS - ? buildProxyUrl(req, 'm3u8', resolvedUrl, source, { - allowCORS: true, - }) - : buildProxyUrl(req, 'm3u8', resolvedUrl, source); - rewrittenLines.push(proxyUrl); - } else { - rewrittenLines.push(nextLine); - } - } - continue; - } - - rewrittenLines.push(line); + let content: string; + try { + content = await readTextWithLimit(upstream); + } catch (error) { + return jsonError( + error instanceof Error ? error.message : 'Unable to read playlist', + 502, + responseHeadersBase, + ); } - return rewrittenLines.join('\n'); -} - -function rewriteMapUri( - line: string, - baseUrl: string, - req: Request, - source: string | null, - allowCORS: boolean, -) { - const uriMatch = line.match(/URI="([^"]+)"/); - if (uriMatch) { - const originalUri = uriMatch[1]; - const resolvedUrl = resolveUrl(baseUrl, originalUri); - const proxyUrl = allowCORS - ? resolvedUrl - : buildProxyUrl(req, 'segment', resolvedUrl, source); - return line.replace(uriMatch[0], `URI="${proxyUrl}"`); + if (!content.trimStart().startsWith('#EXTM3U')) { + return jsonError('Upstream is not an m3u8 playlist', 502, { + ...responseHeadersBase, + 'X-DecoTV-Error-Reason': 'not-m3u8', + }); } - return line; -} -function rewriteKeyUri( - line: string, - baseUrl: string, - req: Request, - source: string | null, - allowCORS: boolean, -) { - const uriMatch = line.match(/URI="([^"]+)"/); - if (uriMatch) { - const originalUri = uriMatch[1]; - const resolvedUrl = resolveUrl(baseUrl, originalUri); - const proxyUrl = allowCORS - ? resolvedUrl - : buildProxyUrl(req, 'key', resolvedUrl, source); - return line.replace(uriMatch[0], `URI="${proxyUrl}"`); - } - return line; + const body = rewriteHlsManifest(content, { + requestOrigin: getRequestOrigin(request), + upstreamUrl: url, + finalUrl: upstream.url || url, + source, + referer, + proxyAssets, + adFilter, + }); + + const responseHeaders = new Headers(responseHeadersBase); + withCorsHeaders(responseHeaders); + responseHeaders.set('Content-Type', contentType || M3U8_CONTENT_TYPE); + responseHeaders.set('Cache-Control', 'no-cache, max-age=15'); + responseHeaders.set('X-DecoTV-Proxy-Cache', 'miss'); + + return new Response(body, { + status: 200, + headers: responseHeaders, + }); } diff --git a/src/app/api/server-config/route.ts b/src/app/api/server-config/route.ts index 245445ea5..9838eb0c6 100644 --- a/src/app/api/server-config/route.ts +++ b/src/app/api/server-config/route.ts @@ -51,6 +51,8 @@ export async function GET(request: NextRequest) { config.SiteConfig.SearchResultLoadMode === 'pagination' ? 'pagination' : 'infinite', + PlaybackProxyMode: process.env.NEXT_PUBLIC_PLAYBACK_PROXY_MODE || 'smart', + PlaybackMaxAutoSwitch: Number(process.env.PLAYBACK_MAX_AUTO_SWITCH) || 3, // 提供说明信息 AdultFilterInfo: { enabled: adultFilterEnabled, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e0d3d4c12..64061c9d9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -113,6 +113,8 @@ export default async function RootLayout({ PRIVATE_LIBRARY_ENABLED: privateLibraryEnabled, AUTH_MODE: getAuthMode(), PUBLIC_ALLOW_ADMIN: isPublicAdminAllowed(), + PLAYBACK_PROXY_MODE: process.env.NEXT_PUBLIC_PLAYBACK_PROXY_MODE || 'smart', + PLAYBACK_MAX_AUTO_SWITCH: Number(process.env.PLAYBACK_MAX_AUTO_SWITCH) || 3, }; return ( diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 489c8a1e9..2b4a3fe02 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -39,6 +39,19 @@ import { attachNextEpisodeCountdown, attachShortcutsOverlay, } from '@/lib/player/decoArtplayerTheme'; +import type { + PlaybackHealthResult, + PlaybackProxyMode, + PlaybackStrategy, + PlaybackStreamType, +} from '@/lib/player/hls-utils'; +import { + getSourceQualityWeight, + isSourceTemporarilyBad, + markSourcePlaybackFailure, + markSourcePlaybackSuccess, + updateSourceQualityFromHealth, +} from '@/lib/player/source-quality-store'; import { SearchResult } from '@/lib/types'; import { generateCacheKey, globalCache } from '@/lib/unified-cache'; import { @@ -626,6 +639,14 @@ function PlayPageClient() { const detailRef = useRef(detail); const currentEpisodeIndexRef = useRef(currentEpisodeIndex); const privateProgressSyncRef = useRef(0); + const playbackStrategyRef = useRef('direct'); + const playbackAttemptsRef = useRef({ + strategySwitches: 0, + sourceSwitches: 0, + lastRawUrl: '', + }); + const hlsErrorCountRef = useRef>({}); + const reportedPlaybackSuccessRef = useRef(''); // 同步最新值到 refs useEffect(() => { @@ -646,6 +667,31 @@ function PlayPageClient() { // 视频播放地址 const [videoUrl, setVideoUrl] = useState(''); + const [resolvedPlaybackUrl, setResolvedPlaybackUrl] = useState(''); + const [playbackStrategy, setPlaybackStrategy] = + useState('direct'); + const [playbackStreamType, setPlaybackStreamType] = + useState('unknown'); + const [playbackStatus, setPlaybackStatus] = useState< + | 'idle' + | 'resolving' + | 'testing' + | 'loading-manifest' + | 'loading-first-segment' + | 'playing' + | 'buffering' + | 'recovering' + | 'switching-strategy' + | 'switching-source' + | 'failed' + >('idle'); + const [playbackHealth, setPlaybackHealth] = + useState(null); + const [playbackErrorReason, setPlaybackErrorReason] = useState(''); + + useEffect(() => { + playbackStrategyRef.current = playbackStrategy; + }, [playbackStrategy]); // 总集数 const totalEpisodes = detail?.episodes?.length || 0; @@ -732,6 +778,68 @@ function PlayPageClient() { setToast({ show: true, message, type }); }; + const getMaxAutoSwitch = () => { + const runtimeValue = + typeof window !== 'undefined' + ? Number((window as any).RUNTIME_CONFIG?.PLAYBACK_MAX_AUTO_SWITCH) + : 0; + return Number.isFinite(runtimeValue) && runtimeValue > 0 ? runtimeValue : 3; + }; + + const getRuntimePlaybackProxyMode = (): PlaybackProxyMode => { + if (typeof window === 'undefined') return 'smart'; + const saved = localStorage.getItem('playbackProxyMode'); + const runtime = + saved || (window as any).RUNTIME_CONFIG?.PLAYBACK_PROXY_MODE || 'smart'; + return runtime === 'direct' || runtime === 'proxy' || runtime === 'off' + ? runtime + : 'smart'; + }; + + const getPlaybackStrategyLabel = (strategy: PlaybackStrategy) => { + switch (strategy) { + case 'direct': + return '直连'; + case 'manifest-proxy': + return '列表代理'; + case 'asset-proxy': + return '分片代理'; + case 'full-proxy': + return '完整代理'; + case 'native': + return '原生 HLS'; + default: + return strategy; + } + }; + + const getPlaybackStatusLabel = () => { + switch (playbackStatus) { + case 'resolving': + return '解析播放链路'; + case 'testing': + return '检测可播放性'; + case 'loading-manifest': + return '获取播放列表'; + case 'loading-first-segment': + return '等待首片/首帧'; + case 'buffering': + return '缓冲中'; + case 'recovering': + return '尝试恢复'; + case 'switching-strategy': + return '切换播放链路'; + case 'switching-source': + return '切换播放源'; + case 'failed': + return '播放失败'; + case 'playing': + return '播放中'; + default: + return '准备播放'; + } + }; + const isPrivateLibrarySource = (source: string) => source === 'private_library'; @@ -1417,6 +1525,10 @@ function PlayPageClient() { const testResult = await getVideoResolutionFromM3u8(episodeUrl, { timeoutMs: 9000, + source: source.source, + sourceName: source.source_name, + title: source.title, + episodeIndex: currentEpisodeIndexRef.current, }); return { source, testResult }; }), @@ -1462,12 +1574,10 @@ function PlayPageClient() { // 计算每个结果的评分 const resultsWithScore = successfulResults.map((result) => ({ ...result, - score: calculateSourceScore( - result.testResult, - maxSpeed, - minPing, - maxPing, - ), + score: + calculateSourceScore(result.testResult, maxSpeed, minPing, maxPing) + + (result.testResult.score ? result.testResult.score * 0.35 : 0) + + getSourceQualityWeight(`${result.source.source}-${result.source.id}`), })); // 按综合评分排序,选择最佳播放源 @@ -2531,6 +2641,256 @@ function PlayPageClient() { } }; + const resolveCurrentPlayback = useCallback( + async ( + preferredStrategy: PlaybackStrategy | 'smart' = 'smart', + proxyMode: PlaybackProxyMode = getRuntimePlaybackProxyMode(), + ) => { + if (!videoUrl) return; + + const requestRawUrl = videoUrl; + setPlaybackStatus('resolving'); + setPlaybackErrorReason(''); + setResolvedPlaybackUrl(''); + setIsVideoLoading(true); + setVideoLoadingStage('initing'); + + try { + const response = await fetch('/api/playback/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceKey: currentSourceRef.current, + sourceName: detailRef.current?.source_name, + episodeUrl: requestRawUrl, + title: videoTitleRef.current, + episodeIndex: currentEpisodeIndexRef.current, + requestOrigin: window.location.origin, + proxyMode, + strategy: preferredStrategy, + }), + }); + + if (!response.ok) { + throw new Error(`resolve-http-${response.status}`); + } + + const resolved = (await response.json()) as { + finalUrl: string; + originalUrl: string; + type: PlaybackStreamType; + strategy: PlaybackStrategy; + health?: PlaybackHealthResult; + }; + + setPlaybackStrategy(resolved.strategy); + playbackStrategyRef.current = resolved.strategy; + setPlaybackStreamType(resolved.type); + setPlaybackHealth(resolved.health || null); + setResolvedPlaybackUrl(resolved.finalUrl); + setPlaybackStatus( + resolved.type === 'hls' + ? 'loading-manifest' + : 'loading-first-segment', + ); + + if (resolved.type === 'flv') { + setPlaybackErrorReason('当前项目未启用 flv.js,无法直接播放 FLV'); + setPlaybackStatus('failed'); + } + + if (resolved.health) { + updateSourceQualityFromHealth( + `${currentSourceRef.current}-${currentIdRef.current}`, + resolved.health, + ); + } + } catch (error) { + const reason = + error instanceof Error ? error.message : 'playback-resolve-failed'; + setPlaybackErrorReason(reason); + setPlaybackStatus('failed'); + markSourcePlaybackFailure( + `${currentSourceRef.current}-${currentIdRef.current}`, + reason, + ); + setResolvedPlaybackUrl(requestRawUrl); + } + }, + [videoUrl], + ); + + const switchToNextPlayableSource = useCallback( + async (reason: string): Promise => { + const maxSwitch = getMaxAutoSwitch(); + if (playbackAttemptsRef.current.sourceSwitches >= maxSwitch) { + return false; + } + + if (!availableSources.length) return false; + + const currentIndex = availableSources.findIndex( + (source) => + source.source === currentSourceRef.current && + source.id === currentIdRef.current, + ); + + for (let offset = 1; offset <= availableSources.length; offset++) { + const candidate = + availableSources[ + (currentIndex + offset + availableSources.length) % + availableSources.length + ]; + if (!candidate) continue; + if ( + candidate.source === currentSourceRef.current && + candidate.id === currentIdRef.current + ) { + continue; + } + const candidateKey = `${candidate.source}-${candidate.id}`; + if (isSourceTemporarilyBad(candidateKey)) continue; + + playbackAttemptsRef.current.sourceSwitches += 1; + setPlaybackStatus('switching-source'); + setPlaybackErrorReason(reason); + showToast( + `正在尝试备用播放源 ${playbackAttemptsRef.current.sourceSwitches}/${maxSwitch}`, + 'info', + ); + const currentPlayTime = artPlayerRef.current?.currentTime || 0; + if (currentPlayTime > 1) { + resumeTimeRef.current = currentPlayTime; + } + await handleSourceChange( + candidate.source, + candidate.id, + candidate.title, + ); + return true; + } + + return false; + }, + [availableSources], + ); + + const handlePlaybackFailure = useCallback( + async (reason: string) => { + const sourceKey = `${currentSourceRef.current}-${currentIdRef.current}`; + markSourcePlaybackFailure(sourceKey, reason); + setPlaybackErrorReason(reason); + setPlaybackStatus('recovering'); + + const strategyOrder: PlaybackStrategy[] = [ + 'direct', + 'manifest-proxy', + 'asset-proxy', + ]; + const currentStrategy = playbackStrategyRef.current; + const currentIndex = Math.max(0, strategyOrder.indexOf(currentStrategy)); + const maxSwitch = getMaxAutoSwitch(); + + if ( + playbackAttemptsRef.current.strategySwitches < maxSwitch && + currentIndex < strategyOrder.length - 1 + ) { + const nextStrategy = strategyOrder[currentIndex + 1]; + playbackAttemptsRef.current.strategySwitches += 1; + const currentPlayTime = artPlayerRef.current?.currentTime || 0; + if (currentPlayTime > 1) { + resumeTimeRef.current = currentPlayTime; + } + setPlaybackStatus('switching-strategy'); + showToast( + `正在尝试备用播放链路 ${playbackAttemptsRef.current.strategySwitches}/${maxSwitch}`, + 'info', + ); + await resolveCurrentPlayback(nextStrategy, 'smart'); + return; + } + + const switched = await switchToNextPlayableSource(reason); + if (!switched) { + setPlaybackStatus('failed'); + setIsVideoLoading(false); + showToast('当前可用播放链路已尝试完毕,请手动换源', 'error'); + } + }, + [resolveCurrentPlayback, switchToNextPlayableSource], + ); + + const copyPlaybackDiagnostics = useCallback(async () => { + const diagnostics = { + title: videoTitleRef.current, + source: currentSourceRef.current, + sourceName: detailRef.current?.source_name, + episodeIndex: currentEpisodeIndexRef.current, + strategy: playbackStrategyRef.current, + status: playbackStatus, + urlType: playbackStreamType, + reason: playbackErrorReason, + rawUrl: videoUrl, + resolvedUrl: resolvedPlaybackUrl, + attempts: playbackAttemptsRef.current, + health: playbackHealth, + }; + + try { + await navigator.clipboard.writeText(JSON.stringify(diagnostics, null, 2)); + showToast('播放诊断信息已复制', 'success'); + } catch { + showToast('复制诊断信息失败', 'error'); + } + }, [ + playbackErrorReason, + playbackHealth, + playbackStatus, + playbackStreamType, + resolvedPlaybackUrl, + videoUrl, + ]); + + useEffect(() => { + if (!videoUrl) { + setResolvedPlaybackUrl(''); + setPlaybackStatus('idle'); + return; + } + + playbackAttemptsRef.current = { + strategySwitches: 0, + sourceSwitches: playbackAttemptsRef.current.sourceSwitches, + lastRawUrl: videoUrl, + }; + hlsErrorCountRef.current = {}; + reportedPlaybackSuccessRef.current = ''; + void resolveCurrentPlayback('smart'); + }, [resolveCurrentPlayback, videoUrl]); + + useEffect(() => { + if (!resolvedPlaybackUrl || playbackStatus === 'playing') { + return; + } + + const timeoutMs = + playbackStatus === 'buffering' + ? 30000 + : playbackStatus === 'loading-first-segment' + ? 20000 + : playbackStatus === 'loading-manifest' + ? 10000 + : 0; + + if (!timeoutMs) return; + + const timer = window.setTimeout(() => { + void handlePlaybackFailure(`${playbackStatus}-timeout`); + }, timeoutMs); + + return () => window.clearTimeout(timer); + }, [handlePlaybackFailure, playbackStatus, resolvedPlaybackUrl]); + // --------------------------------------------------------------------------- // 键盘快捷键 // --------------------------------------------------------------------------- @@ -2945,7 +3305,7 @@ function PlayPageClient() { if ( !Artplayer || !Hls || - !videoUrl || + !resolvedPlaybackUrl || loading || currentEpisodeIndex === null || !artRef.current @@ -2968,7 +3328,8 @@ function PlayPageClient() { setError('视频地址无效'); return; } - console.log(videoUrl); + const playbackUrl = resolvedPlaybackUrl; + console.log(playbackUrl); // 检测是否为WebKit浏览器 const isWebkit = @@ -2981,7 +3342,7 @@ function PlayPageClient() { const savedPlaybackRate = loadPlaybackRate(); lastPlaybackRateRef.current = savedPlaybackRate; - artPlayerRef.current.switch = videoUrl; + artPlayerRef.current.switch = playbackUrl; artPlayerRef.current.title = `${videoTitle} - 第${ currentEpisodeIndex + 1 }集`; @@ -2989,7 +3350,7 @@ function PlayPageClient() { if (artPlayerRef.current?.video) { ensureVideoSource( artPlayerRef.current.video as HTMLVideoElement, - videoUrl, + playbackUrl, ); } // 切换后立即恢复播放速率,防止被重置 @@ -3013,7 +3374,8 @@ function PlayPageClient() { artPlayerRef.current = new Artplayer({ container: artRef.current, - url: videoUrl, + url: playbackUrl, + type: playbackStreamType === 'hls' ? 'm3u8' : undefined, poster: videoCover, volume: 0.7, isLive: false, @@ -3079,6 +3441,17 @@ function PlayPageClient() { maxBufferLength: bufferConfig.maxBufferLength, backBufferLength: bufferConfig.backBufferLength, maxBufferSize: bufferConfig.maxBufferSize, + maxMaxBufferLength: 120, + maxBufferHole: 0.5, + manifestLoadingTimeOut: 10000, + manifestLoadingMaxRetry: 2, + levelLoadingTimeOut: 10000, + levelLoadingMaxRetry: 2, + fragLoadingTimeOut: 15000, + fragLoadingMaxRetry: 3, + startFragPrefetch: true, + testBandwidth: true, + capLevelToPlayerSize: true, /* 自定义loader */ loader: blockAdEnabledRef.current @@ -3175,9 +3548,90 @@ function PlayPageClient() { }, ); + hls.on(Hls.Events.MANIFEST_PARSED, () => { + const levels = hls.levels || []; + const preferred = levels + .map((level, index) => ({ level, index })) + .filter(({ level }) => { + const height = level.height || 0; + return height >= 720 && height <= 1080; + }) + .sort((left, right) => { + const heightDiff = + (right.level.height || 0) - (left.level.height || 0); + if (heightDiff !== 0) return heightDiff; + return (right.level.bitrate || 0) - (left.level.bitrate || 0); + })[0]; + + if (preferred) { + hls.currentLevel = preferred.index; + } + setPlaybackStatus('loading-first-segment'); + }); + + hls.on(Hls.Events.ERROR, function (_event: any, data: any) { + const reason = data?.details || data?.type || 'hls-error'; + hlsErrorCountRef.current[reason] = + (hlsErrorCountRef.current[reason] || 0) + 1; + + if (data?.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) { + setPlaybackStatus('buffering'); + try { + video.currentTime += 0.1; + hls.recoverMediaError(); + } catch { + // ignore local recovery failures + } + if (hlsErrorCountRef.current[reason] >= 3) { + void handlePlaybackFailure(reason); + } + return; + } + + if (!data?.fatal && hlsErrorCountRef.current[reason] < 3) { + return; + } + + switch (data?.type) { + case Hls.ErrorTypes.MEDIA_ERROR: + if (hlsErrorCountRef.current[reason] <= 2) { + setPlaybackStatus('recovering'); + hls.recoverMediaError(); + return; + } + break; + case Hls.ErrorTypes.NETWORK_ERROR: + if ( + ![ + Hls.ErrorDetails.MANIFEST_LOAD_ERROR, + Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT, + Hls.ErrorDetails.LEVEL_LOAD_ERROR, + Hls.ErrorDetails.LEVEL_LOAD_TIMEOUT, + Hls.ErrorDetails.FRAG_LOAD_ERROR, + Hls.ErrorDetails.FRAG_LOAD_TIMEOUT, + Hls.ErrorDetails.KEY_LOAD_ERROR, + Hls.ErrorDetails.KEY_LOAD_TIMEOUT, + ].includes(data?.details) + ) { + hls.startLoad(); + if (hlsErrorCountRef.current[reason] <= 2) return; + } + break; + default: + break; + } + + try { + hls.stopLoad(); + } catch { + // ignore + } + void handlePlaybackFailure(reason); + }); + hls.on(Hls.Events.ERROR, function (event: any, data: any) { console.error('HLS Error:', event, data); - if (data.fatal) { + if (data.fatal && data.__legacyRecovery === true) { switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: console.log('网络错误,尝试恢复...'); @@ -3456,6 +3910,16 @@ function PlayPageClient() { reportPrivateLibraryProgress('progress', true); }); + artPlayerRef.current.on('video:waiting', () => { + setPlaybackStatus('buffering'); + setIsVideoLoading(true); + }); + + artPlayerRef.current.on('video:playing', () => { + setPlaybackStatus('playing'); + setIsVideoLoading(false); + }); + artPlayerRef.current.on('video:ended', () => { releaseWakeLock(); reportPrivateLibraryProgress('played', true); @@ -3525,6 +3989,8 @@ function PlayPageClient() { // 隐藏换源加载状态 setIsVideoLoading(false); + setPlaybackStatus('playing'); + setPlaybackErrorReason(''); }); // 监听视频时间更新事件,实现跳过片头片尾 @@ -3590,6 +4056,7 @@ function PlayPageClient() { if (artPlayerRef.current.currentTime > 0) { return; } + void handlePlaybackFailure('artplayer-error'); }); // 监听视频播放结束事件,自动播放下一集 @@ -3616,6 +4083,32 @@ function PlayPageClient() { lastSaveTimeRef.current = now; } reportPrivateLibraryProgress('progress'); + + const currentTime = artPlayerRef.current?.currentTime || 0; + const successKey = `${currentSourceRef.current}-${currentIdRef.current}-${currentEpisodeIndexRef.current}`; + if ( + currentTime >= 60 && + reportedPlaybackSuccessRef.current !== successKey + ) { + reportedPlaybackSuccessRef.current = successKey; + markSourcePlaybackSuccess( + `${currentSourceRef.current}-${currentIdRef.current}`, + { + firstSegmentMs: playbackHealth?.firstSegment?.timeToFirstByteMs, + ttfbMs: playbackHealth?.firstSegment?.timeToFirstByteMs, + }, + ); + void fetch('/api/playback/report', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceKey: currentSourceRef.current, + ok: true, + firstSegmentMs: playbackHealth?.firstSegment?.timeToFirstByteMs, + ttfbMs: playbackHealth?.firstSegment?.timeToFirstByteMs, + }), + }).catch(() => undefined); + } }); artPlayerRef.current.on('pause', () => { @@ -3625,7 +4118,7 @@ function PlayPageClient() { if (artPlayerRef.current?.video) { ensureVideoSource( artPlayerRef.current.video as HTMLVideoElement, - videoUrl, + playbackUrl, ); } } catch (err) { @@ -3637,9 +4130,10 @@ function PlayPageClient() { Hls, blockAdEnabled, loading, + playbackStreamType, resetAudioTrackState, + resolvedPlaybackUrl, resolveActiveHlsTrackIndex, - videoUrl, ]); useEffect(() => { @@ -4197,14 +4691,103 @@ function PlayPageClient() { {/* 换源加载提示 - 使用播放器自带的加载动画 */} {isVideoLoading && ( -
-
+
+
{videoLoadingStage === 'sourceChanging' ? '切换播放源...' : '视频加载中...'} +
+
+ 阶段 + + {getPlaybackStatusLabel()} + +
+
+ + + {detail?.source_name || currentSource || 'unknown'} + +
+
+ 策略 + + {getPlaybackStrategyLabel(playbackStrategy)} + +
+
+ 尝试 + + 链路 {playbackAttemptsRef.current.strategySwitches}/ + {getMaxAutoSwitch()} · 换源{' '} + {playbackAttemptsRef.current.sourceSwitches}/ + {getMaxAutoSwitch()} + +
+
+ 首片 + + {playbackHealth?.firstSegment?.ok + ? `${playbackHealth.firstSegment.timeToFirstByteMs || 0}ms` + : '未通过'} + +
+ {playbackErrorReason && ( +
+ {playbackErrorReason} +
+ )} +
+
+ + + + + +
)} diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index 3d797eb8f..5b7b2b009 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -57,6 +57,9 @@ function compareDefaultSourceOrder(a: SourceSortItem, b: SourceSortItem) { } function compareLatencyMetrics(a: SourceSortItem, b: SourceSortItem) { + const scoreDiff = (b.videoInfo?.score || 0) - (a.videoInfo?.score || 0); + if (scoreDiff !== 0) return scoreDiff; + const pingDiff = (a.videoInfo?.pingTime || 0) - (b.videoInfo?.pingTime || 0); if (Math.abs(pingDiff) > RESPONSE_TIE_BREAKER_MS) return pingDiff; @@ -121,6 +124,38 @@ function getSpeedTextClassName(speedKBps?: number) { return 'text-orange-600 dark:text-orange-400'; } +function getStrategyLabel(strategy?: VideoInfo['recommendedStrategy']) { + switch (strategy) { + case 'direct': + return '直连'; + case 'manifest-proxy': + return '列表代理'; + case 'asset-proxy': + return '分片代理'; + default: + return '待判断'; + } +} + +function getHealthLine(videoInfo: VideoInfo) { + const health = videoInfo.health; + if (!health) return null; + const apiText = + videoInfo.pingTime > 0 ? formatResponseTime(videoInfo.pingTime) : '未测'; + const manifestText = health.manifest?.ok ? 'm3u8 成功' : 'm3u8 失败'; + const firstSegmentText = health.firstSegment?.ok + ? `首片 ${formatResponseTime(health.firstSegment.timeToFirstByteMs || 0)}` + : '首片失败'; + const corsText = health.cors?.checkedInBrowser + ? health.cors.manifestReadable + ? 'CORS 可直连' + : 'CORS 需代理' + : 'CORS 待浏览器确认'; + return `API ${apiText} · ${manifestText} · ${firstSegmentText} · ${corsText} · ${getStrategyLabel( + health.recommendedStrategy, + )}`; +} + interface EpisodeSelectorProps { /** 总集数 */ totalEpisodes: number; @@ -308,6 +343,10 @@ const EpisodeSelector: React.FC = ({ try { const info = await getVideoResolutionFromM3u8(episodeUrl, { timeoutMs: force ? 10000 : 8000, + source: source.source, + sourceName: source.source_name, + title: source.title, + episodeIndex: value - 1, }); if (isCurrentTestScope()) { setVideoInfoMap((prev) => new Map(prev).set(sourceKey, info)); @@ -336,7 +375,7 @@ const EpisodeSelector: React.FC = ({ } } }, - [getSourceKey, getTestEpisodeUrl], + [getSourceKey, getTestEpisodeUrl, value], ); const handleManualSpeedTest = useCallback(async () => { @@ -683,6 +722,20 @@ const EpisodeSelector: React.FC = ({ if (!videoInfo) return null; + if (videoInfo.grade && !videoInfo.hasError) { + return { + label: `评分 ${videoInfo.grade}`, + className: + videoInfo.grade === 'A' + ? 'text-green-600 dark:text-green-400' + : videoInfo.grade === 'B' + ? 'text-sky-600 dark:text-sky-300' + : videoInfo.grade === 'C' + ? 'text-yellow-600 dark:text-yellow-400' + : 'text-red-600 dark:text-red-400', + }; + } + if (videoInfo.hasError) { return { label: '检测失败', @@ -1068,8 +1121,17 @@ const EpisodeSelector: React.FC = ({ if (videoInfo) { if (!videoInfo.hasError) { + const healthLine = getHealthLine(videoInfo); return (
+ {healthLine && ( +
+ {healthLine} +
+ )} {videoInfo.pingTime > 0 && (
{ const [playerBufferMode, setPlayerBufferMode] = useState< 'standard' | 'enhanced' | 'max' >('standard'); + const [playbackProxyMode, setPlaybackProxyMode] = useState< + 'smart' | 'direct' | 'proxy' | 'off' + >('smart'); const [doubanDataSource, setDoubanDataSource] = useState( 'cmliussss-cdn-tencent', ); @@ -120,6 +123,29 @@ export const UserMenu: React.FC = () => { ]; // 豆瓣数据源选项 + const playbackProxyModeOptions = [ + { + value: 'smart' as const, + label: '智能', + description: '优先直连,失败后再尝试列表代理和分片代理', + }, + { + value: 'direct' as const, + label: '直连', + description: '浏览器直接访问视频地址,服务器不转发分片', + }, + { + value: 'proxy' as const, + label: '代理', + description: '强制使用服务端代理,仅适合带宽充足的自用部署', + }, + { + value: 'off' as const, + label: '关闭代理', + description: '禁用 DecoTV 播放代理,只使用原始 URL', + }, + ]; + const doubanDataSourceOptions = [ { value: 'auto', label: '智能自动(推荐)' }, { value: 'direct', label: '直连(服务器直接请求豆瓣)' }, @@ -265,6 +291,20 @@ export const UserMenu: React.FC = () => { ) { setPlayerBufferMode(savedBufferMode); } + + const savedPlaybackProxyMode = localStorage.getItem('playbackProxyMode'); + const runtimePlaybackProxyMode = + (window as any).RUNTIME_CONFIG?.PLAYBACK_PROXY_MODE || 'smart'; + const nextPlaybackProxyMode = + savedPlaybackProxyMode || runtimePlaybackProxyMode; + if ( + nextPlaybackProxyMode === 'smart' || + nextPlaybackProxyMode === 'direct' || + nextPlaybackProxyMode === 'proxy' || + nextPlaybackProxyMode === 'off' + ) { + setPlaybackProxyMode(nextPlaybackProxyMode); + } } }, []); @@ -465,6 +505,15 @@ export const UserMenu: React.FC = () => { } }; + const handlePlaybackProxyModeChange = ( + value: 'smart' | 'direct' | 'proxy' | 'off', + ) => { + setPlaybackProxyMode(value); + if (typeof window !== 'undefined') { + localStorage.setItem('playbackProxyMode', value); + } + }; + const handleDoubanDataSourceChange = (value: string) => { setDoubanDataSource(value); if (typeof window !== 'undefined') { @@ -557,6 +606,8 @@ export const UserMenu: React.FC = () => { (window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || ''; const defaultFluidSearch = (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false; + const defaultPlaybackProxyMode = + (window as any).RUNTIME_CONFIG?.PLAYBACK_PROXY_MODE || 'smart'; setDefaultAggregateSearch(true); setEnableOptimization(true); @@ -567,6 +618,7 @@ export const UserMenu: React.FC = () => { setDoubanImageProxyType(defaultDoubanImageProxyType); setDoubanImageProxyUrl(defaultDoubanImageProxyUrl); setPlayerBufferMode('standard'); + setPlaybackProxyMode(defaultPlaybackProxyMode); if (typeof window !== 'undefined') { localStorage.setItem('defaultAggregateSearch', JSON.stringify(true)); @@ -578,6 +630,7 @@ export const UserMenu: React.FC = () => { localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType); localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl); localStorage.setItem('playerBufferMode', 'standard'); + localStorage.setItem('playbackProxyMode', defaultPlaybackProxyMode); window.dispatchEvent(new CustomEvent('doubanProxyChanged')); } }; @@ -1272,6 +1325,44 @@ export const UserMenu: React.FC = () => {
+
+
+

+ 播放代理策略 +

+

+ 默认智能模式优先直连,只有必要时才走服务端代理 +

+
+
+ {playbackProxyModeOptions.map((option) => { + const isSelected = playbackProxyMode === option.value; + return ( + + ); + })} +
+
+ {/* 底部说明 */}

diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 4419cd11c..81a721f61 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -88,6 +88,16 @@ export interface AdminConfig { disabled?: boolean; is_adult?: boolean; disable_ad_filter?: boolean; + proxyStrategy?: 'auto' | 'direct' | 'proxy' | 'manifest-only'; + ua?: string; + referer?: string; + origin?: string; + headers?: Record; + timeoutMs?: number; + priority?: number; + regionHint?: string; + adult?: boolean; + disabledReason?: string; }[]; CustomCategories: { name?: string; diff --git a/src/lib/config.ts b/src/lib/config.ts index 14f25957b..e08f43187 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -11,8 +11,18 @@ export interface ApiSite { api: string; name: string; detail?: string; + disable_ad_filter?: boolean; + proxyStrategy?: 'auto' | 'direct' | 'proxy' | 'manifest-only'; + ua?: string; + referer?: string; + origin?: string; + headers?: Record; + timeoutMs?: number; + priority?: number; + regionHint?: string; + adult?: boolean; + disabledReason?: string; is_adult?: boolean; // 标记是否为成人资源 - disable_ad_filter?: boolean; // 该源不走 m3u8 广告过滤代理 } export interface LiveCfg { @@ -83,6 +93,87 @@ function normalizeForComparison(value: unknown): unknown { return value; } +function isProbablyAdultSourceName(name?: string): boolean { + const value = (name || '').toLowerCase(); + return ( + value.includes('🔞') || + value.includes('成人') || + value.includes('福利') || + value.includes('倫理') || + value.includes('伦理') || + value.includes('18+') || + value.includes('adult') || + value.includes('porn') + ); +} + +function sanitizeHeaders(headers: unknown): Record | undefined { + if (!headers || typeof headers !== 'object' || Array.isArray(headers)) { + return undefined; + } + + const entries = Object.entries(headers as Record) + .filter(([, value]) => typeof value === 'string' && value.trim()) + .map(([key, value]) => [key, String(value)] as const); + + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function normalizeSourceExtra>(source: T): T { + if ( + source.proxyStrategy && + !['auto', 'direct', 'proxy', 'manifest-only'].includes(source.proxyStrategy) + ) { + delete source.proxyStrategy; + } + + const timeoutMs = Number(source.timeoutMs); + if (Number.isFinite(timeoutMs) && timeoutMs > 0) { + source.timeoutMs = Math.round(timeoutMs); + } else { + delete source.timeoutMs; + } + + const priority = Number(source.priority); + if (Number.isFinite(priority)) { + source.priority = Math.round(priority); + } else { + delete source.priority; + } + + const headers = sanitizeHeaders(source.headers); + if (headers) { + source.headers = headers; + } else { + delete source.headers; + } + + if (source.adult !== undefined && source.is_adult === undefined) { + source.is_adult = Boolean(source.adult); + } + if (isProbablyAdultSourceName(source.name) && !source.is_adult) { + source.is_adult = true; + source.adult = true; + } + return source; +} + +function copySourceExtra(source: Partial): Partial { + return normalizeSourceExtra({ + disable_ad_filter: source.disable_ad_filter, + proxyStrategy: source.proxyStrategy, + ua: source.ua, + referer: source.referer, + origin: source.origin, + headers: sanitizeHeaders(source.headers), + timeoutMs: source.timeoutMs, + priority: source.priority, + regionHint: source.regionHint, + adult: source.adult, + disabledReason: source.disabledReason, + }); +} + function isConfigConsistent( expected: AdminConfig, actual: AdminConfig, @@ -154,6 +245,8 @@ export function refineConfig(adminConfig: AdminConfig): AdminConfig { existingSource.api = site.api; existingSource.detail = site.detail; existingSource.is_adult = site.is_adult || false; + Object.assign(existingSource, copySourceExtra(site)); + normalizeSourceExtra(existingSource); existingSource.from = 'config'; } else { // 如果不存在,创建新条目 @@ -162,7 +255,9 @@ export function refineConfig(adminConfig: AdminConfig): AdminConfig { name: site.name, api: site.api, detail: site.detail, - is_adult: site.is_adult || false, + is_adult: + site.is_adult || site.adult || isProbablyAdultSourceName(site.name), + ...copySourceExtra(site), from: 'config', disabled: false, }); @@ -354,7 +449,9 @@ async function getInitConfig( name: site.name, api: site.api, detail: site.detail, - is_adult: site.is_adult || false, + is_adult: + site.is_adult || site.adult || isProbablyAdultSourceName(site.name), + ...copySourceExtra(site), from: 'config', disabled: false, }); @@ -617,6 +714,7 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { return false; } seenSourceKeys.add(source.key); + normalizeSourceExtra(source); return true; }); @@ -694,6 +792,7 @@ export async function getAvailableApiSites(user?: string): Promise { detail: s.detail, is_adult: s.is_adult, disable_ad_filter: s.disable_ad_filter, + ...copySourceExtra(s), })); } @@ -721,6 +820,7 @@ export async function getAvailableApiSites(user?: string): Promise { detail: s.detail, is_adult: s.is_adult, disable_ad_filter: s.disable_ad_filter, + ...copySourceExtra(s), })); } } diff --git a/src/lib/m3u8-proxy.ts b/src/lib/m3u8-proxy.ts index 4ee606a34..ecbeeda62 100644 --- a/src/lib/m3u8-proxy.ts +++ b/src/lib/m3u8-proxy.ts @@ -1,6 +1,8 @@ import { createHmac, timingSafeEqual } from 'crypto'; +import { DEFAULT_AD_FILTER_CONFIG, filterM3U8 } from '@/lib/ad-filter'; import { getAuthSecret } from '@/lib/auth'; +import { getPlaylistBaseUrl, resolveUrl } from '@/lib/player/hls-utils'; function getM3U8ProxySecret(): string | null { const explicit = @@ -56,3 +58,225 @@ export function verifyM3U8ProxySignature( return timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); } + +export type HlsProxyAssetKind = 'segment' | 'key' | 'map' | 'playlist'; + +export interface BuildHlsProxyUrlOptions { + requestOrigin: string; + upstreamUrl: string; + source?: string | null; + referer?: string; + proxyAssets?: boolean; + adFilter?: boolean; +} + +export interface RewriteHlsManifestOptions { + requestOrigin: string; + upstreamUrl: string; + finalUrl?: string; + source?: string | null; + referer?: string; + proxyAssets: boolean; + adFilter?: boolean; +} + +export function inferHlsAssetKind(upstreamUrl: string): HlsProxyAssetKind { + const pathname = (() => { + try { + return new URL(upstreamUrl).pathname.toLowerCase(); + } catch { + return upstreamUrl.toLowerCase(); + } + })(); + + if (pathname.endsWith('.m3u8')) return 'playlist'; + if (pathname.endsWith('.key')) return 'key'; + if ( + pathname.endsWith('.mp4') || + pathname.endsWith('.m4s') || + pathname.endsWith('.m4v') || + pathname.endsWith('.init') + ) { + return 'map'; + } + return 'segment'; +} + +function appendCommonProxyParams( + proxyUrl: URL, + options: { + upstreamUrl: string; + source?: string | null; + referer?: string; + }, +) { + proxyUrl.searchParams.set('url', options.upstreamUrl); + if (options.source) { + proxyUrl.searchParams.set('source', options.source); + proxyUrl.searchParams.set('decotv-source', options.source); + } + if (options.referer) { + proxyUrl.searchParams.set('referer', options.referer); + } + const signature = signM3U8ProxyRequest(options.upstreamUrl, options.referer); + if (signature) { + proxyUrl.searchParams.set('sig', signature); + } +} + +export function buildHlsManifestProxyUrl( + options: BuildHlsProxyUrlOptions, +): string { + const proxyUrl = new URL('/api/proxy/m3u8', options.requestOrigin); + appendCommonProxyParams(proxyUrl, options); + proxyUrl.searchParams.set('proxyAssets', options.proxyAssets ? '1' : '0'); + if (options.adFilter === false) { + proxyUrl.searchParams.set('adfilter', 'false'); + } + return proxyUrl.toString(); +} + +export function buildHlsAssetProxyUrl(options: { + requestOrigin: string; + upstreamUrl: string; + source?: string | null; + referer?: string; + kind?: HlsProxyAssetKind; +}): string { + const proxyUrl = new URL('/api/proxy/m3u8-asset', options.requestOrigin); + appendCommonProxyParams(proxyUrl, options); + proxyUrl.searchParams.set( + 'kind', + options.kind || inferHlsAssetKind(options.upstreamUrl), + ); + return proxyUrl.toString(); +} + +function rewriteUriAttribute( + line: string, + baseUrl: string, + options: RewriteHlsManifestOptions, + target: 'playlist' | 'asset', + kind?: HlsProxyAssetKind, +): string { + return line.replace(/URI="([^"]+)"/g, (match, uri) => { + const resolved = resolveUrl(baseUrl, uri); + const nextUrl = + target === 'playlist' + ? buildHlsManifestProxyUrl({ + requestOrigin: options.requestOrigin, + upstreamUrl: resolved, + source: options.source, + referer: options.referer, + proxyAssets: options.proxyAssets, + adFilter: options.adFilter, + }) + : options.proxyAssets + ? buildHlsAssetProxyUrl({ + requestOrigin: options.requestOrigin, + upstreamUrl: resolved, + source: options.source, + referer: options.referer, + kind, + }) + : resolved; + return match.replace(uri, nextUrl); + }); +} + +export function rewriteHlsManifest( + content: string, + options: RewriteHlsManifestOptions, +): string { + const baseUrl = getPlaylistBaseUrl(options.finalUrl || options.upstreamUrl); + const lines = content.split('\n'); + const rewritten: string[] = []; + + for (let i = 0; i < lines.length; i++) { + let line = lines[i].trimEnd(); + const trimmed = line.trim(); + + if ( + trimmed.startsWith('#EXT-X-MEDIA:') || + trimmed.startsWith('#EXT-X-I-FRAME-STREAM-INF:') + ) { + line = rewriteUriAttribute(line, baseUrl, options, 'playlist'); + rewritten.push(line); + continue; + } + + if (trimmed.startsWith('#EXT-X-STREAM-INF:')) { + rewritten.push(line); + if (i + 1 < lines.length) { + i++; + const nextLine = lines[i].trim(); + if (nextLine && !nextLine.startsWith('#')) { + const resolved = resolveUrl(baseUrl, nextLine); + rewritten.push( + buildHlsManifestProxyUrl({ + requestOrigin: options.requestOrigin, + upstreamUrl: resolved, + source: options.source, + referer: options.referer, + proxyAssets: options.proxyAssets, + adFilter: options.adFilter, + }), + ); + } else { + rewritten.push(lines[i]); + } + } + continue; + } + + if (trimmed.startsWith('#EXT-X-KEY:')) { + line = rewriteUriAttribute(line, baseUrl, options, 'asset', 'key'); + rewritten.push(line); + continue; + } + + if (trimmed.startsWith('#EXT-X-MAP:')) { + line = rewriteUriAttribute(line, baseUrl, options, 'asset', 'map'); + rewritten.push(line); + continue; + } + + if ( + trimmed.startsWith('#EXT-X-PART:') || + trimmed.startsWith('#EXT-X-PRELOAD-HINT:') + ) { + line = rewriteUriAttribute(line, baseUrl, options, 'asset', 'segment'); + rewritten.push(line); + continue; + } + + if (trimmed && !trimmed.startsWith('#')) { + const resolved = resolveUrl(baseUrl, trimmed); + rewritten.push( + options.proxyAssets + ? buildHlsAssetProxyUrl({ + requestOrigin: options.requestOrigin, + upstreamUrl: resolved, + source: options.source, + referer: options.referer, + kind: 'segment', + }) + : resolved, + ); + continue; + } + + rewritten.push(line); + } + + const body = rewritten.join('\n'); + if ( + options.adFilter !== false && + !body.includes('#EXT-X-STREAM-INF') && + process.env.ENABLE_AD_FILTER !== 'false' && + process.env.ENABLE_AD_FILTER !== '0' + ) { + return filterM3U8(body, DEFAULT_AD_FILTER_CONFIG).filtered; + } + return body; +} diff --git a/src/lib/player/hls-utils.ts b/src/lib/player/hls-utils.ts new file mode 100644 index 000000000..9c8ab3092 --- /dev/null +++ b/src/lib/player/hls-utils.ts @@ -0,0 +1,374 @@ +export type PlaybackStreamType = 'hls' | 'mp4' | 'flv' | 'unknown'; + +export type PlaybackStrategy = + | 'direct' + | 'manifest-proxy' + | 'asset-proxy' + | 'full-proxy' + | 'native'; + +export type PlaybackProxyMode = 'smart' | 'direct' | 'proxy' | 'off'; + +export interface HlsVariant { + uri: string; + bandwidth?: number; + resolution?: string; + width?: number; + height?: number; + codecs?: string; +} + +export interface HlsKeyInfo { + method?: string; + uri?: string; + resolvedUri?: string; +} + +export interface HlsMapInfo { + uri?: string; + resolvedUri?: string; +} + +export interface ParsedHlsPlaylist { + isMaster: boolean; + variants: HlsVariant[]; + firstSegment?: string; + firstSegmentResolved?: string; + key?: HlsKeyInfo; + map?: HlsMapInfo; + targetDuration?: number; +} + +export interface PlaybackHealthResult { + ok: boolean; + playable: boolean; + recommendedStrategy: Exclude; + source?: string; + urlType: PlaybackStreamType; + manifest?: { + ok: boolean; + status?: number; + contentType?: string; + size?: number; + finalUrl?: string; + isMaster?: boolean; + selectedVariant?: HlsVariant; + targetDuration?: number; + reason?: string; + }; + key?: { + present: boolean; + ok?: boolean; + status?: number; + size?: number; + reason?: string; + }; + firstSegment?: { + ok: boolean; + status?: number; + contentType?: string; + contentLength?: number; + acceptRanges?: string; + timeToFirstByteMs?: number; + firstChunkBytes?: number; + reason?: string; + }; + cors?: { + manifestReadable?: boolean; + segmentReadable?: boolean; + checkedInBrowser?: boolean; + reason?: string; + }; + timings?: Record; + reason?: string; + suggestions: string[]; + warnings?: string[]; + nestedProxy?: boolean; + score?: number; + grade?: 'A' | 'B' | 'C' | 'D'; + debug?: Record; +} + +export const DEFAULT_ANDROID_TV_UA = + 'Mozilla/5.0 (Linux; Android 10; AndroidTV) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + +export function isHlsUrl(url: string): boolean { + if (!url) return false; + return ( + /\.m3u8(?:$|[?#])/i.test(url) || + /\/m3u8(?:$|[/?#-])/i.test(url) || + /\/api\/proxy\/m3u8(?:$|[/?#-])/i.test(url) + ); +} + +export function isMp4Url(url: string): boolean { + if (!url) return false; + return /\.mp4(?:$|[?#])/i.test(url); +} + +export function isFlvUrl(url: string): boolean { + if (!url) return false; + return /\.flv(?:$|[?#])/i.test(url); +} + +export function isLikelyM3U8Content( + contentType: string | null | undefined, + url = '', +): boolean { + const lower = (contentType || '').toLowerCase(); + return ( + lower.includes('mpegurl') || + lower.includes('vnd.apple.mpegurl') || + lower.includes('application/x-mpegurl') || + lower.includes('audio/mpegurl') || + lower.includes('octet-stream') || + isHlsUrl(url) + ); +} + +export function inferPlaybackType( + url: string, + contentType?: string | null, +): PlaybackStreamType { + const lowerType = (contentType || '').toLowerCase(); + if (isLikelyM3U8Content(contentType, url)) return 'hls'; + if (lowerType.includes('video/mp4') || isMp4Url(url)) return 'mp4'; + if (lowerType.includes('flv') || isFlvUrl(url)) return 'flv'; + return 'unknown'; +} + +export function sanitizeEpisodeUrl(rawUrl: string): string { + return (rawUrl || '') + .trim() + .replace(/^['"]+|['"]+$/g, '') + .replace(/\\u0026/gi, '&') + .replace(/&/gi, '&') + .replace(/\s+/g, ''); +} + +export function resolveUrl(baseUrl: string, value: string): string { + try { + return new URL(value, baseUrl).toString(); + } catch { + return value; + } +} + +export function getPlaylistBaseUrl(finalUrl: string): string { + try { + return new URL('.', finalUrl).toString(); + } catch { + const idx = finalUrl.lastIndexOf('/'); + return idx >= 0 ? finalUrl.slice(0, idx + 1) : finalUrl; + } +} + +export function unwrapDecoProxyUrl(rawUrl: string): { + url: string; + referer?: string; + wasProxy: boolean; + proxyPath?: string; +} { + const sanitized = sanitizeEpisodeUrl(rawUrl); + try { + const base = + typeof window !== 'undefined' ? window.location.origin : 'http://local'; + const parsed = new URL(sanitized, base); + const isDecoProxy = + parsed.pathname === '/api/proxy/m3u8' || + parsed.pathname === '/api/proxy/m3u8-filter' || + parsed.pathname === '/api/proxy/m3u8-asset' || + parsed.pathname === '/api/proxy/segment' || + parsed.pathname === '/api/proxy/key'; + const upstream = parsed.searchParams.get('url'); + if (isDecoProxy && upstream) { + return { + url: sanitizeEpisodeUrl(upstream), + referer: parsed.searchParams.get('referer') || undefined, + wasProxy: true, + proxyPath: parsed.pathname, + }; + } + } catch { + // Fall through to the sanitized input. + } + + return { url: sanitized, wasProxy: false }; +} + +export function hasSuspiciousUrlEncoding(rawUrl: string): string[] { + const warnings: string[] = []; + if (!rawUrl || rawUrl.trim() !== rawUrl) warnings.push('url-whitespace'); + if (/\\u0026/i.test(rawUrl)) warnings.push('escaped-ampersand'); + if (/\$/.test(rawUrl)) warnings.push('mac-cms-separator-leftover'); + if (/\s/.test(rawUrl.trim())) warnings.push('url-contains-space'); + return warnings; +} + +function parseAttributeList(value: string): Record { + const result: Record = {}; + const regex = /([A-Z0-9-]+)=("[^"]*"|[^,]*)/gi; + let match: RegExpExecArray | null; + while ((match = regex.exec(value)) !== null) { + result[match[1].toUpperCase()] = match[2].replace(/^"|"$/g, ''); + } + return result; +} + +function parseResolution(value?: string): { + resolution?: string; + width?: number; + height?: number; +} { + if (!value) return {}; + const match = value.match(/^(\d+)x(\d+)$/i); + if (!match) return { resolution: value }; + const width = Number(match[1]); + const height = Number(match[2]); + return { + resolution: value, + width: Number.isFinite(width) ? width : undefined, + height: Number.isFinite(height) ? height : undefined, + }; +} + +export function parseHlsPlaylist( + content: string, + baseUrl: string, +): ParsedHlsPlaylist { + const lines = content.split(/\r?\n/); + const variants: HlsVariant[] = []; + let firstSegment: string | undefined; + let key: HlsKeyInfo | undefined; + let map: HlsMapInfo | undefined; + let targetDuration: number | undefined; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + if (line.startsWith('#EXT-X-TARGETDURATION:')) { + const duration = Number(line.slice('#EXT-X-TARGETDURATION:'.length)); + if (Number.isFinite(duration)) targetDuration = duration; + continue; + } + + if (line.startsWith('#EXT-X-KEY:')) { + const attrs = parseAttributeList(line.slice('#EXT-X-KEY:'.length)); + key = { + method: attrs.METHOD, + uri: attrs.URI, + resolvedUri: attrs.URI ? resolveUrl(baseUrl, attrs.URI) : undefined, + }; + continue; + } + + if (line.startsWith('#EXT-X-MAP:')) { + const attrs = parseAttributeList(line.slice('#EXT-X-MAP:'.length)); + map = { + uri: attrs.URI, + resolvedUri: attrs.URI ? resolveUrl(baseUrl, attrs.URI) : undefined, + }; + continue; + } + + if (line.startsWith('#EXT-X-STREAM-INF:')) { + const attrs = parseAttributeList(line.slice('#EXT-X-STREAM-INF:'.length)); + let uri = ''; + for (let j = i + 1; j < lines.length; j++) { + const next = lines[j].trim(); + if (!next) continue; + if (!next.startsWith('#')) { + uri = next; + i = j; + } + break; + } + const resolution = parseResolution(attrs.RESOLUTION); + variants.push({ + uri: uri ? resolveUrl(baseUrl, uri) : '', + bandwidth: attrs.BANDWIDTH ? Number(attrs.BANDWIDTH) : undefined, + codecs: attrs.CODECS, + ...resolution, + }); + continue; + } + + if (!line.startsWith('#') && !firstSegment) { + firstSegment = line; + } + } + + return { + isMaster: variants.length > 0, + variants, + firstSegment, + firstSegmentResolved: firstSegment + ? resolveUrl(baseUrl, firstSegment) + : undefined, + key, + map, + targetDuration, + }; +} + +export function chooseDefaultHlsVariant( + variants: HlsVariant[], +): HlsVariant | undefined { + if (!variants.length) return undefined; + const scored = variants + .filter((variant) => variant.uri) + .map((variant, index) => { + const height = variant.height || 0; + const bandwidth = variant.bandwidth || 0; + const targetHeight = + height >= 720 && height <= 1080 + ? 100 + : height > 1080 + ? Math.max(20, 90 - (height - 1080) / 40) + : height / 12; + const bitratePenalty = bandwidth > 10_000_000 ? 15 : 0; + return { + variant, + index, + score: + targetHeight + Math.min(bandwidth / 500_000, 20) - bitratePenalty, + }; + }); + + scored.sort((a, b) => b.score - a.score || a.index - b.index); + return scored[0]?.variant || variants[0]; +} + +export function scorePlaybackHealth( + health: Pick< + PlaybackHealthResult, + 'manifest' | 'key' | 'firstSegment' | 'cors' | 'playable' | 'nestedProxy' + >, +): { score: number; grade: 'A' | 'B' | 'C' | 'D' } { + let score = 0; + + if (health.firstSegment?.ok) { + score += 40; + const ttfb = health.firstSegment.timeToFirstByteMs || 0; + if (ttfb > 0) { + if (ttfb <= 800) score += 30; + else if (ttfb <= 2000) score += 24; + else if (ttfb <= 5000) score += 16; + else score += 8; + } else { + score += 12; + } + } + + if (health.manifest?.ok) score += 15; + if (!health.key?.present || health.key.ok) score += 10; + if (health.cors?.manifestReadable) score += 5; + if (health.playable && score < 65) score = 65; + if (health.nestedProxy) score -= 8; + score = Math.max(0, Math.min(100, Math.round(score))); + + const grade = score >= 85 ? 'A' : score >= 70 ? 'B' : score >= 45 ? 'C' : 'D'; + return { score, grade }; +} diff --git a/src/lib/player/playback-resolver.ts b/src/lib/player/playback-resolver.ts new file mode 100644 index 000000000..b174fc17f --- /dev/null +++ b/src/lib/player/playback-resolver.ts @@ -0,0 +1,157 @@ +import type { ApiSite } from '@/lib/config'; +import { buildHlsManifestProxyUrl } from '@/lib/m3u8-proxy'; + +import { + inferPlaybackType, + PlaybackHealthResult, + PlaybackProxyMode, + PlaybackStrategy, + PlaybackStreamType, + sanitizeEpisodeUrl, + unwrapDecoProxyUrl, +} from './hls-utils'; +import { checkPlaybackHealth } from './stream-health'; + +export interface ResolvePlayableUrlInput { + sourceKey?: string; + sourceName?: string; + sourceConfig?: Partial; + episodeUrl: string; + title?: string; + episodeIndex?: number; + requestOrigin: string; + userAgent?: string; + referer?: string; + proxyMode?: PlaybackProxyMode; + strategy?: PlaybackStrategy | 'smart'; +} + +export interface ResolvePlayableUrlOutput { + finalUrl: string; + originalUrl: string; + type: PlaybackStreamType; + strategy: PlaybackStrategy; + health?: PlaybackHealthResult; + warnings: string[]; + debug: Record; +} + +function getDefaultProxyMode(): PlaybackProxyMode { + const raw = (process.env.NEXT_PUBLIC_PLAYBACK_PROXY_MODE || 'smart').trim(); + if (raw === 'direct' || raw === 'proxy' || raw === 'off') return raw; + return 'smart'; +} + +function healthChecksEnabled(): boolean { + const raw = process.env.PLAYBACK_HEALTH_CHECK; + return raw === undefined || raw === 'true' || raw === '1'; +} + +function strategyFromMode( + mode: PlaybackProxyMode, + preferred?: PlaybackStrategy | 'smart', +): PlaybackStrategy | 'smart' { + if (preferred && preferred !== 'smart') return preferred; + if (mode === 'direct' || mode === 'off') return 'direct'; + if (mode === 'proxy') return 'asset-proxy'; + return 'smart'; +} + +function buildFinalUrl(input: { + strategy: PlaybackStrategy; + originalUrl: string; + requestOrigin: string; + sourceKey?: string; + referer?: string; +}): string { + switch (input.strategy) { + case 'manifest-proxy': + return buildHlsManifestProxyUrl({ + requestOrigin: input.requestOrigin, + upstreamUrl: input.originalUrl, + source: input.sourceKey, + referer: input.referer, + proxyAssets: false, + }); + case 'asset-proxy': + case 'full-proxy': + return buildHlsManifestProxyUrl({ + requestOrigin: input.requestOrigin, + upstreamUrl: input.originalUrl, + source: input.sourceKey, + referer: input.referer, + proxyAssets: true, + }); + case 'native': + case 'direct': + default: + return input.originalUrl; + } +} + +export async function resolvePlayableUrl( + input: ResolvePlayableUrlInput, +): Promise { + const unwrapped = unwrapDecoProxyUrl(input.episodeUrl); + const originalUrl = sanitizeEpisodeUrl(unwrapped.url); + const type = inferPlaybackType(originalUrl); + const warnings: string[] = []; + if (unwrapped.wasProxy) warnings.push('unwrapped-existing-proxy'); + + const mode = input.proxyMode || getDefaultProxyMode(); + const requestedStrategy = strategyFromMode(mode, input.strategy); + let health: PlaybackHealthResult | undefined; + let strategy: PlaybackStrategy = + requestedStrategy === 'smart' ? 'direct' : requestedStrategy; + + if (type === 'flv') { + strategy = 'direct'; + warnings.push('flv-not-supported-by-default-player'); + } else if (type !== 'hls') { + strategy = mode === 'proxy' ? 'asset-proxy' : 'direct'; + } else if (mode === 'off' || mode === 'direct') { + strategy = 'direct'; + } else if (requestedStrategy !== 'smart') { + strategy = requestedStrategy; + } else if (healthChecksEnabled()) { + health = await checkPlaybackHealth({ + source: input.sourceKey, + sourceConfig: input.sourceConfig, + episodeUrl: originalUrl, + requestOrigin: input.requestOrigin, + userAgent: input.userAgent, + referer: input.referer || unwrapped.referer, + title: input.title, + episodeIndex: input.episodeIndex, + strategy: 'smart', + }); + strategy = health.recommendedStrategy; + } + + if (mode === 'proxy' && type === 'hls') { + strategy = 'asset-proxy'; + } + + const finalUrl = buildFinalUrl({ + strategy, + originalUrl, + requestOrigin: input.requestOrigin, + sourceKey: input.sourceKey, + referer: input.referer || unwrapped.referer, + }); + + return { + finalUrl, + originalUrl, + type, + strategy, + health, + warnings, + debug: { + proxyMode: mode, + requestedStrategy, + sourceName: input.sourceName, + unwrappedProxyPath: unwrapped.proxyPath, + }, + }; +} diff --git a/src/lib/player/source-quality-store.ts b/src/lib/player/source-quality-store.ts new file mode 100644 index 000000000..85d82667b --- /dev/null +++ b/src/lib/player/source-quality-store.ts @@ -0,0 +1,163 @@ +import type { PlaybackHealthResult } from './hls-utils'; + +export interface SourceQualityRecord { + sourceKey: string; + successCount: number; + failCount: number; + lastSuccessAt?: number; + lastFailAt?: number; + avgTtfb?: number; + avgFirstSegmentMs?: number; + badUntil?: number; + reasons: Record; +} + +const STORAGE_PREFIX = 'source_quality:'; +const BAD_SOURCE_TTL_MS = + Number(process.env.NEXT_PUBLIC_PLAYBACK_BAD_SOURCE_TTL || 1800) * 1000; +const GOOD_SOURCE_TTL_MS = + Number(process.env.NEXT_PUBLIC_PLAYBACK_GOOD_SOURCE_TTL || 86400) * 1000; + +function now() { + return Date.now(); +} + +function readRecord(sourceKey: string): SourceQualityRecord { + if (typeof window === 'undefined') { + return { + sourceKey, + successCount: 0, + failCount: 0, + reasons: {}, + }; + } + + try { + const raw = localStorage.getItem(`${STORAGE_PREFIX}${sourceKey}`); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + return { + sourceKey, + successCount: Number(parsed.successCount) || 0, + failCount: Number(parsed.failCount) || 0, + lastSuccessAt: parsed.lastSuccessAt, + lastFailAt: parsed.lastFailAt, + avgTtfb: parsed.avgTtfb, + avgFirstSegmentMs: parsed.avgFirstSegmentMs, + badUntil: parsed.badUntil, + reasons: parsed.reasons || {}, + }; + } + } catch { + // Ignore broken localStorage entries. + } + + return { + sourceKey, + successCount: 0, + failCount: 0, + reasons: {}, + }; +} + +function writeRecord(record: SourceQualityRecord) { + if (typeof window === 'undefined') return; + try { + localStorage.setItem( + `${STORAGE_PREFIX}${record.sourceKey}`, + JSON.stringify(record), + ); + } catch { + // Ignore quota/private mode failures. + } +} + +function rollingAverage(current: number | undefined, next: number): number { + if (!Number.isFinite(next) || next <= 0) return current || 0; + if (!current || current <= 0) return Math.round(next); + return Math.round(current * 0.7 + next * 0.3); +} + +export function getSourceQualityRecord(sourceKey: string): SourceQualityRecord { + return readRecord(sourceKey); +} + +export function isSourceTemporarilyBad(sourceKey: string): boolean { + const record = readRecord(sourceKey); + return Boolean(record.badUntil && record.badUntil > now()); +} + +export function getSourceQualityWeight(sourceKey: string): number { + const record = readRecord(sourceKey); + const currentTime = now(); + + if (record.badUntil && record.badUntil > currentTime) { + return -25; + } + + let weight = 0; + if ( + record.lastSuccessAt && + currentTime - record.lastSuccessAt < GOOD_SOURCE_TTL_MS + ) { + weight += Math.min(20, record.successCount * 4); + } + if ( + record.lastFailAt && + currentTime - record.lastFailAt < BAD_SOURCE_TTL_MS + ) { + weight -= Math.min(20, record.failCount * 5); + } + if (record.avgTtfb && record.avgTtfb > 0) { + if (record.avgTtfb <= 800) weight += 8; + else if (record.avgTtfb > 5000) weight -= 8; + } + return weight; +} + +export function markSourcePlaybackSuccess( + sourceKey: string, + metrics?: { ttfbMs?: number; firstSegmentMs?: number }, +) { + const record = readRecord(sourceKey); + record.successCount += 1; + record.lastSuccessAt = now(); + record.badUntil = undefined; + if (metrics?.ttfbMs) { + record.avgTtfb = rollingAverage(record.avgTtfb, metrics.ttfbMs); + } + if (metrics?.firstSegmentMs) { + record.avgFirstSegmentMs = rollingAverage( + record.avgFirstSegmentMs, + metrics.firstSegmentMs, + ); + } + writeRecord(record); +} + +export function markSourcePlaybackFailure(sourceKey: string, reason: string) { + const record = readRecord(sourceKey); + record.failCount += 1; + record.lastFailAt = now(); + const normalizedReason = reason || 'unknown'; + record.reasons[normalizedReason] = + (record.reasons[normalizedReason] || 0) + 1; + if (record.failCount >= Math.max(2, record.successCount + 2)) { + record.badUntil = now() + BAD_SOURCE_TTL_MS; + } + writeRecord(record); +} + +export function updateSourceQualityFromHealth( + sourceKey: string, + health: PlaybackHealthResult, +) { + if (health.playable) { + markSourcePlaybackSuccess(sourceKey, { + ttfbMs: health.firstSegment?.timeToFirstByteMs, + firstSegmentMs: health.firstSegment?.timeToFirstByteMs, + }); + } else { + markSourcePlaybackFailure(sourceKey, health.reason || 'health-failed'); + } +} diff --git a/src/lib/player/stream-health.ts b/src/lib/player/stream-health.ts new file mode 100644 index 000000000..4d85af46f --- /dev/null +++ b/src/lib/player/stream-health.ts @@ -0,0 +1,695 @@ +import type { ApiSite } from '@/lib/config'; +import { + fetchWithValidatedRedirects, + normalizeHeaderUrl, + validateProxyTargetUrl, +} from '@/lib/proxy-security'; + +import { + chooseDefaultHlsVariant, + DEFAULT_ANDROID_TV_UA, + getPlaylistBaseUrl, + hasSuspiciousUrlEncoding, + inferPlaybackType, + isHlsUrl, + parseHlsPlaylist, + PlaybackHealthResult, + PlaybackStrategy, + PlaybackStreamType, + resolveUrl, + sanitizeEpisodeUrl, + scorePlaybackHealth, + unwrapDecoProxyUrl, +} from './hls-utils'; + +export interface PlaybackHealthInput { + source?: string; + sourceConfig?: Partial; + episodeUrl: string; + strategy?: PlaybackStrategy | 'smart'; + requestOrigin?: string; + userAgent?: string; + referer?: string; + title?: string; + episodeIndex?: number; +} + +const MAX_PLAYLIST_BYTES = 2 * 1024 * 1024; +const MAX_REDIRECTS = 3; + +function getHealthTimeoutMs(sourceConfig?: Partial): number { + const sourceTimeout = Number(sourceConfig?.timeoutMs); + if (Number.isFinite(sourceTimeout) && sourceTimeout > 0) { + return sourceTimeout; + } + return Number(process.env.PLAYBACK_HEALTH_TIMEOUT_MS) || 8000; +} + +function shouldTestFirstSegment(): boolean { + const flag = process.env.PLAYBACK_FIRST_SEGMENT_TEST; + return flag === undefined || flag === 'true' || flag === '1'; +} + +function getHeaderRecord(headers?: unknown): Record { + if (!headers || typeof headers !== 'object' || Array.isArray(headers)) { + return {}; + } + return Object.fromEntries( + Object.entries(headers as Record) + .filter(([, value]) => typeof value === 'string' && value.trim()) + .map(([key, value]) => [key, String(value)]), + ); +} + +export function buildPlaybackRequestHeaders(input: { + url: string; + sourceConfig?: Partial; + userAgent?: string; + referer?: string; + range?: string; +}): Record { + const sourceHeaders = getHeaderRecord(input.sourceConfig?.headers); + const headers: Record = { + Accept: '*/*', + 'User-Agent': + sourceHeaders['User-Agent'] || + sourceHeaders['user-agent'] || + input.sourceConfig?.ua || + input.userAgent || + DEFAULT_ANDROID_TV_UA, + ...sourceHeaders, + }; + + const explicitReferer = normalizeHeaderUrl( + input.referer || input.sourceConfig?.referer, + ); + let inferredReferer: string | undefined; + try { + inferredReferer = new URL(input.url).origin + '/'; + } catch { + inferredReferer = undefined; + } + + const referer = explicitReferer || inferredReferer; + if (referer && !headers.Referer && !headers.referer) { + headers.Referer = referer; + } + + const explicitOrigin = + input.sourceConfig?.origin || + (() => { + try { + return referer ? new URL(referer).origin : undefined; + } catch { + return undefined; + } + })(); + if (explicitOrigin && !headers.Origin && !headers.origin) { + headers.Origin = explicitOrigin; + } + + if (input.range) { + headers.Range = input.range; + } + + return headers; +} + +async function readTextWithLimit( + response: Response, + maxBytes = MAX_PLAYLIST_BYTES, +): Promise { + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new Error('playlist-too-large'); + } + + if (!response.body) return ''; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let received = 0; + let text = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > maxBytes) { + await reader.cancel().catch(() => undefined); + throw new Error('playlist-too-large'); + } + text += decoder.decode(value, { stream: true }); + } + + text += decoder.decode(); + return text; +} + +function isHtmlPayload(text: string, contentType: string): boolean { + const trimmed = text.trimStart().slice(0, 100).toLowerCase(); + return ( + contentType.toLowerCase().includes('text/html') || + trimmed.startsWith('= 0 ? value : undefined; +} + +async function fetchPlaylist(input: { + url: string; + headers: Record; + timeoutMs: number; +}): Promise<{ + response: Response; + text: string; + durationMs: number; +}> { + await validateProxyTargetUrl(input.url); + const startedAt = Date.now(); + const response = await fetchWithValidatedRedirects( + input.url, + { + cache: 'no-store', + headers: input.headers, + method: 'GET', + }, + { timeoutMs: input.timeoutMs, maxRedirects: MAX_REDIRECTS }, + ); + const text = await readTextWithLimit(response); + return { + response, + text, + durationMs: Date.now() - startedAt, + }; +} + +async function testSmallAsset(input: { + url: string; + headers: Record; + timeoutMs: number; + kind: 'key' | 'segment' | 'map' | 'file'; +}): Promise<{ + ok: boolean; + status?: number; + contentType?: string; + contentLength?: number; + acceptRanges?: string; + timeToFirstByteMs?: number; + firstChunkBytes?: number; + reason?: string; +}> { + try { + await validateProxyTargetUrl(input.url); + } catch (error) { + return { + ok: false, + reason: error instanceof Error ? error.message : 'invalid-url', + }; + } + + const startedAt = Date.now(); + let response: Response; + try { + response = await fetchWithValidatedRedirects( + input.url, + { + cache: 'no-store', + headers: { + ...input.headers, + Range: input.kind === 'key' ? 'bytes=0-255' : 'bytes=0-1023', + }, + method: 'GET', + }, + { timeoutMs: input.timeoutMs, maxRedirects: MAX_REDIRECTS }, + ); + } catch (error) { + return { + ok: false, + timeToFirstByteMs: Date.now() - startedAt, + reason: error instanceof Error ? error.message : 'upstream-fetch-failed', + }; + } + + const timeToFirstByteMs = Date.now() - startedAt; + const status = response.status; + const contentType = response.headers.get('content-type') || undefined; + const contentLength = parseContentLength(response.headers); + const acceptRanges = response.headers.get('accept-ranges') || undefined; + + let firstChunkBytes = 0; + try { + const reader = response.body?.getReader(); + if (reader) { + const first = await reader.read(); + firstChunkBytes = first.value?.byteLength || 0; + await reader.cancel().catch(() => undefined); + } + } catch { + // The status/header information is still useful. + } + + const ok = response.ok || status === 206; + return { + ok, + status, + contentType, + contentLength, + acceptRanges, + timeToFirstByteMs, + firstChunkBytes, + reason: ok ? undefined : `HTTP ${status}`, + }; +} + +function detectNestedProxy(url: string): boolean { + try { + const host = new URL(url).hostname.toLowerCase(); + return ( + host.includes('pz.v88.qzz.io') || + host.includes('v88.qzz.io') || + host.includes('proxy') || + host.includes('jx.') + ); + } catch { + return false; + } +} + +function isMixedContentRisk(url: string, requestOrigin?: string): boolean { + if (!requestOrigin) return false; + try { + return ( + new URL(requestOrigin).protocol === 'https:' && + new URL(url).protocol === 'http:' + ); + } catch { + return false; + } +} + +function recommendedStrategyFor(input: { + url: string; + requestOrigin?: string; + manifestOk: boolean; + firstSegmentOk: boolean; + keyOk: boolean; + strategy?: PlaybackStrategy | 'smart'; +}): PlaybackHealthResult['recommendedStrategy'] { + if (input.strategy === 'direct') return 'direct'; + if (input.strategy === 'manifest-proxy') return 'manifest-proxy'; + if (input.strategy === 'asset-proxy' || input.strategy === 'full-proxy') { + return 'asset-proxy'; + } + if (isMixedContentRisk(input.url, input.requestOrigin)) return 'asset-proxy'; + if (!input.manifestOk) return 'manifest-proxy'; + if (!input.firstSegmentOk || !input.keyOk) return 'asset-proxy'; + return 'direct'; +} + +export async function checkPlaybackHealth( + input: PlaybackHealthInput, +): Promise { + const rawUrl = input.episodeUrl || ''; + const unwrapped = unwrapDecoProxyUrl(rawUrl); + const url = sanitizeEpisodeUrl(unwrapped.url); + const warnings = hasSuspiciousUrlEncoding(rawUrl); + const timings: Record = {}; + const sourceConfig = input.sourceConfig || {}; + const timeoutMs = getHealthTimeoutMs(sourceConfig); + const nestedProxy = detectNestedProxy(url); + if (nestedProxy) warnings.push('nested-proxy'); + if (isMixedContentRisk(url, input.requestOrigin)) + warnings.push('mixed-content'); + + if (!url) { + return { + ok: false, + playable: false, + recommendedStrategy: 'manifest-proxy', + source: input.source, + urlType: 'unknown', + reason: 'empty-url', + suggestions: ['播放地址为空,请换源或重新获取详情。'], + warnings, + nestedProxy, + }; + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return { + ok: false, + playable: false, + recommendedStrategy: 'manifest-proxy', + source: input.source, + urlType: 'unknown', + reason: 'invalid-url', + suggestions: ['播放地址不是合法 URL。'], + warnings, + nestedProxy, + }; + } + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return { + ok: false, + playable: false, + recommendedStrategy: 'manifest-proxy', + source: input.source, + urlType: 'unknown', + reason: 'unsupported-protocol', + suggestions: ['仅支持 http/https 播放地址。'], + warnings, + nestedProxy, + }; + } + + const headers = buildPlaybackRequestHeaders({ + url, + sourceConfig, + userAgent: input.userAgent, + referer: input.referer || unwrapped.referer, + }); + const urlType = inferPlaybackType(url); + + if (urlType !== 'hls' && !isHlsUrl(url)) { + const firstSegment = await testSmallAsset({ + url, + headers, + timeoutMs, + kind: urlType === 'mp4' ? 'file' : 'segment', + }); + const playable = firstSegment.ok && urlType !== 'flv'; + const base: PlaybackHealthResult = { + ok: playable, + playable, + recommendedStrategy: recommendedStrategyFor({ + url, + requestOrigin: input.requestOrigin, + manifestOk: true, + firstSegmentOk: firstSegment.ok, + keyOk: true, + strategy: input.strategy, + }), + source: input.source, + urlType, + firstSegment, + cors: { + checkedInBrowser: false, + reason: 'server-side-only', + }, + timings, + reason: playable + ? undefined + : urlType === 'flv' + ? 'flv-not-supported' + : firstSegment.reason || 'asset-failed', + suggestions: + urlType === 'flv' + ? ['当前播放器未启用 flv.js,建议换 mp4/m3u8 源。'] + : firstSegment.ok + ? [] + : ['媒体文件首段不可访问,请换源或尝试代理。'], + warnings, + nestedProxy, + }; + const scored = scorePlaybackHealth(base); + return { ...base, ...scored }; + } + + let manifestResponse: Response; + let manifestText: string; + let manifestDurationMs = 0; + try { + const manifest = await fetchPlaylist({ url, headers, timeoutMs }); + manifestResponse = manifest.response; + manifestText = manifest.text; + manifestDurationMs = manifest.durationMs; + timings.manifestMs = manifestDurationMs; + } catch (error) { + const reason = + error instanceof Error ? error.message : 'manifest-fetch-failed'; + return { + ok: false, + playable: false, + recommendedStrategy: 'manifest-proxy', + source: input.source, + urlType: 'hls', + manifest: { + ok: false, + reason, + }, + cors: { + checkedInBrowser: false, + reason: 'server-side-only', + }, + timings, + reason, + suggestions: ['m3u8 列表无法获取,优先尝试列表代理或换源。'], + warnings, + nestedProxy, + ...scorePlaybackHealth({ + playable: false, + manifest: { ok: false }, + firstSegment: { ok: false }, + nestedProxy, + }), + }; + } + + const manifestStatus = manifestResponse.status; + const manifestContentType = + manifestResponse.headers.get('content-type') || ''; + const manifestFinalUrl = manifestResponse.url || url; + const manifestSize = new Blob([manifestText]).size; + + if (!manifestResponse.ok) { + const base: PlaybackHealthResult = { + ok: false, + playable: false, + recommendedStrategy: 'manifest-proxy', + source: input.source, + urlType: 'hls', + manifest: { + ok: false, + status: manifestStatus, + contentType: manifestContentType, + size: manifestSize, + finalUrl: manifestFinalUrl, + reason: `HTTP ${manifestStatus}`, + }, + cors: { checkedInBrowser: false, reason: 'server-side-only' }, + timings, + reason: `manifest-http-${manifestStatus}`, + suggestions: ['m3u8 返回非 2xx 状态,建议尝试代理或换源。'], + warnings, + nestedProxy, + }; + return { ...base, ...scorePlaybackHealth(base) }; + } + + if (isHtmlPayload(manifestText, manifestContentType)) { + const base: PlaybackHealthResult = { + ok: false, + playable: false, + recommendedStrategy: 'manifest-proxy', + source: input.source, + urlType: 'hls', + manifest: { + ok: false, + status: manifestStatus, + contentType: manifestContentType, + size: manifestSize, + finalUrl: manifestFinalUrl, + reason: 'html-instead-of-m3u8', + }, + cors: { checkedInBrowser: false, reason: 'server-side-only' }, + timings, + reason: 'html-instead-of-m3u8', + suggestions: ['上游返回 HTML 而不是 m3u8,通常是防盗链或源失效。'], + warnings, + nestedProxy, + }; + return { ...base, ...scorePlaybackHealth(base) }; + } + + if (!manifestText.trimStart().startsWith('#EXTM3U')) { + const base: PlaybackHealthResult = { + ok: false, + playable: false, + recommendedStrategy: 'manifest-proxy', + source: input.source, + urlType: 'hls', + manifest: { + ok: false, + status: manifestStatus, + contentType: manifestContentType, + size: manifestSize, + finalUrl: manifestFinalUrl, + reason: 'not-m3u8', + }, + cors: { checkedInBrowser: false, reason: 'server-side-only' }, + timings, + reason: 'not-m3u8', + suggestions: ['播放列表格式不正确,请换源。'], + warnings, + nestedProxy, + }; + return { ...base, ...scorePlaybackHealth(base) }; + } + + let playlist = parseHlsPlaylist( + manifestText, + getPlaylistBaseUrl(manifestFinalUrl), + ); + let selectedVariant = chooseDefaultHlsVariant(playlist.variants); + + if (playlist.isMaster && selectedVariant?.uri) { + try { + const media = await fetchPlaylist({ + url: selectedVariant.uri, + headers, + timeoutMs, + }); + timings.mediaManifestMs = media.durationMs; + const mediaBaseUrl = getPlaylistBaseUrl( + media.response.url || selectedVariant.uri, + ); + const mediaText = media.text; + if (media.response.ok && mediaText.trimStart().startsWith('#EXTM3U')) { + playlist = parseHlsPlaylist(mediaText, mediaBaseUrl); + } + } catch (error) { + const reason = + error instanceof Error ? error.message : 'media-playlist-fetch-failed'; + const base: PlaybackHealthResult = { + ok: false, + playable: false, + recommendedStrategy: 'manifest-proxy', + source: input.source, + urlType: 'hls', + manifest: { + ok: false, + status: manifestStatus, + contentType: manifestContentType, + size: manifestSize, + finalUrl: manifestFinalUrl, + isMaster: true, + selectedVariant, + reason, + }, + cors: { checkedInBrowser: false, reason: 'server-side-only' }, + timings, + reason, + suggestions: ['master playlist 可访问,但清晰度子列表不可访问。'], + warnings, + nestedProxy, + }; + return { ...base, ...scorePlaybackHealth(base) }; + } + } + + let keyResult: PlaybackHealthResult['key'] = { + present: Boolean(playlist.key?.resolvedUri), + }; + if (playlist.key?.resolvedUri) { + const keyStartedAt = Date.now(); + const keyAsset = await testSmallAsset({ + url: playlist.key.resolvedUri, + headers, + timeoutMs, + kind: 'key', + }); + timings.keyMs = Date.now() - keyStartedAt; + keyResult = { + present: true, + ok: keyAsset.ok, + status: keyAsset.status, + size: keyAsset.firstChunkBytes || keyAsset.contentLength, + reason: keyAsset.reason, + }; + } + + let firstSegment: PlaybackHealthResult['firstSegment'] = { + ok: Boolean(playlist.firstSegmentResolved), + }; + if (playlist.firstSegmentResolved && shouldTestFirstSegment()) { + firstSegment = await testSmallAsset({ + url: playlist.firstSegmentResolved, + headers, + timeoutMs, + kind: 'segment', + }); + timings.firstSegmentMs = firstSegment.timeToFirstByteMs || 0; + } else if (!playlist.firstSegmentResolved) { + firstSegment = { + ok: false, + reason: 'first-segment-missing', + }; + } + + const manifestOk = true; + const keyOk = !keyResult.present || Boolean(keyResult.ok); + const playable = manifestOk && firstSegment.ok && keyOk; + const recommendedStrategy = recommendedStrategyFor({ + url, + requestOrigin: input.requestOrigin, + manifestOk, + firstSegmentOk: Boolean(firstSegment.ok), + keyOk, + strategy: input.strategy, + }); + + const base: PlaybackHealthResult = { + ok: manifestOk, + playable, + recommendedStrategy, + source: input.source, + urlType: 'hls' as PlaybackStreamType, + manifest: { + ok: true, + status: manifestStatus, + contentType: manifestContentType, + size: manifestSize, + finalUrl: manifestFinalUrl, + isMaster: Boolean(selectedVariant), + selectedVariant, + targetDuration: playlist.targetDuration, + }, + key: keyResult, + firstSegment, + cors: { + checkedInBrowser: false, + reason: 'server-side-only', + }, + timings, + reason: playable + ? undefined + : !keyOk + ? 'key-failed' + : firstSegment.reason || 'segment-failed', + suggestions: playable + ? [] + : !keyOk + ? ['AES key 无法获取,建议尝试分片代理或换源。'] + : ['首个分片无法获取,建议尝试分片代理或换源。'], + warnings, + nestedProxy, + debug: { + checkedUrl: url, + unwrappedProxy: unwrapped.wasProxy ? unwrapped.proxyPath : undefined, + manifestDurationMs, + firstSegmentHost: playlist.firstSegmentResolved + ? new URL(resolveUrl(url, playlist.firstSegmentResolved)).hostname + : undefined, + }, + }; + + return { ...base, ...scorePlaybackHealth(base) }; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 4ce5a6410..67e62b2f5 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -3,6 +3,7 @@ import he from 'he'; import Hls from 'hls.js'; import { resolveImageUrl } from './image-url'; +import type { PlaybackHealthResult } from './player/hls-utils'; export function processImageUrl(originalUrl: string): string { return resolveImageUrl(originalUrl, { wsrvWidth: 256 }); @@ -20,6 +21,10 @@ export interface VideoSourceTestResult { message?: string; playable?: boolean; testedAt?: number; + health?: PlaybackHealthResult; + recommendedStrategy?: PlaybackHealthResult['recommendedStrategy']; + grade?: PlaybackHealthResult['grade']; + score?: number; } const DEFAULT_SOURCE_TEST_TIMEOUT_MS = 10000; @@ -204,7 +209,14 @@ async function measureNativeVideoSource( export async function getVideoResolutionFromM3u8( m3u8Url: string, - options: { timeoutMs?: number } = {}, + options: { + timeoutMs?: number; + source?: string; + sourceName?: string; + title?: string; + episodeIndex?: number; + useServerHealth?: boolean; + } = {}, ): Promise { if (!m3u8Url) { return buildResult({ @@ -226,6 +238,11 @@ export async function getVideoResolutionFromM3u8( const timeoutMs = options.timeoutMs || DEFAULT_SOURCE_TEST_TIMEOUT_MS; + if (options.useServerHealth !== false) { + const healthResult = await getPlaybackHealthFromServer(m3u8Url, options); + if (healthResult) return healthResult; + } + if (!isLikelyHlsUrl(m3u8Url) || !Hls.isSupported()) { return measureNativeVideoSource(m3u8Url, timeoutMs); } @@ -408,6 +425,123 @@ export async function getVideoResolutionFromM3u8( }); } +async function probeBrowserCors( + url: string, + timeoutMs: number, +): Promise<{ readable: boolean; reason?: string }> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + cache: 'no-store', + mode: 'cors', + signal: controller.signal, + }); + if (!response.ok) { + return { readable: false, reason: `HTTP ${response.status}` }; + } + await response.body?.cancel().catch(() => undefined); + return { readable: true }; + } catch (error) { + const aborted = + error instanceof DOMException && error.name === 'AbortError'; + return { + readable: false, + reason: aborted ? 'CORS check timeout' : 'CORS blocked', + }; + } finally { + clearTimeout(timer); + } +} + +function buildResultFromHealth( + health: PlaybackHealthResult, +): VideoSourceTestResult { + const selectedHeight = health.manifest?.selectedVariant?.height || 0; + const quality = + selectedHeight > 0 + ? qualityFromWidth(selectedHeight) + : health.urlType === 'mp4' + ? 'MP4' + : '未知'; + const firstSegmentMs = health.firstSegment?.timeToFirstByteMs || 0; + const firstChunkBytes = health.firstSegment?.firstChunkBytes || 0; + const speedKBps = + firstSegmentMs > 0 && firstChunkBytes > 0 + ? firstChunkBytes / 1024 / (firstSegmentMs / 1000) + : undefined; + const pingTime = + firstSegmentMs || + health.timings?.manifestMs || + health.timings?.mediaManifestMs || + 0; + + return { + quality, + loadSpeed: formatVideoLoadSpeed(speedKBps), + pingTime: Math.round(pingTime), + speedKBps, + hasError: !health.playable, + status: health.playable ? 'ok' : health.manifest?.ok ? 'partial' : 'failed', + message: + health.reason || (health.playable ? '播放链路可用' : '播放链路异常'), + playable: health.playable, + testedAt: Date.now(), + health, + recommendedStrategy: health.recommendedStrategy, + grade: health.grade, + score: health.score, + }; +} + +async function getPlaybackHealthFromServer( + m3u8Url: string, + options: { + timeoutMs?: number; + source?: string; + sourceName?: string; + title?: string; + episodeIndex?: number; + }, +): Promise { + try { + const params = new URLSearchParams({ + url: m3u8Url, + strategy: 'smart', + }); + if (options.source) params.set('source', options.source); + if (options.title) params.set('title', options.title); + if (options.episodeIndex !== undefined) { + params.set('episodeIndex', String(options.episodeIndex)); + } + + const response = await fetch(`/api/playback/health?${params.toString()}`, { + cache: 'no-store', + }); + if (!response.ok) return null; + + const health = (await response.json()) as PlaybackHealthResult; + if (typeof window !== 'undefined' && health.urlType === 'hls') { + const cors = await probeBrowserCors( + m3u8Url, + Math.min(3000, options.timeoutMs || DEFAULT_SOURCE_TEST_TIMEOUT_MS), + ); + health.cors = { + ...(health.cors || {}), + manifestReadable: cors.readable, + checkedInBrowser: true, + reason: cors.readable ? undefined : cors.reason, + }; + if (!cors.readable && health.recommendedStrategy === 'direct') { + health.recommendedStrategy = 'manifest-proxy'; + } + } + return buildResultFromHealth(health); + } catch { + return null; + } +} + export function cleanHtmlTags(text: string) { if (!text) return ''; From b2506cb8cef688fa55e093bfca66423d4eaf962c Mon Sep 17 00:00:00 2001 From: katelya Date: Thu, 21 May 2026 22:26:26 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=81=A5=E5=BA=B7=E5=BA=A6=E6=8C=87=E6=A0=87=E5=B9=B6?= =?UTF-8?q?=E6=94=B9=E8=BF=9B=20URL=20=E8=A7=A3=E5=8C=85=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 PlaybackHealthResult 中新增详细吞吐量指标字段,包括 firstByteMs、sampleBytes、sampleDurationMs 和 throughputKbps - 改进 unwrapDecoProxyUrl 函数以递归解包 URL,并捕获 originalUrl 和 proxyRoute 等额外元数据 - 增强 scorePlaybackHealth 函数,将吞吐量指标纳入播放评分逻辑,以确保更准确地评估播放健康度 - 更新 resolvePlayableUrl 以保留原始 URL 和解包后的 URL 用于诊断目的 - 引入 detectRuntimeTarget 函数以识别运行环境(vercel、docker、node、unknown) - 增强 fetchPlaylist 和 testSmallAsset 函数,加入重试逻辑以处理 HTTP 403 错误并收集调试信息 - 在 testSmallAsset 中实现 CORS 检查,以确定跨域可访问性 - 改进基于估算吞吐量和所需带宽选择 HLS 变体的逻辑 - 在代码中添加详细注释,以提升代码的可读性与可维护性 --- src/app/play/page.tsx | 442 ++++++++++++++++++++++++---- src/lib/player/hls-utils.ts | 173 ++++++++--- src/lib/player/playback-resolver.ts | 6 + src/lib/player/stream-health.ts | 338 +++++++++++++++++++-- 4 files changed, 835 insertions(+), 124 deletions(-) diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 2b4a3fe02..4909095e7 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -492,6 +492,39 @@ function saveDanmukuSettings(settings: Partial) { } } +/** + * 针对各类播放异常、超时与防盗链拦截等,给出人性的 UI 提示原因 + * @param reason 原始异常标志 + * @param health 播放链路测速与健康数据 + * @returns 友好原因说明 + */ +function getFriendlyFailureReason( + reason: string, + health: PlaybackHealthResult | null, +): string { + const norm = (reason || '').toLowerCase(); + if ( + norm.includes('403') || + norm.includes('forbidden') || + norm.includes('http-403') + ) { + return '源站拒绝访问(403),自动换源'; + } + if (health && health.corsOk === false) { + return '视频首片跨域拒绝,自动尝试代理安全链路'; + } + if ( + norm.includes('loading-first-segment-timeout') || + norm.includes('first-segment-timeout') + ) { + return '首片加载超时,正在自动切换备用链路'; + } + if (norm.includes('buffering-timeout') || norm.includes('stalled')) { + return '视频加载缓慢/卡顿,正在自动尝试优化画质或换源'; + } + return `播放异常(${reason}),正在为您寻找最优链路`; +} + function PlayPageClient() { const router = useRouter(); const searchParams = useSearchParams(); @@ -668,6 +701,42 @@ function PlayPageClient() { // 视频播放地址 const [videoUrl, setVideoUrl] = useState(''); const [resolvedPlaybackUrl, setResolvedPlaybackUrl] = useState(''); + + // NOTE: 诊断与会话增强 Refs,用于多维度排查卡顿与播放异常问题 + const playbackSessionIdRef = useRef(''); + const abortControllerRef = useRef(null); + const playbackEventsRef = useRef< + { time: string; event: string; detail?: any }[] + >([]); + const hlsErrorDetailsRef = useRef< + { time: string; type: string; detail: string; fatal: boolean }[] + >([]); + const playbackHealthRef = useRef(null); + + // 视频原生事件解绑 Ref,防止重复绑定引发内存泄漏 + const videoListenersCleanupRef = useRef<(() => void) | null>(null); + + // 记录播放生命周期事件 + const addPlaybackEvent = useCallback((event: string, detail?: any) => { + const time = new Date().toISOString(); + playbackEventsRef.current = [ + ...playbackEventsRef.current.slice(-19), // 最多保留 20 条,避免内存膨胀 + { time, event, detail }, + ]; + }, []); + + // 记录 Hls 发生的异常详情 + const addHlsError = useCallback( + (type: string, detail: string, fatal: boolean) => { + const time = new Date().toISOString(); + hlsErrorDetailsRef.current = [ + ...hlsErrorDetailsRef.current.slice(-19), + { time, type, detail, fatal }, + ]; + }, + [], + ); + const [playbackStrategy, setPlaybackStrategy] = useState('direct'); const [playbackStreamType, setPlaybackStreamType] = @@ -689,6 +758,11 @@ function PlayPageClient() { useState(null); const [playbackErrorReason, setPlaybackErrorReason] = useState(''); + // 保持健康状态与 Ref 同步,方便闭包环境读取 + useEffect(() => { + playbackHealthRef.current = playbackHealth; + }, [playbackHealth]); + useEffect(() => { playbackStrategyRef.current = playbackStrategy; }, [playbackStrategy]); @@ -2066,6 +2140,18 @@ function PlayPageClient() { const cleanupPlayer = () => { cleanupMobileMouseSeekPatch(); + // 取消尚未完成的解析请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + + // 解绑视频的原生事件,防止内存泄漏 + if (videoListenersCleanupRef.current) { + videoListenersCleanupRef.current(); + videoListenersCleanupRef.current = null; + } + // Clean up DecoDock features and theme before destroying the player countdownCleanupRef.current?.(); countdownCleanupRef.current = null; @@ -2648,6 +2734,13 @@ function PlayPageClient() { ) => { if (!videoUrl) return; + // 取消上一个进行中的解析请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + const abortController = new AbortController(); + abortControllerRef.current = abortController; + const requestRawUrl = videoUrl; setPlaybackStatus('resolving'); setPlaybackErrorReason(''); @@ -2655,6 +2748,12 @@ function PlayPageClient() { setIsVideoLoading(true); setVideoLoadingStage('initing'); + addPlaybackEvent('resolve:start', { + sessionId: playbackSessionIdRef.current, + strategy: preferredStrategy, + proxyMode, + }); + try { const response = await fetch('/api/playback/resolve', { method: 'POST', @@ -2669,6 +2768,7 @@ function PlayPageClient() { proxyMode, strategy: preferredStrategy, }), + signal: abortController.signal, }); if (!response.ok) { @@ -2683,6 +2783,13 @@ function PlayPageClient() { health?: PlaybackHealthResult; }; + addPlaybackEvent('resolve:success', { + strategy: resolved.strategy, + type: resolved.type, + healthScore: resolved.health?.score, + throughputKbps: resolved.health?.smallAsset?.throughputKbps, + }); + setPlaybackStrategy(resolved.strategy); playbackStrategyRef.current = resolved.strategy; setPlaybackStreamType(resolved.type); @@ -2706,8 +2813,17 @@ function PlayPageClient() { ); } } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + // 被主动取消的请求,不进行错误提示和重试 + console.log('播放解析请求已被取消'); + return; + } + const reason = error instanceof Error ? error.message : 'playback-resolve-failed'; + + addPlaybackEvent('resolve:error', { reason }); + setPlaybackErrorReason(reason); setPlaybackStatus('failed'); markSourcePlaybackFailure( @@ -2753,11 +2869,13 @@ function PlayPageClient() { playbackAttemptsRef.current.sourceSwitches += 1; setPlaybackStatus('switching-source'); - setPlaybackErrorReason(reason); - showToast( - `正在尝试备用播放源 ${playbackAttemptsRef.current.sourceSwitches}/${maxSwitch}`, - 'info', - ); + + addPlaybackEvent('switching-source', { + fromSource: `${currentSourceRef.current}-${currentIdRef.current}`, + toSource: candidateKey, + reason, + }); + const currentPlayTime = artPlayerRef.current?.currentTime || 0; if (currentPlayTime > 1) { resumeTimeRef.current = currentPlayTime; @@ -2772,15 +2890,19 @@ function PlayPageClient() { return false; }, - [availableSources], + [availableSources, addPlaybackEvent], ); const handlePlaybackFailure = useCallback( async (reason: string) => { const sourceKey = `${currentSourceRef.current}-${currentIdRef.current}`; markSourcePlaybackFailure(sourceKey, reason); - setPlaybackErrorReason(reason); - setPlaybackStatus('recovering'); + + const friendlyReason = getFriendlyFailureReason( + reason, + playbackHealthRef.current, + ); + setPlaybackErrorReason(friendlyReason); const strategyOrder: PlaybackStrategy[] = [ 'direct', @@ -2791,17 +2913,51 @@ function PlayPageClient() { const currentIndex = Math.max(0, strategyOrder.indexOf(currentStrategy)); const maxSwitch = getMaxAutoSwitch(); - if ( + const runtimeTarget = + playbackHealthRef.current?.runtimeTarget || 'unknown'; + const isVercel = runtimeTarget === 'vercel'; + + let willSwitchStrategy = playbackAttemptsRef.current.strategySwitches < maxSwitch && - currentIndex < strategyOrder.length - 1 + currentIndex < strategyOrder.length - 1; + + // NOTE: Vercel Serverless 环境中转大文件容易超时和超流量,故禁用 asset-proxy 中转代理并给予 UI 提示 + if ( + willSwitchStrategy && + strategyOrder[currentIndex + 1] === 'asset-proxy' && + isVercel ) { + willSwitchStrategy = false; + showToast( + 'Vercel 部署环境不支持分片中转,已跳过代理尝试,正在换源...', + 'info', + ); + addPlaybackEvent('skip-asset-proxy-due-to-vercel'); + } + + if (willSwitchStrategy) { + setPlaybackStatus('switching-strategy'); + } else { + setPlaybackStatus('switching-source'); + } + + addPlaybackEvent('playback-failure', { + reason, + friendlyReason, + willSwitchStrategy, + currentStrategy, + }); + + // 在 UI 停留 1 秒展示具体的换源或降级原因,提升用户感知 + await new Promise((resolve) => setTimeout(resolve, 1000)); + + if (willSwitchStrategy) { const nextStrategy = strategyOrder[currentIndex + 1]; playbackAttemptsRef.current.strategySwitches += 1; const currentPlayTime = artPlayerRef.current?.currentTime || 0; if (currentPlayTime > 1) { resumeTimeRef.current = currentPlayTime; } - setPlaybackStatus('switching-strategy'); showToast( `正在尝试备用播放链路 ${playbackAttemptsRef.current.strategySwitches}/${maxSwitch}`, 'info', @@ -2817,23 +2973,43 @@ function PlayPageClient() { showToast('当前可用播放链路已尝试完毕,请手动换源', 'error'); } }, - [resolveCurrentPlayback, switchToNextPlayableSource], + [resolveCurrentPlayback, switchToNextPlayableSource, addPlaybackEvent], ); const copyPlaybackDiagnostics = useCallback(async () => { const diagnostics = { - title: videoTitleRef.current, - source: currentSourceRef.current, - sourceName: detailRef.current?.source_name, - episodeIndex: currentEpisodeIndexRef.current, - strategy: playbackStrategyRef.current, - status: playbackStatus, - urlType: playbackStreamType, - reason: playbackErrorReason, - rawUrl: videoUrl, - resolvedUrl: resolvedPlaybackUrl, - attempts: playbackAttemptsRef.current, - health: playbackHealth, + session: { + sessionId: playbackSessionIdRef.current, + title: videoTitleRef.current, + source: currentSourceRef.current, + sourceName: detailRef.current?.source_name, + episodeIndex: currentEpisodeIndexRef.current, + currentTime: artPlayerRef.current?.currentTime || 0, + duration: artPlayerRef.current?.duration || 0, + }, + deployment: { + runtimeTarget: playbackHealth?.runtimeTarget || 'unknown', + isVercel: playbackHealth?.runtimeTarget === 'vercel', + location: typeof window !== 'undefined' ? window.location.href : '', + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '', + }, + playback: { + strategy: playbackStrategyRef.current, + status: playbackStatus, + urlType: playbackStreamType, + reason: playbackErrorReason, + rawUrl: videoUrl, + resolvedUrl: resolvedPlaybackUrl, + attempts: playbackAttemptsRef.current, + }, + throughput: { + score: playbackHealth?.score || 'N/A', + throughputKbps: playbackHealth?.smallAsset?.throughputKbps || 0, + timeToFirstByteMs: playbackHealth?.smallAsset?.timeToFirstByteMs || 0, + corsOk: playbackHealth?.corsOk, + }, + events: playbackEventsRef.current, + hlsErrors: hlsErrorDetailsRef.current, }; try { @@ -2851,6 +3027,10 @@ function PlayPageClient() { videoUrl, ]); + const hasDowngradedFirstSegmentRef = useRef(false); + const hasDowngradedBufferingRef = useRef(false); + const [extraTimeoutTrigger, setExtraTimeoutTrigger] = useState(0); + useEffect(() => { if (!videoUrl) { setResolvedPlaybackUrl(''); @@ -2858,6 +3038,19 @@ function PlayPageClient() { return; } + // 初始化新的播放会话 ID,清空旧的事件与错误历史 + playbackSessionIdRef.current = + 'sess_' + Math.random().toString(36).substring(2, 10) + '_' + Date.now(); + playbackEventsRef.current = []; + hlsErrorDetailsRef.current = []; + hasDowngradedFirstSegmentRef.current = false; + hasDowngradedBufferingRef.current = false; + + addPlaybackEvent('session:init', { + sessionId: playbackSessionIdRef.current, + videoUrl, + }); + playbackAttemptsRef.current = { strategySwitches: 0, sourceSwitches: playbackAttemptsRef.current.sourceSwitches, @@ -2873,23 +3066,79 @@ function PlayPageClient() { return; } - const timeoutMs = - playbackStatus === 'buffering' - ? 30000 - : playbackStatus === 'loading-first-segment' - ? 20000 - : playbackStatus === 'loading-manifest' - ? 10000 - : 0; + let timeoutMs = 0; + if (playbackStatus === 'buffering') { + timeoutMs = 30000; + } else if (playbackStatus === 'loading-first-segment') { + // 若已降级过则进行 15 秒短超时,否则为 20 秒 + timeoutMs = hasDowngradedFirstSegmentRef.current ? 15000 : 20000; + } else if (playbackStatus === 'loading-manifest') { + timeoutMs = 10000; + } if (!timeoutMs) return; const timer = window.setTimeout(() => { + const hls = artPlayerRef.current?.video?.hls; + + // 首片加载超时:若存在多个清晰度且非最低清晰度,强制降级到最低清晰度 (索引 0) 并额外重试 15 秒 + if ( + playbackStatus === 'loading-first-segment' && + !hasDowngradedFirstSegmentRef.current + ) { + if ( + hls && + hls.levels && + hls.levels.length > 1 && + hls.currentLevel > 0 + ) { + console.log('首片加载超时,自动切换至最低画质重试'); + hls.currentLevel = 0; + hasDowngradedFirstSegmentRef.current = true; + showToast('首片加载缓慢,已尝试切换至最低画质重试...', 'info'); + addPlaybackEvent('first-segment-downgrade-retry', { + fromLevel: hls.currentLevel, + toLevel: 0, + }); + setExtraTimeoutTrigger((prev) => prev + 1); + return; + } + } + + // 播放卡顿缓冲超时:尝试降级至最低清晰度继续缓冲 30 秒,否则直接报错换源 + if ( + playbackStatus === 'buffering' && + !hasDowngradedBufferingRef.current + ) { + if ( + hls && + hls.levels && + hls.levels.length > 1 && + hls.currentLevel > 0 + ) { + console.log('播放卡顿超时,自动降级至最低画质'); + hls.currentLevel = 0; + hasDowngradedBufferingRef.current = true; + showToast('缓冲超时,已自动降级画质以保障流畅播放...', 'info'); + addPlaybackEvent('buffering-downgrade-retry', { + fromLevel: hls.currentLevel, + toLevel: 0, + }); + setExtraTimeoutTrigger((prev) => prev + 1); + return; + } + } + void handlePlaybackFailure(`${playbackStatus}-timeout`); }, timeoutMs); return () => window.clearTimeout(timer); - }, [handlePlaybackFailure, playbackStatus, resolvedPlaybackUrl]); + }, [ + handlePlaybackFailure, + playbackStatus, + resolvedPlaybackUrl, + extraTimeoutTrigger, + ]); // --------------------------------------------------------------------------- // 键盘快捷键 @@ -3465,6 +3714,61 @@ function PlayPageClient() { ensureVideoSource(video, url); + // 绑定 video 元素的原生事件,配合 ref 记录播放细节,并精确识别真实首帧 + const onLoadedMetadata = () => { + addPlaybackEvent('video-native:loadedmetadata'); + }; + const onLoadedData = () => { + addPlaybackEvent('video-native:loadeddata'); + setPlaybackStatus('playing'); // 真实首帧成功出画面 + setIsVideoLoading(false); + }; + const onCanPlay = () => { + addPlaybackEvent('video-native:canplay'); + setPlaybackStatus('playing'); // 真实首帧成功出画面 + setIsVideoLoading(false); + }; + const onPlaying = () => { + addPlaybackEvent('video-native:playing'); + }; + const onWaiting = () => { + addPlaybackEvent('video-native:waiting'); + setPlaybackStatus('buffering'); + setIsVideoLoading(true); + }; + const onStalled = () => { + addPlaybackEvent('video-native:stalled'); + }; + const onVideoError = () => { + addPlaybackEvent('video-native:error', { + code: video.error?.code, + message: video.error?.message, + }); + }; + + // 清理上一轮绑定的原生事件 + if (videoListenersCleanupRef.current) { + videoListenersCleanupRef.current(); + } + + video.addEventListener('loadedmetadata', onLoadedMetadata); + video.addEventListener('loadeddata', onLoadedData); + video.addEventListener('canplay', onCanPlay); + video.addEventListener('playing', onPlaying); + video.addEventListener('waiting', onWaiting); + video.addEventListener('stalled', onStalled); + video.addEventListener('error', onVideoError); + + videoListenersCleanupRef.current = () => { + video.removeEventListener('loadedmetadata', onLoadedMetadata); + video.removeEventListener('loadeddata', onLoadedData); + video.removeEventListener('canplay', onCanPlay); + video.removeEventListener('playing', onPlaying); + video.removeEventListener('waiting', onWaiting); + video.removeEventListener('stalled', onStalled); + video.removeEventListener('error', onVideoError); + }; + hls.on( Hls.Events.AUDIO_TRACKS_UPDATED, ( @@ -3549,28 +3853,52 @@ function PlayPageClient() { ); hls.on(Hls.Events.MANIFEST_PARSED, () => { + const health = playbackHealthRef.current; const levels = hls.levels || []; - const preferred = levels - .map((level, index) => ({ level, index })) - .filter(({ level }) => { - const height = level.height || 0; - return height >= 720 && height <= 1080; - }) - .sort((left, right) => { - const heightDiff = - (right.level.height || 0) - (left.level.height || 0); - if (heightDiff !== 0) return heightDiff; - return (right.level.bitrate || 0) - (left.level.bitrate || 0); - })[0]; - - if (preferred) { - hls.currentLevel = preferred.index; + + addPlaybackEvent('hls:manifest-parsed', { + levelsCount: levels.length, + healthScore: health?.score, + throughputKbps: health?.smallAsset?.throughputKbps, + }); + + const isHealthy = health?.grade === 'A'; + + if (isHealthy) { + // A 级:链路状态优良,开启自动 ABR 首屏码率起播,让 hls.js 自行测量 + hls.startLevel = -1; + console.log('播放链路健康评级为 A,允许自动 ABR 起播'); + } else { + // B/C/D 级或未测量出:强制限制起播清晰度为 720p 或更低清晰度,防止卡死在 1080p + let targetIdx = 0; + const targetLevel = levels + .map((lvl, idx) => ({ lvl, idx })) + .filter( + ({ lvl }) => + lvl.height && lvl.height >= 480 && lvl.height <= 720, + ) + .sort((a, b) => (b.lvl.height || 0) - (a.lvl.height || 0))[0]; + + if (targetLevel) { + targetIdx = targetLevel.idx; + } + + hls.startLevel = targetIdx; + console.log( + `播放链路不佳 (评级:${health?.score || '未知'}),限制首片起播清晰度 Level 索引为 ${targetIdx} (${levels[targetIdx]?.height || '未知'}p)`, + ); } setPlaybackStatus('loading-first-segment'); }); hls.on(Hls.Events.ERROR, function (_event: any, data: any) { const reason = data?.details || data?.type || 'hls-error'; + addHlsError( + data?.type || 'unknown', + data?.details || 'unknown', + !!data?.fatal, + ); + hlsErrorCountRef.current[reason] = (hlsErrorCountRef.current[reason] || 0) + 1; @@ -3630,23 +3958,7 @@ function PlayPageClient() { }); hls.on(Hls.Events.ERROR, function (event: any, data: any) { - console.error('HLS Error:', event, data); - if (data.fatal && data.__legacyRecovery === true) { - switch (data.type) { - case Hls.ErrorTypes.NETWORK_ERROR: - console.log('网络错误,尝试恢复...'); - hls.startLoad(); - break; - case Hls.ErrorTypes.MEDIA_ERROR: - console.log('媒体错误,尝试恢复...'); - hls.recoverMediaError(); - break; - default: - console.log('无法恢复的错误'); - hls.destroy(); - break; - } - } + console.error('HLS Error Console:', event, data); }); }, }, diff --git a/src/lib/player/hls-utils.ts b/src/lib/player/hls-utils.ts index 9c8ab3092..a7aa1231b 100644 --- a/src/lib/player/hls-utils.ts +++ b/src/lib/player/hls-utils.ts @@ -87,6 +87,33 @@ export interface PlaybackHealthResult { score?: number; grade?: 'A' | 'B' | 'C' | 'D'; debug?: Record; + + // NOTE: 新增真实吞吐量测速字段 + firstByteMs?: number; + sampleBytes?: number; + sampleDurationMs?: number; + throughputKBps?: number; + throughputKbps?: number; + estimatedPlayable?: boolean; + requiredBandwidthKbps?: number; + selectedVariantBandwidthKbps?: number; + + corsOk?: boolean; + runtimeTarget?: 'vercel' | 'docker' | 'node' | 'unknown'; + smallAsset?: { + ok: boolean; + status?: number; + contentType?: string; + contentLength?: number; + acceptRanges?: string; + timeToFirstByteMs?: number; + firstChunkBytes?: number; + reason?: string; + corsOk?: boolean; + sampleBytes?: number; + sampleDurationMs?: number; + throughputKbps?: number; + }; } export const DEFAULT_ANDROID_TV_UA = @@ -166,35 +193,63 @@ export function getPlaylistBaseUrl(finalUrl: string): string { export function unwrapDecoProxyUrl(rawUrl: string): { url: string; - referer?: string; + originalUrl: string; + wasDecoProxy: boolean; wasProxy: boolean; + proxyRoute?: string; proxyPath?: string; + oldSig?: string; + referer?: string; } { - const sanitized = sanitizeEpisodeUrl(rawUrl); - try { - const base = - typeof window !== 'undefined' ? window.location.origin : 'http://local'; - const parsed = new URL(sanitized, base); - const isDecoProxy = - parsed.pathname === '/api/proxy/m3u8' || - parsed.pathname === '/api/proxy/m3u8-filter' || - parsed.pathname === '/api/proxy/m3u8-asset' || - parsed.pathname === '/api/proxy/segment' || - parsed.pathname === '/api/proxy/key'; - const upstream = parsed.searchParams.get('url'); - if (isDecoProxy && upstream) { - return { - url: sanitizeEpisodeUrl(upstream), - referer: parsed.searchParams.get('referer') || undefined, - wasProxy: true, - proxyPath: parsed.pathname, - }; + let currentUrl = sanitizeEpisodeUrl(rawUrl); + let wasDecoProxy = false; + let firstProxyRoute: string | undefined = undefined; + let firstOldSig: string | undefined = undefined; + let firstReferer: string | undefined = undefined; + + const base = + typeof window !== 'undefined' ? window.location.origin : 'http://local'; + + // NOTE: 使用 while(true) 递归解包真正的原始上游 URL + while (true) { + try { + const parsed = new URL(currentUrl, base); + const isDecoProxy = + parsed.pathname === '/api/proxy/m3u8' || + parsed.pathname === '/api/proxy/m3u8-filter' || + parsed.pathname === '/api/proxy/m3u8-asset' || + parsed.pathname === '/api/proxy/segment' || + parsed.pathname === '/api/proxy/key'; + + if (isDecoProxy) { + const upstream = parsed.searchParams.get('url'); + if (upstream) { + if (!wasDecoProxy) { + wasDecoProxy = true; + firstProxyRoute = parsed.pathname; + firstOldSig = parsed.searchParams.get('sig') || undefined; + firstReferer = parsed.searchParams.get('referer') || undefined; + } + currentUrl = sanitizeEpisodeUrl(upstream); + continue; + } + } + } catch { + // ignore parse failures and stop unwrapping } - } catch { - // Fall through to the sanitized input. + break; } - return { url: sanitized, wasProxy: false }; + return { + url: currentUrl, + originalUrl: currentUrl, + wasDecoProxy, + wasProxy: wasDecoProxy, + proxyRoute: firstProxyRoute, + proxyPath: firstProxyRoute, + oldSig: firstOldSig, + referer: firstReferer, + }; } export function hasSuspiciousUrlEncoding(rawUrl: string): string[] { @@ -345,30 +400,76 @@ export function scorePlaybackHealth( health: Pick< PlaybackHealthResult, 'manifest' | 'key' | 'firstSegment' | 'cors' | 'playable' | 'nestedProxy' - >, + > & { + throughputKbps?: number; + selectedVariantBandwidthKbps?: number; + }, ): { score: number; grade: 'A' | 'B' | 'C' | 'D' } { let score = 0; - if (health.firstSegment?.ok) { - score += 40; - const ttfb = health.firstSegment.timeToFirstByteMs || 0; + // 1. m3u8 列表是否成功获取 + const manifestOk = health.manifest?.ok; + if (manifestOk) score += 15; + + // 2. 解密 Key 是否成功获取(如果存在) + const keyOk = !health.key?.present || health.key?.ok; + if (keyOk) score += 10; + + // 3. 首片/首帧分片基础连通性检测 + const segmentOk = health.firstSegment?.ok; + if (segmentOk) { + score += 30; + const ttfb = health.firstSegment?.timeToFirstByteMs || 0; if (ttfb > 0) { - if (ttfb <= 800) score += 30; - else if (ttfb <= 2000) score += 24; - else if (ttfb <= 5000) score += 16; - else score += 8; + if (ttfb <= 800) score += 15; + else if (ttfb <= 2000) score += 10; + else if (ttfb <= 5000) score += 5; + else score += 2; } else { - score += 12; + score += 5; + } + } + + // 4. 吞吐量吞吐速度加分 + const throughput = health.throughputKbps || 0; + const reqBandwidth = health.selectedVariantBandwidthKbps + ? health.selectedVariantBandwidthKbps * 1.2 + : 1000; // 默认标清播放起步带宽 1000 Kbps (125 KB/s) + + let throughputOk = false; + if (segmentOk && throughput > 0) { + if (throughput >= reqBandwidth) { + score += 30; + throughputOk = true; + } else if (throughput >= reqBandwidth * 0.7) { + score += 20; + } else if (throughput >= reqBandwidth * 0.4) { + score += 10; + } else { + score += 2; } } - if (health.manifest?.ok) score += 15; - if (!health.key?.present || health.key.ok) score += 10; if (health.cors?.manifestReadable) score += 5; - if (health.playable && score < 65) score = 65; if (health.nestedProxy) score -= 8; score = Math.max(0, Math.min(100, Math.round(score))); - const grade = score >= 85 ? 'A' : score >= 70 ? 'B' : score >= 45 ? 'C' : 'D'; + // 判定是否可播放:基础连通性全部通过 + const playable = Boolean(health.playable && manifestOk && segmentOk && keyOk); + + let grade: 'A' | 'B' | 'C' | 'D' = 'D'; + if (!playable) { + grade = 'D'; + } else { + // NOTE: 只有在基础资源均就绪,且吞吐量满足所选清晰度带宽需求时,才允许评定为 A 级 + if (manifestOk && keyOk && segmentOk && throughputOk && score >= 80) { + grade = 'A'; + } else if (score >= 60) { + grade = 'B'; + } else { + grade = 'C'; + } + } + return { score, grade }; } diff --git a/src/lib/player/playback-resolver.ts b/src/lib/player/playback-resolver.ts index b174fc17f..cbabe16b1 100644 --- a/src/lib/player/playback-resolver.ts +++ b/src/lib/player/playback-resolver.ts @@ -152,6 +152,12 @@ export async function resolvePlayableUrl( requestedStrategy, sourceName: input.sourceName, unwrappedProxyPath: unwrapped.proxyPath, + // NOTE: 保留诊断面板所需要的解包前后全套 URL 与签名信息 + originalEpisodeUrl: input.episodeUrl, + unwrappedUrl: originalUrl, + wasDecoProxy: unwrapped.wasDecoProxy, + proxyRouteBefore: unwrapped.proxyRoute, + finalResolvedUrl: finalUrl, }, }; } diff --git a/src/lib/player/stream-health.ts b/src/lib/player/stream-health.ts index 4d85af46f..ec9e5d377 100644 --- a/src/lib/player/stream-health.ts +++ b/src/lib/player/stream-health.ts @@ -159,6 +159,23 @@ function parseContentLength(headers: Headers): number | undefined { return Number.isFinite(value) && value >= 0 ? value : undefined; } +export function detectRuntimeTarget(): + | 'vercel' + | 'docker' + | 'node' + | 'unknown' { + if (process.env.VERCEL || process.env.NOW_BUILDER) { + return 'vercel'; + } + if (process.env.DOCKER_ENV || process.env.IS_DOCKER) { + return 'docker'; + } + if (process.env.NODE_ENV === 'production') { + return 'node'; + } + return 'unknown'; +} + async function fetchPlaylist(input: { url: string; headers: Record; @@ -167,23 +184,94 @@ async function fetchPlaylist(input: { response: Response; text: string; durationMs: number; + retryUsed?: boolean; + debugInfo?: Record; }> { await validateProxyTargetUrl(input.url); const startedAt = Date.now(); - const response = await fetchWithValidatedRedirects( - input.url, - { - cache: 'no-store', - headers: input.headers, - method: 'GET', - }, - { timeoutMs: input.timeoutMs, maxRedirects: MAX_REDIRECTS }, - ); - const text = await readTextWithLimit(response); + let response: Response | undefined = undefined; + let text = ''; + + const doFetch = async (headers: Record) => { + const res = await fetchWithValidatedRedirects( + input.url, + { + cache: 'no-store', + headers, + method: 'GET', + }, + { timeoutMs: input.timeoutMs, maxRedirects: MAX_REDIRECTS }, + ); + const txt = await readTextWithLimit(res); + return { res, txt }; + }; + + let retryUsed = false; + let debugInfo: Record | undefined; + + try { + const res = await doFetch(input.headers); + response = res.res; + text = res.txt; + } catch (error) { + // NOTE: 可能是 HTTP 403 引起的读取失败,或者直接 fetch 报错,尝试兜底重试一次 + if ( + error instanceof Error && + (error.message.includes('403') || error.message.includes('status 403')) + ) { + // 走向 403 兜底重试流程 + } else { + throw error; + } + } + + // 检查是否为 403 + if (!response || response.status === 403) { + retryUsed = true; + let urlOrigin = ''; + try { + urlOrigin = new URL(input.url).origin; + } catch { + // ignore + } + + const retryHeaders: Record = { + ...input.headers, + 'User-Agent': DEFAULT_ANDROID_TV_UA, + Referer: urlOrigin ? urlOrigin + '/' : input.headers.Referer || '', + }; + if (urlOrigin) { + retryHeaders.Origin = urlOrigin; + } + + debugInfo = { + attempt1: { + status: 403, + contentType: response?.headers.get('content-type') || '', + snippet: text ? text.slice(0, 120) : '', + headers: { + referer: input.headers.Referer || input.headers.referer || '', + origin: input.headers.Origin || input.headers.origin || '', + ua: input.headers['User-Agent'] || input.headers['user-agent'] || '', + }, + }, + }; + + const res = await doFetch(retryHeaders); + response = res.res; + text = res.txt; + } + + if (!response) { + throw new Error('fetch-playlist-failed'); + } + return { response, text, durationMs: Date.now() - startedAt, + retryUsed, + debugInfo, }; } @@ -192,6 +280,7 @@ async function testSmallAsset(input: { headers: Record; timeoutMs: number; kind: 'key' | 'segment' | 'map' | 'file'; + requestOrigin?: string; }): Promise<{ ok: boolean; status?: number; @@ -201,6 +290,10 @@ async function testSmallAsset(input: { timeToFirstByteMs?: number; firstChunkBytes?: number; reason?: string; + corsOk?: boolean; + sampleBytes?: number; + sampleDurationMs?: number; + throughputKbps?: number; }> { try { await validateProxyTargetUrl(input.url); @@ -213,6 +306,10 @@ async function testSmallAsset(input: { const startedAt = Date.now(); let response: Response; + const isLargeTest = input.kind === 'segment' || input.kind === 'file'; + // NOTE: 对首个 ts/m4s 分片使用最大 512KB 的吞吐量下载测试 + const rangeHeader = input.kind === 'key' ? 'bytes=0-255' : 'bytes=0-524287'; + try { response = await fetchWithValidatedRedirects( input.url, @@ -220,7 +317,7 @@ async function testSmallAsset(input: { cache: 'no-store', headers: { ...input.headers, - Range: input.kind === 'key' ? 'bytes=0-255' : 'bytes=0-1023', + Range: rangeHeader, }, method: 'GET', }, @@ -240,19 +337,64 @@ async function testSmallAsset(input: { const contentLength = parseContentLength(response.headers); const acceptRanges = response.headers.get('accept-ranges') || undefined; + // NOTE: 基于 Access-Control-Allow-Origin 响应头精准判定跨域 CORS + const allowOrigin = response.headers.get('access-control-allow-origin'); + let corsOk = false; + if (allowOrigin) { + const originLower = allowOrigin.trim().toLowerCase(); + if (originLower === '*') { + corsOk = true; + } else if (input.requestOrigin) { + const reqOriginLower = input.requestOrigin.trim().toLowerCase(); + corsOk = + originLower.includes(reqOriginLower) || + reqOriginLower.includes(originLower); + } else { + corsOk = true; + } + } + let firstChunkBytes = 0; + let sampleBytes = 0; + let sampleDurationMs = 0; + try { const reader = response.body?.getReader(); if (reader) { - const first = await reader.read(); - firstChunkBytes = first.value?.byteLength || 0; - await reader.cancel().catch(() => undefined); + const readStartedAt = Date.now(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) { + if (firstChunkBytes === 0) { + firstChunkBytes = value.byteLength; + } + sampleBytes += value.byteLength; + } + const now = Date.now(); + sampleDurationMs = now - readStartedAt; + // NOTE: 最多下载 512KB 或测试达到 2 秒,提前 cancel 中断下载 + if ( + isLargeTest && + (sampleBytes >= 512 * 1024 || sampleDurationMs >= 2000) + ) { + await reader.cancel().catch(() => undefined); + break; + } + } } } catch { - // The status/header information is still useful. + // 捕获可能的中断读取异常,已下载数据仍然有效 + } + + if (sampleDurationMs === 0) { + sampleDurationMs = Date.now() - startedAt - timeToFirstByteMs; } + if (sampleDurationMs < 1) sampleDurationMs = 1; + const throughputKbps = Math.round((sampleBytes * 8) / sampleDurationMs); const ok = response.ok || status === 206; + return { ok, status, @@ -262,6 +404,10 @@ async function testSmallAsset(input: { timeToFirstByteMs, firstChunkBytes, reason: ok ? undefined : `HTTP ${status}`, + corsOk, + sampleBytes, + sampleDurationMs, + throughputKbps, }; } @@ -298,15 +444,47 @@ function recommendedStrategyFor(input: { firstSegmentOk: boolean; keyOk: boolean; strategy?: PlaybackStrategy | 'smart'; + runtime: 'vercel' | 'docker' | 'node' | 'unknown'; + segmentCorsOk: boolean; }): PlaybackHealthResult['recommendedStrategy'] { if (input.strategy === 'direct') return 'direct'; if (input.strategy === 'manifest-proxy') return 'manifest-proxy'; if (input.strategy === 'asset-proxy' || input.strategy === 'full-proxy') { return 'asset-proxy'; } - if (isMixedContentRisk(input.url, input.requestOrigin)) return 'asset-proxy'; - if (!input.manifestOk) return 'manifest-proxy'; - if (!input.firstSegmentOk || !input.keyOk) return 'asset-proxy'; + + // 混合内容限制:由于 HTTPS 加载 HTTP 会产生混合内容拦截 + if (isMixedContentRisk(input.url, input.requestOrigin)) { + if (input.runtime === 'vercel') { + return 'manifest-proxy'; // Vercel 不中转分片,这里作为妥协 + } + return 'asset-proxy'; + } + + // 1. 直连:如果所有条件都通过,且分片跨域 CORS 也可读 + if ( + input.manifestOk && + input.firstSegmentOk && + input.keyOk && + input.segmentCorsOk + ) { + return 'direct'; + } + + // 2. 仅代理清单 (manifest-proxy):当分片 CORS 可读,但列表需要服务端代理时 + if (!input.manifestOk && input.segmentCorsOk) { + return 'manifest-proxy'; + } + + // 3. 分片代理 (asset-proxy):当分片跨域不可读或首片请求被拦截时 + if (!input.segmentCorsOk || !input.firstSegmentOk || !input.keyOk) { + // NOTE: Vercel 等 Serverless 平台不中转分片,退回清单代理并由 UI 给予用户警告 + if (input.runtime === 'vercel') { + return 'manifest-proxy'; + } + return 'asset-proxy'; + } + return 'direct'; } @@ -321,6 +499,8 @@ export async function checkPlaybackHealth( const sourceConfig = input.sourceConfig || {}; const timeoutMs = getHealthTimeoutMs(sourceConfig); const nestedProxy = detectNestedProxy(url); + const runtime = detectRuntimeTarget(); + if (nestedProxy) warnings.push('nested-proxy'); if (isMixedContentRisk(url, input.requestOrigin)) warnings.push('mixed-content'); @@ -378,12 +558,14 @@ export async function checkPlaybackHealth( }); const urlType = inferPlaybackType(url); + // 非 HLS 源处理 (如 MP4/FLV) if (urlType !== 'hls' && !isHlsUrl(url)) { const firstSegment = await testSmallAsset({ url, headers, timeoutMs, kind: urlType === 'mp4' ? 'file' : 'segment', + requestOrigin: input.requestOrigin, }); const playable = firstSegment.ok && urlType !== 'flv'; const base: PlaybackHealthResult = { @@ -396,6 +578,8 @@ export async function checkPlaybackHealth( firstSegmentOk: firstSegment.ok, keyOk: true, strategy: input.strategy, + runtime, + segmentCorsOk: Boolean(firstSegment.corsOk), }), source: input.source, urlType, @@ -403,6 +587,7 @@ export async function checkPlaybackHealth( cors: { checkedInBrowser: false, reason: 'server-side-only', + segmentReadable: firstSegment.corsOk, }, timings, reason: playable @@ -418,6 +603,17 @@ export async function checkPlaybackHealth( : ['媒体文件首段不可访问,请换源或尝试代理。'], warnings, nestedProxy, + firstByteMs: firstSegment.timeToFirstByteMs, + sampleBytes: firstSegment.sampleBytes, + sampleDurationMs: firstSegment.sampleDurationMs, + throughputKbps: firstSegment.throughputKbps, + throughputKBps: + firstSegment.sampleBytes && firstSegment.sampleDurationMs + ? Math.round(firstSegment.sampleBytes / firstSegment.sampleDurationMs) + : undefined, + corsOk: Boolean(firstSegment.corsOk), + runtimeTarget: runtime, + smallAsset: firstSegment, }; const scored = scorePlaybackHealth(base); return { ...base, ...scored }; @@ -426,12 +622,17 @@ export async function checkPlaybackHealth( let manifestResponse: Response; let manifestText: string; let manifestDurationMs = 0; + let isRetryUsed = false; + let retryDebugInfo: Record | null = null; + try { const manifest = await fetchPlaylist({ url, headers, timeoutMs }); manifestResponse = manifest.response; manifestText = manifest.text; manifestDurationMs = manifest.durationMs; timings.manifestMs = manifestDurationMs; + isRetryUsed = Boolean(manifest.retryUsed); + retryDebugInfo = manifest.debugInfo || null; } catch (error) { const reason = error instanceof Error ? error.message : 'manifest-fetch-failed'; @@ -487,7 +688,10 @@ export async function checkPlaybackHealth( cors: { checkedInBrowser: false, reason: 'server-side-only' }, timings, reason: `manifest-http-${manifestStatus}`, - suggestions: ['m3u8 返回非 2xx 状态,建议尝试代理或换源。'], + suggestions: + manifestStatus === 403 + ? ['源站返回 403 拒绝访问,疑似防盗链或区域封锁。'] + : ['m3u8 返回非 2xx 状态,建议尝试代理或换源。'], warnings, nestedProxy, }; @@ -550,6 +754,7 @@ export async function checkPlaybackHealth( ); let selectedVariant = chooseDefaultHlsVariant(playlist.variants); + // 如果是 Master Playlist,获取对应的 Media Playlist if (playlist.isMaster && selectedVariant?.uri) { try { const media = await fetchPlaylist({ @@ -595,6 +800,7 @@ export async function checkPlaybackHealth( } } + // Key 检测 let keyResult: PlaybackHealthResult['key'] = { present: Boolean(playlist.key?.resolvedUri), }; @@ -605,6 +811,7 @@ export async function checkPlaybackHealth( headers, timeoutMs, kind: 'key', + requestOrigin: input.requestOrigin, }); timings.keyMs = Date.now() - keyStartedAt; keyResult = { @@ -616,17 +823,38 @@ export async function checkPlaybackHealth( }; } - let firstSegment: PlaybackHealthResult['firstSegment'] = { + // 首片分片检测 + let firstSegment: PlaybackHealthResult['firstSegment'] & { + corsOk?: boolean; + sampleBytes?: number; + sampleDurationMs?: number; + throughputKbps?: number; + } = { ok: Boolean(playlist.firstSegmentResolved), }; if (playlist.firstSegmentResolved && shouldTestFirstSegment()) { - firstSegment = await testSmallAsset({ + const segmentTest = await testSmallAsset({ url: playlist.firstSegmentResolved, headers, timeoutMs, kind: 'segment', + requestOrigin: input.requestOrigin, }); - timings.firstSegmentMs = firstSegment.timeToFirstByteMs || 0; + firstSegment = { + ok: segmentTest.ok, + status: segmentTest.status, + contentType: segmentTest.contentType, + contentLength: segmentTest.contentLength, + acceptRanges: segmentTest.acceptRanges, + timeToFirstByteMs: segmentTest.timeToFirstByteMs, + firstChunkBytes: segmentTest.firstChunkBytes, + reason: segmentTest.reason, + corsOk: segmentTest.corsOk, + sampleBytes: segmentTest.sampleBytes, + sampleDurationMs: segmentTest.sampleDurationMs, + throughputKbps: segmentTest.throughputKbps, + }; + timings.firstSegmentMs = segmentTest.timeToFirstByteMs || 0; } else if (!playlist.firstSegmentResolved) { firstSegment = { ok: false, @@ -634,9 +862,39 @@ export async function checkPlaybackHealth( }; } + // NOTE: 在这里根据测速结果执行 ABR 智能降级 + const speed = firstSegment.throughputKbps || 0; + if (playlist.isMaster && speed > 0 && playlist.variants.length > 0) { + // 筛选出带宽需求 <= 实际吞吐量 80% 的 variants + const acceptable = playlist.variants.filter((v) => { + const bw = v.bandwidth || 0; + return bw === 0 || bw / 1000 <= speed * 0.8; + }); + + if (acceptable.length > 0) { + selectedVariant = chooseDefaultHlsVariant(acceptable) || acceptable[0]; + } else { + // 吞吐量过低时强选最低码率以保障流畅播放 + const sortedByBw = [...playlist.variants].sort( + (a, b) => (a.bandwidth || 0) - (b.bandwidth || 0), + ); + selectedVariant = sortedByBw[0]; + } + } + + const selectedVariantBandwidthKbps = selectedVariant?.bandwidth + ? Math.round(selectedVariant.bandwidth / 1000) + : undefined; + const requiredBandwidthKbps = selectedVariantBandwidthKbps + ? Math.round(selectedVariantBandwidthKbps * 1.2) + : undefined; + const manifestOk = true; const keyOk = !keyResult.present || Boolean(keyResult.ok); const playable = manifestOk && firstSegment.ok && keyOk; + const segmentCorsOk = + firstSegment.corsOk === undefined ? true : firstSegment.corsOk; + const recommendedStrategy = recommendedStrategyFor({ url, requestOrigin: input.requestOrigin, @@ -644,8 +902,22 @@ export async function checkPlaybackHealth( firstSegmentOk: Boolean(firstSegment.ok), keyOk, strategy: input.strategy, + runtime, + segmentCorsOk, }); + // 吞吐量估算播放能力 + const estimatedPlayable = Boolean( + playable && + (!selectedVariantBandwidthKbps || + !speed || + speed >= selectedVariantBandwidthKbps * 0.4), + ); + + if (runtime === 'vercel' && !segmentCorsOk) { + warnings.push('vercel-unsuitable-for-segment-proxy'); + } + const base: PlaybackHealthResult = { ok: manifestOk, playable, @@ -658,7 +930,7 @@ export async function checkPlaybackHealth( contentType: manifestContentType, size: manifestSize, finalUrl: manifestFinalUrl, - isMaster: Boolean(selectedVariant), + isMaster: Boolean(playlist.isMaster), selectedVariant, targetDuration: playlist.targetDuration, }, @@ -667,6 +939,8 @@ export async function checkPlaybackHealth( cors: { checkedInBrowser: false, reason: 'server-side-only', + manifestReadable: true, // 服务端拉取成功代表跨域在网关处待定,至少可读 + segmentReadable: segmentCorsOk, }, timings, reason: playable @@ -681,6 +955,21 @@ export async function checkPlaybackHealth( : ['首个分片无法获取,建议尝试分片代理或换源。'], warnings, nestedProxy, + // 注入吞吐量和测速属性 + firstByteMs: firstSegment.timeToFirstByteMs, + sampleBytes: firstSegment.sampleBytes, + sampleDurationMs: firstSegment.sampleDurationMs, + throughputKbps: speed || undefined, + throughputKBps: + firstSegment.sampleBytes && firstSegment.sampleDurationMs + ? Math.round(firstSegment.sampleBytes / firstSegment.sampleDurationMs) + : undefined, + estimatedPlayable, + requiredBandwidthKbps, + selectedVariantBandwidthKbps, + corsOk: segmentCorsOk, + runtimeTarget: runtime, + smallAsset: firstSegment, debug: { checkedUrl: url, unwrappedProxy: unwrapped.wasProxy ? unwrapped.proxyPath : undefined, @@ -688,6 +977,9 @@ export async function checkPlaybackHealth( firstSegmentHost: playlist.firstSegmentResolved ? new URL(resolveUrl(url, playlist.firstSegmentResolved)).hostname : undefined, + retryUsed: isRetryUsed, + retryDebugInfo, + runtime, }, };