diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 11d2095..d4a439c 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -358,6 +358,7 @@ interface DataSource { detail?: string; disabled?: boolean; from: 'config' | 'custom'; + proxyMode?: boolean; } // 直播源数据类型 @@ -3490,6 +3491,53 @@ const VideoSourceConfig = ({ }); }; + const handleToggleProxyMode = (key: string) => { + const target = sources.find((s) => s.key === key); + if (!target) return; + + // 更新本地状态 + setSources((prev) => + prev.map((s) => + s.key === key ? { ...s, proxyMode: !s.proxyMode } : s + ) + ); + + // 调用API更新 + withLoading(`toggleProxyMode_${key}`, async () => { + try { + const response = await fetch('/api/admin/source', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'toggle_proxy_mode', + key, + }), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || `操作失败: ${response.status}`); + } + + await refreshConfig(); + } catch (error) { + // 失败时回滚本地状态 + setSources((prev) => + prev.map((s) => + s.key === key ? { ...s, proxyMode: !s.proxyMode } : s + ) + ); + showError( + error instanceof Error ? error.message : '切换代理模式失败', + showAlert + ); + throw error; + } + }).catch(() => { + console.error('操作失败', 'toggle_proxy_mode', key); + }); + }; + const handleAddSource = () => { if (!newSource.name || !newSource.key || !newSource.api) return; withLoading('addSource', async () => { @@ -3777,6 +3825,31 @@ const VideoSourceConfig = ({ {!source.disabled ? '启用中' : '已禁用'} + + + {(() => { const status = getValidationStatus(source.key); @@ -4128,6 +4201,9 @@ const VideoSourceConfig = ({ 状态 + + 代理模式 + 有效性 diff --git a/src/app/api/admin/source/route.ts b/src/app/api/admin/source/route.ts index f5c9ca4..f980255 100644 --- a/src/app/api/admin/source/route.ts +++ b/src/app/api/admin/source/route.ts @@ -9,7 +9,7 @@ import { db } from '@/lib/db'; export const runtime = 'nodejs'; // 支持的操作类型 -type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete'; +type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete' | 'toggle_proxy_mode'; interface BaseBody { action?: Action; @@ -37,7 +37,7 @@ export async function POST(request: NextRequest) { const username = authInfo.username; // 基础校验 - const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete']; + const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete', 'toggle_proxy_mode']; if (!username || !action || !ACTIONS.includes(action)) { return NextResponse.json({ error: '参数格式错误' }, { status: 400 }); } @@ -226,6 +226,16 @@ export async function POST(request: NextRequest) { adminConfig.SourceConfig = newList; break; } + case 'toggle_proxy_mode': { + const { key } = body as { key?: string }; + if (!key) + return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 }); + const entry = adminConfig.SourceConfig.find((s) => s.key === key); + if (!entry) + return NextResponse.json({ error: '源不存在' }, { status: 404 }); + entry.proxyMode = !entry.proxyMode; + break; + } default: return NextResponse.json({ error: '未知操作' }, { status: 400 }); } diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index bc043f3..278db00 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -143,6 +143,7 @@ export async function GET(request: NextRequest) { desc: folderMeta?.overview || '', episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(folderName)}&fileName=${encodeURIComponent(ep.fileName)}`), episodes_titles: episodes.map((ep) => ep.title), + proxyMode: false, // openlist 源不使用代理模式 }; return NextResponse.json(result); @@ -167,9 +168,16 @@ export async function GET(request: NextRequest) { } const result = await getDetailFromApi(apiSite, id); + + // 添加 proxyMode 到返回结果 + const resultWithProxy = { + ...result, + proxyMode: apiSite.proxyMode || false, + }; + const cacheTime = await getCacheTime(); - return NextResponse.json(result, { + return NextResponse.json(resultWithProxy, { headers: { 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, diff --git a/src/app/api/proxy/vod/key/route.ts b/src/app/api/proxy/vod/key/route.ts new file mode 100644 index 0000000..eb7762d --- /dev/null +++ b/src/app/api/proxy/vod/key/route.ts @@ -0,0 +1,58 @@ +/* eslint-disable no-console,@typescript-eslint/no-explicit-any */ + +import { NextResponse } from "next/server"; + +import { getConfig } from "@/lib/config"; + +export const runtime = 'nodejs'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + const source = searchParams.get('source'); + + if (!url) { + return NextResponse.json({ error: 'Missing url' }, { status: 400 }); + } + + if (!source) { + return NextResponse.json({ error: 'Missing source' }, { status: 400 }); + } + + // 检查该视频源是否启用了代理模式 + const config = await getConfig(); + const videoSource = config.SourceConfig?.find((s: any) => s.key === source); + + if (!videoSource) { + return NextResponse.json({ error: 'Source not found' }, { status: 404 }); + } + + if (!videoSource.proxyMode) { + return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 }); + } + + try { + const decodedUrl = decodeURIComponent(url); + const response = await fetch(decodedUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': decodedUrl, + }, + }); + + if (!response.ok) { + return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 }); + } + + const headers = new Headers(); + headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream'); + headers.set('Access-Control-Allow-Origin', '*'); + headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept'); + headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range'); + + return new Response(response.body, { headers }); + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 }); + } +} diff --git a/src/app/api/proxy/vod/m3u8/route.ts b/src/app/api/proxy/vod/m3u8/route.ts new file mode 100644 index 0000000..9e3590e --- /dev/null +++ b/src/app/api/proxy/vod/m3u8/route.ts @@ -0,0 +1,191 @@ +/* eslint-disable no-console,@typescript-eslint/no-explicit-any */ + +import { NextResponse } from "next/server"; + +import { getConfig } from "@/lib/config"; +import { getBaseUrl, resolveUrl } from "@/lib/live"; + +export const runtime = 'nodejs'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + const source = searchParams.get('source'); // 视频源key + + if (!url) { + return NextResponse.json({ error: 'Missing url' }, { status: 400 }); + } + + if (!source) { + return NextResponse.json({ error: 'Missing source' }, { status: 400 }); + } + + // 检查该视频源是否启用了代理模式 + const config = await getConfig(); + const videoSource = config.SourceConfig?.find((s: any) => s.key === source); + + if (!videoSource) { + return NextResponse.json({ error: 'Source not found' }, { status: 404 }); + } + + if (!videoSource.proxyMode) { + return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 }); + } + + let response: Response | null = null; + let responseUsed = false; + + try { + const decodedUrl = decodeURIComponent(url); + + response = await fetch(decodedUrl, { + cache: 'no-cache', + redirect: 'follow', + credentials: 'same-origin', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': decodedUrl, + }, + }); + + if (!response.ok) { + return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 }); + } + + const contentType = response.headers.get('Content-Type') || ''; + // rewrite m3u8 + if (contentType.toLowerCase().includes('mpegurl') || contentType.toLowerCase().includes('octet-stream') || decodedUrl.includes('.m3u8')) { + // 获取最终的响应URL(处理重定向后的URL) + const finalUrl = response.url; + const m3u8Content = await response.text(); + responseUsed = true; // 标记 response 已被使用 + + // 使用最终的响应URL作为baseUrl,而不是原始的请求URL + const baseUrl = getBaseUrl(finalUrl); + + // 重写 M3U8 内容 + const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, source); + + const headers = new Headers(); + headers.set('Content-Type', contentType || 'application/vnd.apple.mpegurl'); + headers.set('Access-Control-Allow-Origin', '*'); + headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept'); + headers.set('Cache-Control', 'no-cache'); + headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range'); + return new Response(modifiedContent, { headers }); + } + // just proxy + const headers = new Headers(); + headers.set('Content-Type', response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl'); + headers.set('Access-Control-Allow-Origin', '*'); + headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept'); + headers.set('Cache-Control', 'no-cache'); + headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range'); + + // 直接返回视频流 + return new Response(response.body, { + status: 200, + headers, + }); + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 }); + } finally { + // 确保 response 被正确关闭以释放资源 + if (response && !responseUsed) { + try { + response.body?.cancel(); + } catch (error) { + // 忽略关闭时的错误 + console.warn('Failed to close response body:', error); + } + } + } +} + +function rewriteM3U8Content(content: string, baseUrl: string, req: Request, source: string) { + // 从 referer 头提取协议信息 + const referer = req.headers.get('referer'); + let protocol = 'http'; + if (referer) { + try { + const refererUrl = new URL(referer); + protocol = refererUrl.protocol.replace(':', ''); + } catch (error) { + // ignore + } + } + + const host = req.headers.get('host'); + const proxyBase = `${protocol}://${host}/api/proxy/vod`; + + const lines = content.split('\n'); + const rewrittenLines: string[] = []; + + for (let i = 0; i < lines.length; i++) { + let line = lines[i].trim(); + + // 处理 TS 片段 URL 和其他媒体文件 + if (line && !line.startsWith('#')) { + const resolvedUrl = resolveUrl(baseUrl, line); + const proxyUrl = `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}&source=${source}`; + rewrittenLines.push(proxyUrl); + continue; + } + + // 处理 EXT-X-MAP 标签中的 URI + if (line.startsWith('#EXT-X-MAP:')) { + line = rewriteMapUri(line, baseUrl, proxyBase, source); + } + + // 处理 EXT-X-KEY 标签中的 URI + if (line.startsWith('#EXT-X-KEY:')) { + line = rewriteKeyUri(line, baseUrl, proxyBase, source); + } + + // 处理嵌套的 M3U8 文件 (EXT-X-STREAM-INF) + if (line.startsWith('#EXT-X-STREAM-INF:')) { + rewrittenLines.push(line); + // 下一行通常是 M3U8 URL + if (i + 1 < lines.length) { + i++; + const nextLine = lines[i].trim(); + if (nextLine && !nextLine.startsWith('#')) { + const resolvedUrl = resolveUrl(baseUrl, nextLine); + const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}&source=${source}`; + rewrittenLines.push(proxyUrl); + } else { + rewrittenLines.push(nextLine); + } + } + continue; + } + + rewrittenLines.push(line); + } + + return rewrittenLines.join('\n'); +} + +function rewriteMapUri(line: string, baseUrl: string, proxyBase: string, source: string) { + const uriMatch = line.match(/URI="([^"]+)"/); + if (uriMatch) { + const originalUri = uriMatch[1]; + const resolvedUrl = resolveUrl(baseUrl, originalUri); + const proxyUrl = `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}&source=${source}`; + return line.replace(uriMatch[0], `URI="${proxyUrl}"`); + } + return line; +} + +function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string, source: string) { + const uriMatch = line.match(/URI="([^"]+)"/); + if (uriMatch) { + const originalUri = uriMatch[1]; + const resolvedUrl = resolveUrl(baseUrl, originalUri); + const proxyUrl = `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}&source=${source}`; + return line.replace(uriMatch[0], `URI="${proxyUrl}"`); + } + return line; +} diff --git a/src/app/api/proxy/vod/segment/route.ts b/src/app/api/proxy/vod/segment/route.ts new file mode 100644 index 0000000..8b8a734 --- /dev/null +++ b/src/app/api/proxy/vod/segment/route.ts @@ -0,0 +1,153 @@ +/* eslint-disable no-console,@typescript-eslint/no-explicit-any */ + +import { NextResponse } from "next/server"; + +import { getConfig } from "@/lib/config"; + +export const runtime = 'nodejs'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + const source = searchParams.get('source'); + + if (!url) { + return NextResponse.json({ error: 'Missing url' }, { status: 400 }); + } + + if (!source) { + return NextResponse.json({ error: 'Missing source' }, { status: 400 }); + } + + // 检查该视频源是否启用了代理模式 + const config = await getConfig(); + const videoSource = config.SourceConfig?.find((s: any) => s.key === source); + + if (!videoSource) { + return NextResponse.json({ error: 'Source not found' }, { status: 404 }); + } + + if (!videoSource.proxyMode) { + return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 }); + } + + let response: Response | null = null; + let reader: ReadableStreamDefaultReader | null = null; + + try { + const decodedUrl = decodeURIComponent(url); + response = await fetch(decodedUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': decodedUrl, + }, + }); + if (!response.ok) { + return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 }); + } + + const headers = new Headers(); + headers.set('Content-Type', response.headers.get('Content-Type') || 'video/mp2t'); + headers.set('Access-Control-Allow-Origin', '*'); + headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept'); + headers.set('Accept-Ranges', 'bytes'); + headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range'); + const contentLength = response.headers.get('content-length'); + if (contentLength) { + headers.set('Content-Length', contentLength); + } + + // 使用流式传输,避免占用内存 + const stream = new ReadableStream({ + start(controller) { + if (!response?.body) { + controller.close(); + return; + } + + reader = response.body.getReader(); + const isCancelled = false; + + function pump() { + if (isCancelled || !reader) { + return; + } + + reader.read().then(({ done, value }) => { + if (isCancelled) { + return; + } + + if (done) { + controller.close(); + cleanup(); + return; + } + + controller.enqueue(value); + pump(); + }).catch((error) => { + if (!isCancelled) { + controller.error(error); + cleanup(); + } + }); + } + + function cleanup() { + if (reader) { + try { + reader.releaseLock(); + } catch (e) { + // reader 可能已经被释放,忽略错误 + } + reader = null; + } + } + + pump(); + }, + cancel() { + // 当流被取消时,确保释放所有资源 + if (reader) { + try { + reader.releaseLock(); + } catch (e) { + // reader 可能已经被释放,忽略错误 + } + reader = null; + } + + if (response?.body) { + try { + response.body.cancel(); + } catch (e) { + // 忽略取消时的错误 + } + } + } + }); + + return new Response(stream, { headers }); + } catch (error) { + // 确保在错误情况下也释放资源 + if (reader) { + try { + (reader as ReadableStreamDefaultReader).releaseLock(); + } catch (e) { + // 忽略错误 + } + } + + if (response?.body) { + try { + response.body.cancel(); + } catch (e) { + // 忽略错误 + } + } + + return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 }); + } +} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 90aabe8..4c65317 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -681,6 +681,9 @@ function PlayPageClient() { // 视频播放地址 const [videoUrl, setVideoUrl] = useState(''); + // 视频源代理模式状态 + const [sourceProxyMode, setSourceProxyMode] = useState(false); + // 总集数 const totalEpisodes = detail?.episodes?.length || 0; @@ -1048,9 +1051,13 @@ function PlayPageClient() { const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex); if (hasLocalFile) { - // 使用本地代理接口,URL以.m3u8结尾以便Artplayer自动识别 + // 使用本地代理接口,URL以.m3u8结尾以便Artplayer自动识别 newUrl = `/api/offline-download/local/${currentSource}/${currentId}/${episodeIndex}/playlist.m3u8`; console.log('使用本地下载文件播放:', newUrl); + } else if (sourceProxyMode && newUrl) { + // 如果视频源启用了代理模式,且不是本地下载,则通过代理播放 + newUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(newUrl)}&source=${encodeURIComponent(currentSource)}`; + console.log('使用代理模式播放:', newUrl); } if (newUrl !== videoUrl) { @@ -2041,6 +2048,7 @@ function PlayPageClient() { setVideoCover(detailData.poster); setVideoDoubanId(detailData.douban_id || 0); setDetail(detailData); + setSourceProxyMode(detailData.proxyMode || false); // 从 detail 数据中读取代理模式 if (currentEpisodeIndex >= detailData.episodes.length) { setCurrentEpisodeIndex(0); } @@ -2145,6 +2153,7 @@ function PlayPageClient() { setVideoCover(targetSource.poster); setVideoDoubanId(targetSource.douban_id || 0); setDetail(targetSource); + setSourceProxyMode(targetSource.proxyMode || false); // 从 detail 数据中读取代理模式 // 更新集数 if (targetEpisode >= 0 && targetEpisode < targetSource.episodes.length) { @@ -2274,6 +2283,7 @@ function PlayPageClient() { setCurrentSource(newSource); setCurrentId(newId); setDetail(newDetail); + setSourceProxyMode(newDetail.proxyMode || false); // 从 detail 数据中读取代理模式 setCurrentEpisodeIndex(targetIndex); } catch (err) { // 隐藏换源加载状态 diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 070c40d..7b35077 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -72,6 +72,7 @@ export interface AdminConfig { detail?: string; from: 'config' | 'custom'; disabled?: boolean; + proxyMode?: boolean; // 代理模式开关:启用后由服务器代理m3u8和ts分片 }[]; CustomCategories: { name?: string; diff --git a/src/lib/config.ts b/src/lib/config.ts index 4762d6e..665a914 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -9,6 +9,7 @@ export interface ApiSite { api: string; name: string; detail?: string; + proxyMode?: boolean; } export interface LiveCfg { @@ -522,6 +523,7 @@ export async function getAvailableApiSites(user?: string): Promise { name: s.name, api: s.api, detail: s.detail, + proxyMode: s.proxyMode, })); } @@ -543,6 +545,7 @@ export async function getAvailableApiSites(user?: string): Promise { name: s.name, api: s.api, detail: s.detail, + proxyMode: s.proxyMode, })); } } diff --git a/src/lib/downstream.ts b/src/lib/downstream.ts index 348b9e6..9652f61 100644 --- a/src/lib/downstream.ts +++ b/src/lib/downstream.ts @@ -117,6 +117,7 @@ async function searchWithCache( douban_id: item.vod_douban_id, vod_remarks: item.vod_remarks, vod_total: item.vod_total, + proxyMode: apiSite.proxyMode || false, }; }); @@ -288,6 +289,7 @@ export async function getDetailFromApi( douban_id: videoDetail.vod_douban_id, vod_remarks: videoDetail.vod_remarks, vod_total: videoDetail.vod_total, + proxyMode: apiSite.proxyMode || false, }; } @@ -370,5 +372,6 @@ async function handleSpecialSourceDetail( douban_id: 0, vod_remarks: undefined, vod_total: undefined, + proxyMode: apiSite.proxyMode || false, }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index df20898..0b47b4f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -128,6 +128,7 @@ export interface SearchResult { douban_id?: number; vod_remarks?: string; // 视频备注信息(如"全80集"、"更新至25集"等) vod_total?: number; // 总集数 + proxyMode?: boolean; // 代理模式:启用后由服务器代理m3u8和ts分片 } // 豆瓣数据结构