From 29b649b731e8e41ae4a88f9a56fe29840a0a09d5 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Fri, 20 Feb 2026 17:52:41 +0800 Subject: [PATCH] feat: Enhance IPTV stream proxy with robust M3U8 detection and add Cloudflare Pages configuration, alongside minor UI layout adjustments. --- README.md | 47 +++-- app/api/iptv/stream/route.ts | 175 ++++++++++++------- components/home/TagInput.tsx | 2 +- components/iptv/IPTVPlayer.tsx | 2 +- components/iptv/IPTVSourceManager.tsx | 2 +- components/search/TypeBadgeList.tsx | 30 ++-- components/settings/SettingsSection.tsx | 2 +- components/settings/import/JsonImportTab.tsx | 2 +- package-lock.json | 4 +- package.json | 2 +- wrangler.toml | 10 ++ 11 files changed, 180 insertions(+), 98 deletions(-) create mode 100644 wrangler.toml diff --git a/README.md b/README.md index 855bfa5..bf72d86 100644 --- a/README.md +++ b/README.md @@ -561,18 +561,13 @@ docker run -d -p 3000:3000 \ - **Build output directory**: 输入 `.vercel/output/static` - 点击 **Save and Deploy**。 -4. **关键步骤:修复运行时环境** - > *注意:此时部署虽然显示"Success",但你会发现访问网页会报错。这是因为缺少必要的兼容性配置。请按以下步骤修复:* +4. **等待部署完成**: + > 项目已内置 `wrangler.toml` 配置文件,其中包含 `nodejs_compat` 兼容性标志,无需手动配置。 - - 进入 **[项目设置页面](https://dash.cloudflare.com/?to=/:account/pages/view/kvideo/settings/production)** (如果你的项目名不是 kvideo,请在控制台手动查找 Settings -> Functions)。 - - 拉到页面底部找到 **Compatibility flags** 部分。 - - 添加标志:`nodejs_compat` - -5. **重试部署 (生效配置)**: - - 回到 **[项目概览页面](https://dash.cloudflare.com/?to=/:account/pages/view/kvideo)**。 - - 在 **Deployments** 列表中,找到最新的那次部署。 - - 点击右侧的三个点 `...` 菜单,选择 **Retry deployment**。 - - 等待新的部署完成后,你的 KVideo 就部署成功了! +5. **如果部署失败**: + > 如果看到 `Unknown internal error occurred` 错误,这通常是 Cloudflare 的临时服务端问题。请在 **Deployments** 列表中找到最新部署,点击 `...` 菜单选择 **Retry deployment** 重试即可。 + > + > 如果首次部署仍有问题,可尝试在 **[项目设置页面](https://dash.cloudflare.com/?to=/:account/pages/view/kvideo/settings/production)** 底部的 **Compatibility flags** 中手动添加 `nodejs_compat`,然后重新部署。 #### 选项 3:Docker 部署 @@ -672,6 +667,8 @@ npm start 或通过 U 盘、文件管理器等方式侧载安装。 > **注意**:此 APK 是一个 WebView 壳应用,需要你的 KVideo 实例已经部署并可访问。APK 本身不包含 KVideo 代码,仅作为 TV 端的浏览器入口。 +> +> **最低系统要求**:Android 8.0 (API 26) 及以上。Android 7.0 及更低版本的 WebView 不支持本项目使用的 ES2017+ JavaScript 特性和现代 CSS,可能导致白屏。如遇白屏问题,请升级系统 WebView 或使用 Android 8.0+ 设备。 #### 选项 6:Apple TV 应用构建 @@ -739,6 +736,34 @@ npm start > **自动化部署**:本项目使用 GitHub Actions 自动构建和发布 Docker 镜像。每次代码推送到 main 分支时,会自动构建多架构镜像并推送到 Docker Hub。 +## 常见问题 + +### Cloudflare Pages 部署报 "Unknown internal error" + +这是 Cloudflare 的临时服务端错误,与代码无关。请在 Deployments 列表中重试部署即可。项目已内置 `wrangler.toml` 配置 `nodejs_compat` 兼容性标志。 + +### IPv6 环境下 HTTPS 访问视频无法播放 + +如果你的网络使用 IPv6 访问,且通过路由器端口映射(如 20443 → 443),请确保: +- 反向代理(如 Caddy/Nginx)正确监听 IPv6 地址 +- 路由器的 IPv6 端口映射规则与 IPv4 一致 +- 如使用非标准端口,确保 IPv6 防火墙规则也已放行 + +这是网络/反向代理配置问题,非 KVideo 代码问题。 + +### Android 7.0 设备白屏 + +Android 7.0 (API 24) 的 WebView 基于 Chrome 51,不支持本项目使用的现代 JavaScript(ES2017+)和 CSS 特性。最低要求 Android 8.0 (API 26) 及以上。 + +### IPTV 部分直播流无法播放 + +浏览器原生仅支持 HLS (m3u8) 和部分 MP4/WebM 格式。以下格式在浏览器中不受支持: +- RTMP/RTSP 流(需要专用播放器如 VLC/PotPlayer) +- 某些加密或受 DRM 保护的流 +- 需要特定客户端验证的流 + +KVideo 已内置代理服务器自动处理 CORS 问题和 HLS URL 重写,大部分 HLS 直播流应能正常播放。 + ## 贡献代码 我们非常欢迎各种形式的贡献!无论是报告 Bug、提出新功能建议、改进文档,还是提交代码,你的每一份贡献都让这个项目变得更好。 diff --git a/app/api/iptv/stream/route.ts b/app/api/iptv/stream/route.ts index b2da449..34d3a6b 100644 --- a/app/api/iptv/stream/route.ts +++ b/app/api/iptv/stream/route.ts @@ -1,13 +1,17 @@ /** * IPTV Stream Proxy API Route * Proxies HLS manifests and media segments to avoid CORS issues. - * For .m3u8 manifests, rewrites URLs to also route through this proxy. + * For .m3u8/.m3u manifests, rewrites URLs to also route through this proxy. + * Supports HLS, MPEG-TS, and other stream formats with automatic content detection. */ import { NextRequest, NextResponse } from 'next/server'; export const runtime = 'edge'; +const STREAM_TIMEOUT_MS = 20000; +const REALISTIC_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + function resolveUrl(base: string, relative: string): string { if (relative.startsWith('http://') || relative.startsWith('https://')) { return relative; @@ -21,10 +25,18 @@ function resolveUrl(base: string, relative: string): string { } } +function buildProxyBase(customUa?: string | null, customReferer?: string | null): string { + let base = '/api/iptv/stream?'; + if (customUa) base += `ua=${encodeURIComponent(customUa)}&`; + if (customReferer) base += `referer=${encodeURIComponent(customReferer)}&`; + base += 'url='; + return base; +} + function rewriteM3u8(content: string, baseUrl: string, proxyBase: string): string { return content.split('\n').map(line => { const trimmed = line.trim(); - // Skip empty lines and comments (but process URI= in EXT tags) + // Skip empty lines if (!trimmed) return line; // Rewrite URI="..." in EXT-X-KEY, EXT-X-MAP, etc. @@ -44,6 +56,40 @@ function rewriteM3u8(content: string, baseUrl: string, proxyBase: string): strin }).join('\n'); } +function isM3u8Url(url: string): boolean { + const lower = url.toLowerCase().split('?')[0]; + return lower.endsWith('.m3u8') || lower.endsWith('.m3u'); +} + +function isM3u8ContentType(contentType: string): boolean { + const lower = contentType.toLowerCase(); + return lower.includes('mpegurl') || + lower.includes('x-mpegurl') || + lower.includes('vnd.apple.mpegurl') || + lower.includes('x-scpls'); +} + +function isAmbiguousContentType(contentType: string): boolean { + if (!contentType) return true; + const lower = contentType.toLowerCase(); + return lower.includes('text/plain') || + lower.includes('application/octet-stream') || + lower.includes('binary/octet-stream') || + lower.includes('text/html'); +} + +function isM3u8Content(text: string): boolean { + const trimmed = text.trimStart(); + return trimmed.startsWith('#EXTM3U') || trimmed.startsWith('#EXT-X-'); +} + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges', +}; + export async function GET(request: NextRequest) { const url = request.nextUrl.searchParams.get('url'); const customUa = request.nextUrl.searchParams.get('ua'); @@ -56,10 +102,11 @@ export async function GET(request: NextRequest) { try { const parsedUrl = new URL(url); const fetchHeaders: Record = { - 'User-Agent': customUa || 'Mozilla/5.0 (compatible; KVideo/1.0)', + 'User-Agent': customUa || REALISTIC_USER_AGENT, 'Accept': '*/*', 'Referer': customReferer || `${parsedUrl.protocol}//${parsedUrl.host}/`, 'Origin': `${parsedUrl.protocol}//${parsedUrl.host}`, + 'Connection': 'keep-alive', }; // Forward Range header for partial content requests @@ -69,7 +116,7 @@ export async function GET(request: NextRequest) { } const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15000); + const timeout = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS); const response = await fetch(url, { headers: fetchHeaders, @@ -78,103 +125,101 @@ export async function GET(request: NextRequest) { }); clearTimeout(timeout); - if (!response.ok) { + if (!response.ok && response.status !== 206) { return NextResponse.json( - { error: `Failed to fetch: ${response.status}` }, + { error: `Upstream returned ${response.status}` }, { status: response.status } ); } const contentType = response.headers.get('content-type') || ''; - let isM3u8 = url.includes('.m3u8') || - contentType.includes('mpegurl') || - contentType.includes('x-mpegURL'); + let isM3u8 = isM3u8Url(url) || isM3u8ContentType(contentType); + const proxyBase = buildProxyBase(customUa, customReferer); // If content-type is ambiguous, check the response body for M3U header - // Use clone() to avoid consuming the original body for binary streams - if (!isM3u8 && (contentType.includes('text/plain') || contentType.includes('application/octet-stream') || !contentType)) { + if (!isM3u8 && isAmbiguousContentType(contentType)) { const cloned = response.clone(); - const text = await cloned.text(); - if (text.trimStart().startsWith('#EXTM3U') || text.trimStart().startsWith('#EXT-X-')) { - isM3u8 = true; + // Read first 1KB to check for M3U8 header without consuming too much + const reader = cloned.body?.getReader(); + if (reader) { + const { value } = await reader.read(); + reader.releaseLock(); + if (value) { + const text = new TextDecoder().decode(value.slice(0, 1024)); + if (isM3u8Content(text)) { + isM3u8 = true; + } + } } - // For detected M3U8 from body check, process inline + if (isM3u8) { - const proxyBase = `/api/iptv/stream?${customUa ? `ua=${encodeURIComponent(customUa)}&` : ''}${customReferer ? `referer=${encodeURIComponent(customReferer)}&` : ''}url=`; - const rewritten = rewriteM3u8(text, url, proxyBase); + // Re-read the full body for M3U8 rewriting + const fullText = await response.text(); + const rewritten = rewriteM3u8(fullText, url, proxyBase); return new NextResponse(rewritten, { status: 200, headers: { 'Content-Type': 'application/vnd.apple.mpegurl', - 'Cache-Control': 'no-cache', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': '*', + 'Cache-Control': 'no-cache, no-store', + ...CORS_HEADERS, }, }); } - // Not M3U8, stream original binary body directly to preserve data integrity - const body = response.body; - return new NextResponse(body, { + + // Not M3U8 — stream original binary body directly + return new NextResponse(response.body, { status: response.status, headers: { 'Content-Type': contentType || 'video/mp2t', - 'Cache-Control': 'public, max-age=60', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': '*', + 'Cache-Control': 'no-cache', + ...CORS_HEADERS, }, }); } - const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': '*', - }; - if (isM3u8) { // Parse and rewrite manifest const text = await response.text(); - const proxyBase = `/api/iptv/stream?${customUa ? `ua=${encodeURIComponent(customUa)}&` : ''}${customReferer ? `referer=${encodeURIComponent(customReferer)}&` : ''}url=`; const rewritten = rewriteM3u8(text, url, proxyBase); return new NextResponse(rewritten, { status: 200, headers: { 'Content-Type': 'application/vnd.apple.mpegurl', - 'Cache-Control': 'no-cache', - ...corsHeaders, + 'Cache-Control': 'no-cache, no-store', + ...CORS_HEADERS, }, }); - } else { - // Pipe through media segments directly - const body = response.body; - const forwardContentType = contentType || 'video/mp2t'; - - const responseHeaders: Record = { - 'Content-Type': forwardContentType, - 'Cache-Control': 'public, max-age=60', - ...corsHeaders, - }; - - // Forward range-related headers - const contentRange = response.headers.get('content-range'); - if (contentRange) responseHeaders['Content-Range'] = contentRange; - const acceptRanges = response.headers.get('accept-ranges'); - if (acceptRanges) responseHeaders['Accept-Ranges'] = acceptRanges; - const contentLength = response.headers.get('content-length'); - if (contentLength) responseHeaders['Content-Length'] = contentLength; - - return new NextResponse(body, { - status: response.status, - headers: responseHeaders, - }); } + + // Non-M3U8 media content — pipe through directly + const body = response.body; + const forwardContentType = contentType || 'video/mp2t'; + + const responseHeaders: Record = { + 'Content-Type': forwardContentType, + 'Cache-Control': 'public, max-age=60', + ...CORS_HEADERS, + }; + + // Forward range-related headers + const contentRange = response.headers.get('content-range'); + if (contentRange) responseHeaders['Content-Range'] = contentRange; + const acceptRanges = response.headers.get('accept-ranges'); + if (acceptRanges) responseHeaders['Accept-Ranges'] = acceptRanges; + const contentLength = response.headers.get('content-length'); + if (contentLength) responseHeaders['Content-Length'] = contentLength; + + return new NextResponse(body, { + status: response.status, + headers: responseHeaders, + }); } catch (e) { + const message = e instanceof Error ? e.message : 'Unknown error'; + const isTimeout = message.includes('abort'); return NextResponse.json( - { error: 'Failed to proxy stream' }, - { status: 500 } + { error: isTimeout ? 'Stream request timed out' : 'Failed to proxy stream' }, + { status: isTimeout ? 504 : 502 } ); } } @@ -182,10 +227,6 @@ export async function GET(request: NextRequest) { export async function OPTIONS() { return new NextResponse(null, { status: 204, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': '*', - }, + headers: CORS_HEADERS, }); } diff --git a/components/home/TagInput.tsx b/components/home/TagInput.tsx index 50fa542..60b9314 100644 --- a/components/home/TagInput.tsx +++ b/components/home/TagInput.tsx @@ -14,7 +14,7 @@ export function TagInput({ onAddTag, }: TagInputProps) { return ( -
+

{error}

-
+
{hasOverflow && (