fix: enforce compliance mode for managed deployments

This commit is contained in:
kuekhaoyang
2026-04-02 21:42:00 +08:00
parent 9762e0a6b5
commit 5829a79ee3
15 changed files with 308 additions and 159 deletions
+42 -41
View File
@@ -23,6 +23,8 @@
**KVideo** 是一个高性能、现代化的视频聚合与播放应用,专注于提供极致的用户体验和视觉设计。本项目利用 Next.js 16 的最新特性,结合 React 19 和 Tailwind CSS v4,打造了一个既美观又强大的视频浏览平台。
> **说明**:仓库默认不内置任何视频源、高级源或 IPTV 源。部署者必须自行配置已获授权、可合法使用且允许当前部署方式访问的内容来源。
### 核心设计理念:Liquid Glass(液态玻璃)
项目的视觉设计基于 **"Liquid Glass"** 设计系统,这是一套融合了以下特性的现代化 UI 设计语言:
@@ -500,6 +502,8 @@ docker run -d -p 3000:3000 \
通过环境变量预设 IPTV 直播源,应用启动时会自动添加到直播源列表中。
> **注意**:在 Vercel / Cloudflare 托管部署的合规模式下,IPTV 页面与流中继默认关闭,`IPTV_SOURCES` / `NEXT_PUBLIC_IPTV_SOURCES` 不会生效。
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `IPTV_SOURCES` | IPTV 直播源配置(服务端) | - |
@@ -666,7 +670,7 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
### 架构特点
- **App Router**Next.js 的新路由系统,支持服务端组件和流式渲染
- **API Routes**:内置 API 端点,处理认证、豆瓣数据、视频源代理、IPTV 代理和弹幕聚合
- **API Routes**:内置 API 端点,处理认证、豆瓣数据、弹幕聚合,以及仅在自托管完整模式下启用的媒体代理 / IPTV 中继
- **Server-Sent Events**:搜索 API 使用 SSE 流式返回实时结果
- **Service Worker**PWA 支持和资源缓存
- **Server Components**:优化首屏加载性能
@@ -674,45 +678,25 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
- **Edge Runtime**API 路由运行在 Edge Runtime 上,提供更低延迟
- **Profile-based Storage**:所有用户数据按 profileIdSHA-256 哈希)隔离存储
## 部署合规说明
Issue [#127](https://github.com/KuekHaoYang/KVideo/issues/127) 之后,仓库的公开部署策略做了调整,目的只有一个:避免把这个项目继续包装成“GitHub 账号直连第三方平台的一键导入仓库”。
- 不再推荐任何 `Deploy with Vercel``Connect GitHub``Fork 后直接授权第三方读取仓库` 这一类 GitHub 直连导入流程。
- Vercel / Cloudflare 托管部署现在默认运行在**合规模式**:自动关闭外部媒体代理、热链转发和 IPTV 流中继,仅保留直连播放路径。
- 需要 `/api/proxy``/api/iptv/stream`、自定义 `User-Agent` / `Referer` 转发、IPTV 中继等能力时,请使用 Docker 或传统 Node.js 自托管。
- 无论部署到哪里,都只应接入你有权使用、且允许当前部署环境访问的内容来源。
- 相关政策请直接阅读官方原文:
- [GitHub Terms of Service](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service)
- [GitHub Acceptable Use Policies](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies)
- [Vercel: why running another CDN / proxy on top of Vercel is not recommended](https://vercel.com/guides/why-running-another-cdn-on-top-of-vercel-is-not-recommended)
- [Cloudflare abuse approach](https://www.cloudflare.com/trust-hub/abuse-approach/)
## 快速部署
### 部署到自己的服务器
#### 选项 1Vercel 一键部署(推荐
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/KuekHaoYang/KVideo)
1. 点击上方按钮
2. 连接你的 GitHub 账号
3. Vercel 会自动检测 Next.js 项目并部署
4. 几分钟后即可访问你自己的 KVideo 实例
#### 选项 2Cloudflare Pages 部署 (推荐)
此方法完全免费且速度极快,是部署本项目的最佳选择。
1. **Fork 本仓库**:首先将项目 Fork 到你的 GitHub 账户。
2. **创建项目**
- 点击访问 [**Cloudflare Pages - Connect Git**](https://dash.cloudflare.com/?to=/:account/pages/new/provider/github)。
- 如果未连接 GitHub,请点击 **Connect GitHub**;若已连接,直接选择你刚刚 Fork 的 `KVideo` 项目并点击 **Begin setup**
3. **配置构建参数**
- **Project name**: 默认为 `kvideo` (建议保持不变,后续链接基于此名称)
- **Framework Preset**: 选择 `Next.js`
- **Build command**: 输入 `npm run pages:build`
- **Build output directory**: 输入 `.vercel/output/static`
- 点击 **Save and Deploy**
4. **等待部署完成**
> 项目已内置 `wrangler.toml` 配置文件,其中包含 `nodejs_compat` 兼容性标志,无需手动配置。
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 部署
#### 选项 1Docker 部署(推荐,完整功能
**从 Docker Hub 拉取(最简单):**
@@ -758,7 +742,7 @@ docker run -d -p 3000:3000 \
--name kvideo kuekhaoyang/kvideo:latest
```
#### 选项 4:传统 Node.js 部署
#### 选项 2:传统 Node.js 部署(完整功能)
```bash
# 1. 克隆仓库
@@ -777,7 +761,18 @@ npm start
应用将在 `http://localhost:3000` 启动。
#### 选项 5Android TV APK 构建
#### 选项 3Vercel / Cloudflare 托管部署(合规模式)
适用于只需要搜索、账户、设置、直连播放等基础能力,不需要外部媒体代理 / IPTV 中继的场景。
1. **只从本地工作副本部署**:不要使用 GitHub 一键导入、Fork 后导入、Connect GitHub 读取仓库等流程。
2. **Vercel**:使用本地 CLI 部署现有项目,参考官方文档:[Import an existing project](https://vercel.com/docs/getting-started-with-vercel/import)。
3. **Cloudflare**:使用官方 **Direct Upload** 或 CLI 工作流,不要使用 Git integration,参考官方文档:[Getting started with Pages](https://developers.cloudflare.com/pages/get-started)。
4. **Cloudflare 构建参数**:本仓库保留了 `npm run pages:build``wrangler.toml`,可继续用于现有 Cloudflare 构建链路。
5. **功能限制**:托管平台会自动禁用外部媒体代理和 IPTV 流中继;请仅使用允许浏览器直连、允许当前来源访问且具备合法授权的内容源。
6. **如果你需要完整能力**:直接改用 Docker 或传统 Node.js 自托管,不要在托管平台上强行恢复这些能力。
#### 选项 4Android TV APK 构建
项目内置了一个轻量的 Android TV WebView 壳应用,可以将 KVideo 打包成 APK 安装到 Android TV 或机顶盒上。
@@ -829,7 +824,7 @@ npm start
>
> **发布方式**GitHub Actions 的 `Android TV APK` 工作流会持续验证壳应用可构建;如需对外发布预构建 APK,可手动触发该工作流并填写 `release_tag`,生成对应的 GitHub Release 资产。
#### 选项 6Apple TV 应用构建
#### 选项 5Apple TV 应用构建
项目内置了一个轻量的 tvOS WKWebView 壳应用,可以将 KVideo 安装到 Apple TV 上。
@@ -865,7 +860,11 @@ npm start
### Vercel 部署
Vercel 会自动检测 GitHub 仓库的更新并重新部署,无需手动操作
不要依赖 GitHub 仓库自动导入。请在本地工作副本完成更新后,使用 Vercel CLI 重新发布当前目录
### Cloudflare 托管部署
请在本地工作副本完成更新后,使用 Direct Upload 或 CLI 重新发布。不要使用 Git integration 直接读取 GitHub 仓库。
### Docker 部署
@@ -916,12 +915,14 @@ Android 7.0 (API 24) 的 WebView 基于 Chrome 51,不支持本项目使用的
### IPTV 部分直播流无法播放
如果你部署在 Vercel / Cloudflare 托管平台,IPTV 在合规模式下是**默认禁用**的;这不是 Bug,而是刻意限制。下面的说明只适用于 Docker / Node.js 自托管完整模式。
浏览器原生仅支持 HLS (m3u8) 和部分 MP4/WebM 格式。以下格式在浏览器中不受支持:
- RTMP/RTSP 流(需要专用播放器如 VLC/PotPlayer
- 某些加密或受 DRM 保护的流
- 需要特定客户端验证的流
KVideo 已内置代理服务器自动处理 CORS 问题和 HLS URL 重写,大部分 HLS 直播流应能正常播放。
在自托管完整模式下,KVideo 的 IPTV 代理会处理 CORS 问题和 HLS URL 重写,大部分 HLS 直播流应能正常播放。
### IPTV CCTV 等频道只有声音没有画面
+3 -1
View File
@@ -4,6 +4,7 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
export const runtime = 'edge';
@@ -66,13 +67,14 @@ async function generateProfileId(password: string): Promise<string> {
export async function GET() {
const hasAuth = !!(effectiveAdminPassword || ACCOUNTS);
const runtimeFeatures = getRuntimeFeatures();
return NextResponse.json({
hasAuth,
hasPremiumAuth: !!PREMIUM_PASSWORD,
persistSession: PERSIST_SESSION,
subscriptionSources: SUBSCRIPTION_SOURCES,
iptvSources: IPTV_SOURCES,
iptvSources: runtimeFeatures.iptvEnabled ? IPTV_SOURCES : '',
mergeSources: MERGE_SOURCES,
danmakuApiUrl: DANMAKU_API_URL,
});
+13
View File
@@ -6,6 +6,7 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
export const runtime = 'edge';
@@ -91,6 +92,18 @@ const CORS_HEADERS = {
};
export async function GET(request: NextRequest) {
const runtimeFeatures = getRuntimeFeatures();
if (!runtimeFeatures.iptvEnabled) {
return NextResponse.json(
{
error: 'IPTV relay is disabled on this deployment',
message: runtimeFeatures.restrictionSummary,
},
{ status: 403 }
);
}
const url = request.nextUrl.searchParams.get('url');
const customUa = request.nextUrl.searchParams.get('ua');
const customReferer = request.nextUrl.searchParams.get('referer');
+13
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { processM3u8Content } from '@/lib/utils/proxy-utils';
import { fetchWithRetry } from '@/lib/utils/fetch-with-retry';
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
export const runtime = 'edge';
@@ -9,6 +10,18 @@ export const runtime = 'edge';
// process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
export async function GET(request: NextRequest) {
const runtimeFeatures = getRuntimeFeatures();
if (!runtimeFeatures.mediaProxyEnabled) {
return NextResponse.json(
{
error: 'External media proxy is disabled on this deployment',
message: runtimeFeatures.restrictionSummary,
},
{ status: 403 }
);
}
const url = request.nextUrl.searchParams.get('url');
if (!url) {
+28 -7
View File
@@ -11,11 +11,13 @@ import { IPTVChannelGrid } from '@/components/iptv/IPTVChannelGrid';
import { IPTVPlayer } from '@/components/iptv/IPTVPlayer';
import { Icons } from '@/components/ui/Icon';
import { hasPermission, getSession } from '@/lib/store/auth-store';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
import Link from 'next/link';
import type { M3UChannel } from '@/lib/utils/m3u-parser';
export default function IPTVPage() {
const { sources, cachedChannels, cachedChannelsBySource, refreshSources, isLoading, lastRefreshed } = useIPTVStore();
const { iptvEnabled, restrictionSummary } = useRuntimeFeatures();
const { sources, cachedChannels, cachedChannelsBySource, refreshSources, isLoading } = useIPTVStore();
const [activeChannel, setActiveChannel] = useState<M3UChannel | null>(null);
const [showManager, setShowManager] = useState(false);
@@ -45,6 +47,17 @@ export default function IPTVPage() {
[visibleSources, cachedChannelsBySource]
);
// Auto-refresh on first load if we have sources but no cached channels
useEffect(() => {
if (!iptvEnabled) {
return;
}
if (sources.length > 0 && cachedChannels.length === 0 && !isLoading) {
refreshSources();
}
}, [iptvEnabled, sources.length, cachedChannels.length, isLoading, refreshSources]);
// If auth is configured and user doesn't have iptv_access, show access denied
if (!canAccessIPTV && getSession()) {
return (
@@ -59,12 +72,20 @@ export default function IPTVPage() {
);
}
// Auto-refresh on first load if we have sources but no cached channels
useEffect(() => {
if (sources.length > 0 && cachedChannels.length === 0 && !isLoading) {
refreshSources();
}
}, [sources.length, cachedChannels.length, isLoading, refreshSources]);
if (!iptvEnabled) {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-color)] bg-[image:var(--bg-image)]">
<div className="max-w-xl mx-auto px-6 py-8 text-center bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)]">
<Icons.TV size={48} className="mx-auto mb-4 text-[var(--text-color-secondary)] opacity-40" />
<p className="text-[var(--text-color)] font-medium mb-2"> IPTV</p>
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
{restrictionSummary}
</p>
<Link href="/" className="text-sm text-[var(--accent-color)] hover:underline"></Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed">
+20 -14
View File
@@ -14,6 +14,8 @@ import { AdKeywordsInjector } from "@/components/AdKeywordsInjector";
import { BackToTop } from "@/components/ui/BackToTop";
import { ScrollPositionManager } from "@/components/ScrollPositionManager";
import { LocaleProvider } from "@/components/LocaleProvider";
import { RuntimeFeaturesProvider } from "@/components/RuntimeFeaturesProvider";
import { getRuntimeFeatures } from "@/lib/server/runtime-features";
import fs from 'fs';
import path from 'path';
@@ -80,6 +82,8 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const runtimeFeatures = getRuntimeFeatures();
return (
<html lang="zh-CN" suppressHydrationWarning>
<head>
@@ -100,21 +104,23 @@ export default function RootLayout({
suppressHydrationWarning
>
<ThemeProvider>
{/* 加入自动同步组件,它会在后台默默工作,我们放在 ThemeProvider 内部的最前面 */}
<AutoSync />
<LocaleProvider />
<RuntimeFeaturesProvider initialFeatures={runtimeFeatures}>
{/* 加入自动同步组件,它会在后台默默工作,我们放在 ThemeProvider 内部的最前面 */}
<AutoSync />
<LocaleProvider />
<TVProvider>
<TVNavigationInitializer />
<PasswordGate hasAuth={!!(process.env.ADMIN_PASSWORD || process.env.ACCOUNTS || process.env.ACCESS_PASSWORD)}>
<AdKeywordsWrapper />
{children}
<BackToTop />
<ScrollPositionManager />
</PasswordGate>
</TVProvider>
<Analytics />
<ServiceWorkerRegister />
<TVProvider>
<TVNavigationInitializer />
<PasswordGate hasAuth={!!(process.env.ADMIN_PASSWORD || process.env.ACCOUNTS || process.env.ACCESS_PASSWORD)}>
<AdKeywordsWrapper />
{children}
<BackToTop />
<ScrollPositionManager />
</PasswordGate>
</TVProvider>
<Analytics />
<ServiceWorkerRegister />
</RuntimeFeaturesProvider>
</ThemeProvider>
{/* ARIA Live Region for Screen Reader Announcements */}
+33
View File
@@ -0,0 +1,33 @@
'use client';
import { createContext, useContext, type ReactNode } from 'react';
import type { RuntimeFeatures } from '@/lib/config/runtime-features';
const defaultRuntimeFeatures: RuntimeFeatures = {
deploymentProvider: 'self-hosted',
deploymentProviderLabel: '自托管',
restrictedManagedDeployment: false,
mediaProxyEnabled: true,
iptvEnabled: true,
restrictionSummary: null,
};
const RuntimeFeaturesContext = createContext<RuntimeFeatures>(defaultRuntimeFeatures);
interface RuntimeFeaturesProviderProps {
initialFeatures: RuntimeFeatures;
children: ReactNode;
}
export function RuntimeFeaturesProvider({ initialFeatures, children }: RuntimeFeaturesProviderProps) {
return (
<RuntimeFeaturesContext.Provider value={initialFeatures}>
{children}
</RuntimeFeaturesContext.Provider>
);
}
export function useRuntimeFeatures(): RuntimeFeatures {
return useContext(RuntimeFeaturesContext);
}
+5 -7
View File
@@ -1,12 +1,13 @@
'use client';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
import { Icons } from '@/components/ui/Icon';
import { siteConfig } from '@/lib/config/site-config';
import { getSession, clearSession, hasPermission, type AuthSession } from '@/lib/store/auth-store';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
import { LogOut } from 'lucide-react';
interface NavbarProps {
@@ -16,11 +17,8 @@ interface NavbarProps {
export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
const settingsHref = isPremiumMode ? '/premium/settings' : '/settings';
const [session, setSessionState] = useState<AuthSession | null>(null);
useEffect(() => {
setSessionState(getSession());
}, []);
const [session] = useState<AuthSession | null>(() => getSession());
const { iptvEnabled } = useRuntimeFeatures();
const handleLogout = () => {
clearSession();
@@ -61,7 +59,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
{/* IPTV Link - only show if user has iptv_access or no auth configured */}
{hasPermission('iptv_access') && (
{iptvEnabled && hasPermission('iptv_access') && (
<Link
href="/iptv"
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
+9 -40
View File
@@ -4,11 +4,10 @@ import { useState, useRef, useEffect, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { Card } from '@/components/ui/Card';
import { useHistory } from '@/lib/store/history-store';
import { settingsStore } from '@/lib/store/settings-store';
import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings';
import { CustomVideoPlayer } from './CustomVideoPlayer';
import { VideoPlayerError } from './VideoPlayerError';
import { VideoPlayerEmpty } from './VideoPlayerEmpty';
import { usePlayerSettings } from './hooks/usePlayerSettings';
interface VideoPlayerProps {
playUrl: string;
@@ -53,38 +52,8 @@ export function VideoPlayer({
const durationRef = useRef(0);
const SAVE_INTERVAL = 5000; // 5 seconds throttle
// Get showModeIndicator setting
// Get showModeIndicator and proxyMode settings
const [showModeIndicator, setShowModeIndicator] = useState(false);
const [proxyMode, setProxyMode] = useState<'retry' | 'none' | 'always'>('retry');
useEffect(() => {
// Initial value - use mode-specific store
const store = isPremium ? premiumModeSettingsStore : settingsStore;
const settings = store.getSettings();
setShowModeIndicator(settings.showModeIndicator);
setProxyMode(settings.proxyMode);
// Subscribe to changes
const unsubscribe = store.subscribe(() => {
const newSettings = store.getSettings();
setShowModeIndicator(newSettings.showModeIndicator);
setProxyMode(newSettings.proxyMode);
});
return () => unsubscribe();
}, []);
// Initialize useProxy based on proxyMode when the component mounts or proxyMode changes
// We use a separate effect for this to react to setting changes
useEffect(() => {
if (proxyMode === 'always') {
setUseProxy(true);
} else if (proxyMode === 'none') {
setUseProxy(false);
}
// For 'retry', we assume it starts as false (direct), which is the default state of useProxy
}, [proxyMode]);
const { showModeIndicator, proxyMode } = usePlayerSettings(isPremium);
const effectiveUseProxy = proxyMode === 'always' ? true : proxyMode === 'none' ? false : useProxy;
// Use reactive hook to subscribe to history updates
@@ -174,7 +143,7 @@ export function VideoPlayer({
// 3. Proxy mode is 'retry' (specifically for the auto-switch logic)
// Note: If mode is 'always', we are already using proxy. If it fails, we show error.
if (!useProxy && proxyMode === 'retry') {
if (!effectiveUseProxy && proxyMode === 'retry') {
setUseProxy(true);
setShouldAutoPlay(true); // Force autoplay after proxy retry
setVideoError('');
@@ -200,10 +169,10 @@ export function VideoPlayer({
// Let's toggle it to give best chance.
// Actually requirement says "try no proxy and proxy and same as before".
// So simple toggle is fine.
setUseProxy(prev => !prev);
setUseProxy(prev => proxyMode === 'none' ? false : !prev);
};
const finalPlayUrl = useProxy || proxyMode === 'always'
const finalPlayUrl = effectiveUseProxy
? `/api/proxy?url=${encodeURIComponent(playUrl)}&retry=${retryCount}` // Add retry param to force fresh request
: playUrl;
@@ -217,11 +186,11 @@ export function VideoPlayer({
{/* Mode Indicator Badge - controlled by settings */}
{showModeIndicator && (
<div className="absolute top-3 right-3 z-30">
<span className={`px-2 py-1 text-xs font-medium rounded-full backdrop-blur-md transition-all duration-300 ${useProxy
<span className={`px-2 py-1 text-xs font-medium rounded-full backdrop-blur-md transition-all duration-300 ${effectiveUseProxy
? 'bg-orange-500/80 text-white'
: 'bg-green-500/80 text-white'
}`}>
{useProxy ? '代理模式' : '直连模式'}
{effectiveUseProxy ? '代理模式' : '直连模式'}
</span>
</div>
)}
@@ -235,7 +204,7 @@ export function VideoPlayer({
/>
) : (
<CustomVideoPlayer
key={`${useProxy ? 'proxy' : 'direct'}-${retryCount}-${source}`} // Remount when switching sources, modes, or retrying
key={`${effectiveUseProxy ? 'proxy' : 'direct'}-${retryCount}-${source}`} // Remount when switching sources, modes, or retrying
src={finalPlayUrl}
onError={handleVideoError}
onTimeUpdate={handleTimeUpdate}
+10 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import Hls from 'hls.js';
import { usePlayerSettings } from './usePlayerSettings';
import { filterM3u8Ad } from '@/lib/utils/m3u8-utils';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
interface UseHlsPlayerProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
@@ -22,6 +23,7 @@ export function useHlsPlayer({
}: UseHlsPlayerProps) {
const hlsRef = useRef<Hls | null>(null);
const { adFilterMode, adKeywords } = usePlayerSettings(isPremium);
const { mediaProxyEnabled } = useRuntimeFeatures();
const isAdFilterEnabled = adFilterMode !== 'off';
useEffect(() => {
@@ -225,6 +227,9 @@ export function useHlsPlayer({
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.text();
} catch (e) {
if (!mediaProxyEnabled) {
throw e;
}
console.warn(`[HLS Native] Fetch failed for ${url}, trying proxy...`, e);
const proxiedUrl = `/api/proxy?url=${encodeURIComponent(url)}`;
const res = await fetch(proxiedUrl);
@@ -397,6 +402,10 @@ export function useHlsPlayer({
const handleError = () => {
if (directFailed) return;
directFailed = true;
if (!mediaProxyEnabled) {
onError?.('当前浏览器不支持 HLS 视频播放。建议使用 Chrome、Edge 或 Safari 浏览器。');
return;
}
// Try proxied URL as final attempt
const proxiedUrl = `/api/proxy?url=${encodeURIComponent(src)}`;
video.src = proxiedUrl;
@@ -415,5 +424,5 @@ export function useHlsPlayer({
}
extraBlobs.forEach(url => URL.revokeObjectURL(url));
};
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords]);
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords, mediaProxyEnabled]);
}
+9 -7
View File
@@ -10,6 +10,7 @@ import {
premiumModeSettingsStore,
type ModeSettings,
} from '@/lib/store/premium-mode-settings';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
interface PlayerSettingsSnapshot {
autoNextEpisode: boolean;
@@ -30,7 +31,7 @@ interface PlayerSettingsSnapshot {
danmakuDisplayArea: number;
}
function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
function getPlayerSettingsSnapshot(isPremium: boolean, mediaProxyEnabled: boolean): PlayerSettingsSnapshot {
const globalSettings = settingsStore.getSettings();
const modeSettings = isPremium ? premiumModeSettingsStore.getSettings() : globalSettings;
@@ -45,7 +46,7 @@ function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
adFilterMode: modeSettings.adFilterMode,
adKeywords: globalSettings.adKeywords,
fullscreenType: modeSettings.fullscreenType,
proxyMode: modeSettings.proxyMode,
proxyMode: mediaProxyEnabled ? modeSettings.proxyMode : 'none',
danmakuEnabled: modeSettings.danmakuEnabled,
danmakuApiUrl: modeSettings.danmakuApiUrl,
danmakuOpacity: modeSettings.danmakuOpacity,
@@ -59,12 +60,13 @@ function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
* Provides reactive updates when settings change
*/
export function usePlayerSettings(isPremium: boolean = false) {
const [settings, setSettings] = useState(() => getPlayerSettingsSnapshot(isPremium));
const { mediaProxyEnabled } = useRuntimeFeatures();
const [settings, setSettings] = useState(() => getPlayerSettingsSnapshot(isPremium, mediaProxyEnabled));
// Subscribe to settings changes
useEffect(() => {
const syncSettings = () => {
setSettings(getPlayerSettingsSnapshot(isPremium));
setSettings(getPlayerSettingsSnapshot(isPremium, mediaProxyEnabled));
};
const modeStore = isPremium ? premiumModeSettingsStore : settingsStore;
@@ -77,7 +79,7 @@ export function usePlayerSettings(isPremium: boolean = false) {
unsubscribeModeStore();
unsubscribeGlobalStore?.();
};
}, [isPremium]);
}, [isPremium, mediaProxyEnabled]);
const updateModeSettings = useCallback((partial: Partial<ModeSettings>) => {
if (isPremium) {
@@ -145,8 +147,8 @@ export function usePlayerSettings(isPremium: boolean = false) {
}, [updateModeSettings]);
const setProxyMode = useCallback((value: 'retry' | 'none' | 'always') => {
updateModeSettings({ proxyMode: value });
}, [updateModeSettings]);
updateModeSettings({ proxyMode: mediaProxyEnabled ? value : 'none' });
}, [mediaProxyEnabled, updateModeSettings]);
const setDanmakuEnabled = useCallback((value: boolean) => {
updateModeSettings({ danmakuEnabled: value });
+45 -32
View File
@@ -6,6 +6,7 @@
*/
import { Icons } from '@/components/ui/Icon';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
import {
type ProxyMode,
DEFAULT_SEEK_STEP_SECONDS,
@@ -57,6 +58,9 @@ export function PlayerSettings({
onDanmakuDisplayAreaChange,
showDanmakuApi = true,
}: PlayerSettingsProps) {
const { mediaProxyEnabled, restrictionSummary } = useRuntimeFeatures();
const effectiveProxyMode = mediaProxyEnabled ? proxyMode : 'none';
return (
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4"></h2>
@@ -160,38 +164,47 @@ export function PlayerSettings({
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
onClick={() => onProxyModeChange('retry')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'retry'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"> ()</div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
<button
onClick={() => onProxyModeChange('none')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'none'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1">使</div>
</button>
<button
onClick={() => onProxyModeChange('always')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'always'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
</div>
{mediaProxyEnabled ? (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
onClick={() => onProxyModeChange('retry')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'retry'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"> ()</div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
<button
onClick={() => onProxyModeChange('none')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'none'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1">使</div>
</button>
<button
onClick={() => onProxyModeChange('always')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'always'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
</div>
) : (
<div className="rounded-[var(--radius-2xl)] border border-amber-500/30 bg-amber-500/10 px-4 py-3">
<div className="font-semibold text-[var(--text-color)]"></div>
<div className="text-sm text-[var(--text-color-secondary)] mt-1">
{restrictionSummary}
</div>
</div>
)}
</div>
<div className="border-t border-[var(--glass-border)]" />
+11
View File
@@ -0,0 +1,11 @@
export type DeploymentProvider = 'self-hosted' | 'vercel' | 'cloudflare';
export interface RuntimeFeatures {
deploymentProvider: DeploymentProvider;
deploymentProviderLabel: string;
restrictedManagedDeployment: boolean;
mediaProxyEnabled: boolean;
iptvEnabled: boolean;
restrictionSummary: string | null;
}
+51
View File
@@ -0,0 +1,51 @@
import 'server-only';
import type { RuntimeFeatures } from '@/lib/config/runtime-features';
function isVercelDeployment(): boolean {
return process.env.VERCEL === '1' || Boolean(process.env.VERCEL_ENV);
}
function isCloudflareDeployment(): boolean {
return (
process.env.CF_PAGES === '1' ||
Boolean(process.env.CF_PAGES_URL) ||
Boolean(process.env.CLOUDFLARE_ACCOUNT_ID) ||
Boolean(process.env.CF_ACCOUNT_ID) ||
Boolean(process.env.WORKERS_CI)
);
}
function getRestrictedFeatures(
deploymentProvider: RuntimeFeatures['deploymentProvider'],
deploymentProviderLabel: string
): RuntimeFeatures {
return {
deploymentProvider,
deploymentProviderLabel,
restrictedManagedDeployment: true,
mediaProxyEnabled: false,
iptvEnabled: false,
restrictionSummary: `${deploymentProviderLabel} 托管部署会启用合规模式:关闭外部媒体代理、热链转发和 IPTV 流中继。需要这些能力时请改用 Docker 或传统 Node.js 自托管。`,
};
}
export function getRuntimeFeatures(): RuntimeFeatures {
if (isVercelDeployment()) {
return getRestrictedFeatures('vercel', 'Vercel');
}
if (isCloudflareDeployment()) {
return getRestrictedFeatures('cloudflare', 'Cloudflare');
}
return {
deploymentProvider: 'self-hosted',
deploymentProviderLabel: '自托管',
restrictedManagedDeployment: false,
mediaProxyEnabled: true,
iptvEnabled: true,
restrictionSummary: null,
};
}
+16 -9
View File
@@ -103,17 +103,24 @@ export function parseSourcesFromJson(jsonString: string): ImportResult {
* Fetch and parse sources from a URL
*/
export async function fetchSourcesFromUrl(url: string): Promise<ImportResult> {
// If we're in the browser and it's an external URL, use our proxy to avoid CORS issues
const headers = {
'Accept': 'application/json',
};
const isExternal = url.startsWith('http') && (typeof window !== 'undefined' && !url.includes(window.location.host));
const fetchUrl = isExternal
? `/api/proxy?url=${encodeURIComponent(url)}`
: url;
const response = await fetch(fetchUrl, {
headers: {
'Accept': 'application/json',
},
});
let response: Response;
try {
response = await fetch(url, { headers });
} catch (directError) {
if (!isExternal) {
throw directError;
}
response = await fetch(`/api/proxy?url=${encodeURIComponent(url)}`, {
headers,
});
}
if (!response.ok) {
throw new Error(`获取失败: ${response.status} ${response.statusText}`);