Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

## 🌍 环境变量

### 基础配置
Expand Down
54 changes: 52 additions & 2 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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<string, any>) {
const fields: Record<string, any> = {};
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;
Expand Down Expand Up @@ -2708,6 +2739,7 @@ const VideoSourceConfig = ({
detail: payload.detail || '',
disabled: false,
is_adult: payload.is_adult || false,
...getSourcePlaybackFields(payload),
from: 'custom',
};
sources.push(newSource);
Expand Down Expand Up @@ -2904,6 +2936,7 @@ const VideoSourceConfig = ({
api: newSource.api,
detail: newSource.detail,
is_adult: newSource.is_adult || false,
...getSourcePlaybackFields(newSource),
});
setNewSource({
name: '',
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -3375,14 +3412,23 @@ 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++;
result.details.push({
name: item.name,
key: item.key,
status: 'success',
reason:
!item.is_adult && isProbablyAdultSourceName(item.name)
? '检测到疑似成人源,已自动标记成人内容'
: undefined,
});
} catch (err) {
result.failed++;
Expand Down Expand Up @@ -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<DataSource>),
from: 'config' as const,
disabled: false,
}),
Expand Down
18 changes: 18 additions & 0 deletions src/app/api/admin/source/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ interface BaseBody {
action?: Action;
}

function pickSourcePlaybackFields(body: Record<string, any>) {
const fields: Record<string, any> = {};
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();
Expand Down Expand Up @@ -110,6 +127,7 @@ export async function POST(request: NextRequest) {
api,
detail,
is_adult: is_adult || false,
...pickSourcePlaybackFields(body),
from: 'custom',
disabled: false,
});
Expand Down
90 changes: 90 additions & 0 deletions src/app/api/playback/health/route.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, any>> {
if (request.method === 'POST') {
return (await request.json().catch(() => ({}))) as Record<string, any>;
}
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);
}
75 changes: 75 additions & 0 deletions src/app/api/playback/report/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
}

const records = new Map<string, ServerQualityRecord>();

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<string, any>;
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,
},
},
);
}
Loading