diff --git a/README.md b/README.md index e810162..2d40908 100644 --- a/README.md +++ b/README.md @@ -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**:所有用户数据按 profileId(SHA-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/) + ## 快速部署 ### 部署到自己的服务器 -#### 选项 1:Vercel 一键部署(推荐) - -[![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 实例 - -#### 选项 2:Cloudflare 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`,然后重新部署。 - -#### 选项 3:Docker 部署 +#### 选项 1:Docker 部署(推荐,完整功能) **从 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` 启动。 -#### 选项 5:Android TV APK 构建 +#### 选项 3:Vercel / 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 自托管,不要在托管平台上强行恢复这些能力。 + +#### 选项 4:Android 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 资产。 -#### 选项 6:Apple TV 应用构建 +#### 选项 5:Apple 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 等频道只有声音没有画面 diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts index c9f7c5c..287164c 100644 --- a/app/api/auth/route.ts +++ b/app/api/auth/route.ts @@ -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 { 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, }); diff --git a/app/api/iptv/stream/route.ts b/app/api/iptv/stream/route.ts index 34d3a6b..ae69048 100644 --- a/app/api/iptv/stream/route.ts +++ b/app/api/iptv/stream/route.ts @@ -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'); diff --git a/app/api/proxy/route.ts b/app/api/proxy/route.ts index 6319cf6..4f7975c 100644 --- a/app/api/proxy/route.ts +++ b/app/api/proxy/route.ts @@ -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) { diff --git a/app/iptv/page.tsx b/app/iptv/page.tsx index 2803ed7..2562f17 100644 --- a/app/iptv/page.tsx +++ b/app/iptv/page.tsx @@ -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(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 ( +
+
+ +

当前部署已禁用 IPTV

+

+ {restrictionSummary} +

+ 返回首页 +
+
+ ); + } return (
diff --git a/app/layout.tsx b/app/layout.tsx index 59afcc0..416c880 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( @@ -100,21 +104,23 @@ export default function RootLayout({ suppressHydrationWarning > - {/* 加入自动同步组件,它会在后台默默工作,我们放在 ThemeProvider 内部的最前面 */} - - + + {/* 加入自动同步组件,它会在后台默默工作,我们放在 ThemeProvider 内部的最前面 */} + + - - - - - {children} - - - - - - + + + + + {children} + + + + + + + {/* ARIA Live Region for Screen Reader Announcements */} diff --git a/components/RuntimeFeaturesProvider.tsx b/components/RuntimeFeaturesProvider.tsx new file mode 100644 index 0000000..7133231 --- /dev/null +++ b/components/RuntimeFeaturesProvider.tsx @@ -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(defaultRuntimeFeatures); + +interface RuntimeFeaturesProviderProps { + initialFeatures: RuntimeFeatures; + children: ReactNode; +} + +export function RuntimeFeaturesProvider({ initialFeatures, children }: RuntimeFeaturesProviderProps) { + return ( + + {children} + + ); +} + +export function useRuntimeFeatures(): RuntimeFeatures { + return useContext(RuntimeFeaturesContext); +} diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx index 6a303d2..0fbb83d 100644 --- a/components/layout/Navbar.tsx +++ b/components/layout/Navbar.tsx @@ -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(null); - - useEffect(() => { - setSessionState(getSession()); - }, []); + const [session] = useState(() => getSession()); + const { iptvEnabled } = useRuntimeFeatures(); const handleLogout = () => { clearSession(); @@ -61,7 +59,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
{/* IPTV Link - only show if user has iptv_access or no auth configured */} - {hasPermission('iptv_access') && ( + {iptvEnabled && hasPermission('iptv_access') && ( ('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 && (
- - {useProxy ? '代理模式' : '直连模式'} + {effectiveUseProxy ? '代理模式' : '直连模式'}
)} @@ -235,7 +204,7 @@ export function VideoPlayer({ /> ) : ( ; @@ -22,6 +23,7 @@ export function useHlsPlayer({ }: UseHlsPlayerProps) { const hlsRef = useRef(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]); } diff --git a/components/player/hooks/usePlayerSettings.ts b/components/player/hooks/usePlayerSettings.ts index 8d3e96f..8f7f917 100644 --- a/components/player/hooks/usePlayerSettings.ts +++ b/components/player/hooks/usePlayerSettings.ts @@ -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) => { 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 }); diff --git a/components/settings/PlayerSettings.tsx b/components/settings/PlayerSettings.tsx index 41395cc..7bcf2f1 100644 --- a/components/settings/PlayerSettings.tsx +++ b/components/settings/PlayerSettings.tsx @@ -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 (

播放器设置

@@ -160,38 +164,47 @@ export function PlayerSettings({

控制视频播放时的网络请求策略

-
- - - -
+ {mediaProxyEnabled ? ( +
+ + + +
+ ) : ( +
+
当前部署仅支持直连播放
+
+ {restrictionSummary} +
+
+ )}
diff --git a/lib/config/runtime-features.ts b/lib/config/runtime-features.ts new file mode 100644 index 0000000..24e444a --- /dev/null +++ b/lib/config/runtime-features.ts @@ -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; +} + diff --git a/lib/server/runtime-features.ts b/lib/server/runtime-features.ts new file mode 100644 index 0000000..b0cc56b --- /dev/null +++ b/lib/server/runtime-features.ts @@ -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, + }; +} + diff --git a/lib/utils/source-import-utils.ts b/lib/utils/source-import-utils.ts index 076fa4e..de8a5a4 100644 --- a/lib/utils/source-import-utils.ts +++ b/lib/utils/source-import-utils.ts @@ -103,17 +103,24 @@ export function parseSourcesFromJson(jsonString: string): ImportResult { * Fetch and parse sources from a URL */ export async function fetchSourcesFromUrl(url: string): Promise { - // 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}`);