feat: Enhance IPTV stream proxy with robust M3U8 detection and add Cloudflare Pages configuration, alongside minor UI layout adjustments.

This commit is contained in:
kuekhaoyang
2026-02-20 17:52:41 +08:00
parent 98599bb972
commit 29b649b731
11 changed files with 180 additions and 98 deletions
+36 -11
View File
@@ -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`,然后重新部署。
#### 选项 3Docker 部署
@@ -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+ 设备。
#### 选项 6Apple 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,不支持本项目使用的现代 JavaScriptES2017+)和 CSS 特性。最低要求 Android 8.0 (API 26) 及以上。
### IPTV 部分直播流无法播放
浏览器原生仅支持 HLS (m3u8) 和部分 MP4/WebM 格式。以下格式在浏览器中不受支持:
- RTMP/RTSP 流(需要专用播放器如 VLC/PotPlayer
- 某些加密或受 DRM 保护的流
- 需要特定客户端验证的流
KVideo 已内置代理服务器自动处理 CORS 问题和 HLS URL 重写,大部分 HLS 直播流应能正常播放。
## 贡献代码
我们非常欢迎各种形式的贡献!无论是报告 Bug、提出新功能建议、改进文档,还是提交代码,你的每一份贡献都让这个项目变得更好。
+93 -52
View File
@@ -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<string, string> = {
'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,84 +125,81 @@ 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-')) {
// 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': '*',
},
});
}
// Not M3U8, stream original binary body directly to preserve data integrity
const body = response.body;
return new NextResponse(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, no-store',
...CORS_HEADERS,
},
});
}
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': '*',
};
// Not M3U8 — stream original binary body directly
return new NextResponse(response.body, {
status: response.status,
headers: {
'Content-Type': contentType || 'video/mp2t',
'Cache-Control': 'no-cache',
...CORS_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
}
// Non-M3U8 media content — pipe through directly
const body = response.body;
const forwardContentType = contentType || 'video/mp2t';
const responseHeaders: Record<string, string> = {
'Content-Type': forwardContentType,
'Cache-Control': 'public, max-age=60',
...corsHeaders,
...CORS_HEADERS,
};
// Forward range-related headers
@@ -170,11 +214,12 @@ export async function GET(request: NextRequest) {
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,
});
}
+1 -1
View File
@@ -14,7 +14,7 @@ export function TagInput({
onAddTag,
}: TagInputProps) {
return (
<div className="mb-6 flex gap-2">
<div className="mb-6 flex gap-2 flex-wrap">
<input
type="text"
value={newTagInput}
+1 -1
View File
@@ -688,7 +688,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
<div className="text-center" data-controls>
<p className="text-red-400 text-sm mb-3">{error}</p>
<div className="flex gap-2 justify-center">
<div className="flex gap-2 flex-wrap justify-center">
<button
onClick={(e) => { e.stopPropagation(); loadChannel(currentUrl); }}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white text-sm transition-colors cursor-pointer"
+1 -1
View File
@@ -70,7 +70,7 @@ export function IPTVSourceManager() {
<h3 className="text-sm font-medium text-[var(--text-color)]">
</h3>
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap">
<button
onClick={() => refreshSources()}
disabled={isLoading || sources.length === 0}
+16 -10
View File
@@ -36,6 +36,14 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
const badgeContainerRef = useRef<HTMLDivElement>(null);
const badgeRefs = useRef<(HTMLButtonElement | null)[]>([]);
const toggleExpanded = useCallback(() => {
setIsExpanded(prev => {
const next = !prev;
localStorage.setItem(TYPE_EXPAND_KEY, String(next));
return next;
});
}, []);
// Keyboard navigation
useKeyboardNavigation({
enabled: true,
@@ -58,7 +66,8 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
}, [badges, onToggleType]),
});
// Check if content has overflow on mount and when badges change
// Check if content has overflow on mount and when badge count changes
const hasCheckedOverflow = useRef(false);
useEffect(() => {
const checkOverflow = () => {
if (badgeContainerRef.current) {
@@ -68,10 +77,13 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
};
checkOverflow();
// Recheck after a short delay to account for animations
// Only do delayed recheck on first measurement
if (!hasCheckedOverflow.current) {
hasCheckedOverflow.current = true;
const timeout = setTimeout(checkOverflow, 100);
return () => clearTimeout(timeout);
}, [badges]);
}
}, [badges.length]);
return (
<>
@@ -104,13 +116,7 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
</div>
{hasOverflow && (
<button
onClick={() => {
setIsExpanded(prev => {
const next = !prev;
localStorage.setItem(TYPE_EXPAND_KEY, String(next));
return next;
});
}}
onClick={toggleExpanded}
className="mt-2 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
flex items-center gap-1 transition-colors self-start cursor-pointer"
>
+1 -1
View File
@@ -15,7 +15,7 @@ export function SettingsSection({
<div className="bg-[var(--glass-bg)] backdrop-filter backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6 mb-6 transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) hover:translate-y-[-5px] hover:scale-[1.02] hover:shadow-[0_8px_24px_var(--shadow-color)] hover:z-10">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-[var(--text-color)]">{title}</h2>
{headerAction && <div className="flex gap-2">{headerAction}</div>}
{headerAction && <div className="flex gap-2 flex-wrap">{headerAction}</div>}
</div>
{description && (
<p className="text-sm text-[var(--text-color-secondary)] mb-6 leading-[1.6]">
+1 -1
View File
@@ -109,7 +109,7 @@ export function JsonImportTab() {
</p>
<div className="mt-2 space-y-1 max-h-[150px] overflow-y-auto">
{preview.map((s, i) => (
<div key={i} className="text-xs text-[var(--text-color-secondary)] flex gap-2">
<div key={i} className="text-xs text-[var(--text-color-secondary)] flex gap-2 flex-wrap">
<span className="font-medium text-[var(--text-color)]">{s.name}</span>
<span className="truncate">{s.baseUrl}</span>
</div>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kvideo",
"version": "4.4.8",
"version": "4.4.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kvideo",
"version": "4.4.8",
"version": "4.4.9",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.4.8",
"version": "4.4.9",
"private": true,
"scripts": {
"dev": "next dev",
+10
View File
@@ -0,0 +1,10 @@
# Cloudflare Pages Wrangler Configuration
# This file provides compatibility settings for Cloudflare Pages deployment.
# It ensures nodejs_compat is always set, so users don't need to configure it manually.
name = "kvideo"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
[vars]
# Environment variables can be set here or in Cloudflare Dashboard