From 470213bffab842978cf13f5dcd03e1688d67e266 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sat, 2 May 2026 16:53:15 +0800 Subject: [PATCH] fix(player): allow managed media proxy opt-in --- .env.example | 9 +++ CHANGELOG.md | 8 +++ README.md | 18 +++-- app-release.json | 14 +++- components/settings/PlayerSettings.tsx | 10 +-- docker-compose.yml | 3 + lib/server/runtime-features.ts | 40 +++++++++-- package-lock.json | 4 +- package.json | 2 +- tests/redis-client.test.ts | 17 +++++ tests/runtime-features.test.ts | 96 ++++++++++++++++++++++++++ wrangler.toml | 4 ++ 12 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 tests/runtime-features.test.ts diff --git a/.env.example b/.env.example index 2c45b35..29f572b 100644 --- a/.env.example +++ b/.env.example @@ -21,5 +21,14 @@ REDIS_URL=redis://redis:6379/0 UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= +# Media proxy runtime policy: +# - auto: self-hosted/Docker enables the proxy; Vercel/Cloudflare disables it. +# - enabled: allow the player to use proxy retry on managed deployments, useful +# for iOS/Safari HLS sources that fail direct playback. +# - disabled: force direct playback everywhere. +KVIDEO_MEDIA_PROXY_MODE=auto + +# If no authentication is configured, /api/proxy still remains private unless +# this is set to true. Authenticated deployments do not need public relay. KVIDEO_PUBLIC_RELAY_ENABLED=false KVIDEO_OUTBOUND_PRIVATE_HOST_ALLOWLIST= diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d646a..67be19e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 4.9.5 - 2026-05-02 + +- 新增 `KVIDEO_MEDIA_PROXY_MODE` 运行时开关:Vercel / Cloudflare 默认仍保持受限托管模式,但部署者可显式设置 `enabled`,让播放器的“智能重试 / 总是代理”策略在 iOS/Safari HLS 直连失败时生效。 +- 播放器设置里的代理选项调整为“多环境播放策略”,文案明确覆盖桌面、移动端、iOS Safari 与托管部署差异。 +- Docker Compose、`.env.example`、`wrangler.toml` 和 README 补齐媒体代理、公共中继、Upstash Redis 与托管环境边界说明。 +- 新增运行时能力回归测试,覆盖托管环境默认禁用代理、显式启用代理,以及自托管强制直连的行为。 +- Redis 客户端测试补充 Vercel 缺少 Upstash Token 时的降级断言,避免 `/api/user/sync` 因不完整 Redis 配置再次返回 500。 + ## 4.9.4 - 2026-05-02 - Docker Compose 新增持久化 Redis 服务,默认通过 `REDIS_URL=redis://redis:6379/0` 启用自托管托管账户存储。 diff --git a/README.md b/README.md index 15a0910..d7c184d 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ | Android TV 壳应用 | 支持 | 仓库内提供 `android-tv/` 工程 | | 自托管 Node.js | 支持 | 完整能力路径 | | Docker | 支持 | 完整能力路径,推荐部署方式之一 | -| Cloudflare Workers via OpenNext | 支持 | Cloudflare 主支持路径,但运行时会进入受限托管模式 | -| Vercel / 托管 Cloudflare 部署 | 支持但受限 | 自动关闭外部媒体代理、热链转发和 IPTV 流中继 | +| Cloudflare Workers via OpenNext | 支持 | Cloudflare 主支持路径;默认受限,但可显式开启媒体代理 | +| Vercel / 托管 Cloudflare 部署 | 支持但受限 | 默认关闭媒体代理、热链转发和 IPTV;可用 `KVIDEO_MEDIA_PROXY_MODE=enabled` 修复部分 iOS/Safari HLS 兼容问题 | | Apple TV / tvOS 原生打包 | 不支持 | 旧的 WebView 壳路径已移除,不再宣称支持 | Apple TV 用户当前应使用浏览器、AirPlay,或其他已经被支持的投屏路径,而不是依赖仓库内不存在的完整 tvOS 产品链路。 @@ -70,7 +70,7 @@ Apple TV 用户当前应使用浏览器、AirPlay,或其他已经被支持的 - **移动端优化**:移动端播放器布局、双击手势、触摸交互单独处理 - **Google Cast / Chromecast**:播放器页面加载 Cast SDK - **画中画(PiP)**:桌面浏览器与 Android WebView 都有对应兼容逻辑 -- **代理播放模式**:直连、仅代理、智能重试等模式可切换 +- **多环境播放策略**:直连、仅代理、智能重试等模式可切换,托管环境可用部署变量开启 iOS/Safari 兼容代理 - **实际分辨率探测**:会对当前播放源和线路列表中的源做分辨率探测与缓存 - **广告过滤回退**:广告过滤失败时会退回原始视频流,而不是直接把播放器打死 - **键盘快捷键**:空格/K、F、M、P、方向键、J/L 等常用控制都已接入 @@ -106,7 +106,7 @@ Apple TV 用户当前应使用浏览器、AirPlay,或其他已经被支持的 - **播放器内搜索优化**:大列表搜索使用 `useTransition` 降低阻塞 > [!WARNING] -> IPTV 与媒体代理在 Vercel / Cloudflare 等托管部署中会被自动关闭。需要完整 IPTV 能力时,直接用 Docker 或传统 Node.js 自托管,别自欺欺人地把受限托管平台当完整功能环境。 +> IPTV 在 Vercel / Cloudflare 等托管部署中会被自动关闭。媒体代理默认关闭,但可以通过 `KVIDEO_MEDIA_PROXY_MODE=enabled` 显式开启,用于 iOS/Safari HLS 直连失败后的代理重试。需要完整 IPTV、中继和自定义转发能力时,直接用 Docker 或传统 Node.js 自托管。 ### 豆瓣、推荐与元数据 @@ -169,7 +169,7 @@ Apple TV 用户当前应使用浏览器、AirPlay,或其他已经被支持的 - 代理路由不会转发 Cookie,也不会伪造客户端 IP / `Origin` / `Referer` - 只要开启认证,`AUTH_SECRET` 就是硬要求 - 登录失败会触发节流,接口可能返回 `429` 与 `Retry-After` -- Vercel / Cloudflare 运行时会进入受限托管模式,自动关闭外部媒体代理、热链转发和 IPTV 流中继 +- Vercel / Cloudflare 运行时会进入受限托管模式,默认关闭外部媒体代理、热链转发和 IPTV 流中继;媒体代理可通过 `KVIDEO_MEDIA_PROXY_MODE=enabled` 单独开启 ## 认证模式与权限 @@ -249,6 +249,8 @@ Apple TV 用户当前应使用浏览器、AirPlay,或其他已经被支持的 | 变量名 | 默认值 | 说明 | |--------|--------|------| +| `KVIDEO_MEDIA_PROXY_MODE` | `auto` | 媒体代理运行策略:`auto` 表示自托管 / Docker 开启、Vercel / Cloudflare 默认关闭;`enabled` 表示托管环境也允许播放器代理重试;`disabled` 表示所有环境强制直连 | +| `KVIDEO_MEDIA_PROXY_ENABLED` | - | 兼容布尔别名;`true` 等同 `KVIDEO_MEDIA_PROXY_MODE=enabled`,`false` 等同 `disabled` | | `KVIDEO_PUBLIC_RELAY_ENABLED` | `false` | 是否允许未认证场景下的公共代理访问 | | `KVIDEO_OUTBOUND_PRIVATE_HOST_ALLOWLIST` | - | 允许显式访问的私网 / 内网主机白名单,逗号分隔 | @@ -392,7 +394,9 @@ docker run -d \ - 关闭热链转发 - 关闭 IPTV 流中继 -如果你需要 `/api/proxy`、IPTV 中继、自定义 `User-Agent` / `Referer` 转发等完整能力,就不要继续拿受限托管环境硬扛,直接换 Docker 或传统 Node.js 自托管。 +如果你只遇到 iOS/Safari 播放 `.m3u8` 失败,而桌面和 Android 正常,可以在 Vercel / Cloudflare 设置 `KVIDEO_MEDIA_PROXY_MODE=enabled`,然后在播放器设置里保持“智能重试”或切到“总是代理”。未启用登录认证时,还必须显式设置 `KVIDEO_PUBLIC_RELAY_ENABLED=true`,否则 `/api/proxy` 会继续拒绝公共访问。 + +这不会把托管环境变成完整能力路径:IPTV 中继、自定义 `User-Agent` / `Referer` 透传和更复杂的热链转发仍应使用 Docker 或传统 Node.js 自托管。 ### Cloudflare 路径 @@ -405,7 +409,7 @@ npm run cf:preview `npm run pages:build` 仍然保留,但只是给现存兼容链路兜底的遗留构建脚本,不是这条分支主张的 Cloudflare 主路径。 -Cloudflare Workers 不能使用 Docker Compose 里的 TCP Redis。需要托管账户与同步时,在 Cloudflare 侧配置 `AUTH_SECRET`、`ADMIN_PASSWORD` / `ACCOUNTS`,以及 `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN`。 +Cloudflare Workers 不能使用 Docker Compose 里的 TCP Redis。需要托管账户与同步时,在 Cloudflare 侧配置 `AUTH_SECRET`、`ADMIN_PASSWORD` / `ACCOUNTS`,以及 `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN`。如果只需要修复 Safari/HLS 直连兼容问题,可以额外设置 `KVIDEO_MEDIA_PROXY_MODE=enabled`;公共无登录部署还需要 `KVIDEO_PUBLIC_RELAY_ENABLED=true`。 ### Android TV APK diff --git a/app-release.json b/app-release.json index f2d0dfd..f412036 100644 --- a/app-release.json +++ b/app-release.json @@ -4,8 +4,20 @@ "name": "KVideo", "branch": "main" }, - "currentVersion": "4.9.4", + "currentVersion": "4.9.5", "releases": [ + { + "version": "4.9.5", + "publishedAt": "2026-05-02", + "title": "托管环境 Safari 播放兼容开关", + "notes": [ + "新增 KVIDEO_MEDIA_PROXY_MODE 运行时开关:Vercel / Cloudflare 默认仍保持受限托管模式,但部署者可显式设置 enabled,让播放器的“智能重试 / 总是代理”策略在 iOS/Safari HLS 直连失败时生效。", + "播放器设置里的代理选项调整为“多环境播放策略”,文案明确覆盖桌面、移动端、iOS Safari 与托管部署差异。", + "Docker Compose、.env.example、wrangler.toml 和 README 补齐媒体代理、公共中继、Upstash Redis 与托管环境边界说明。", + "新增运行时能力回归测试,覆盖托管环境默认禁用代理、显式启用代理,以及自托管强制直连的行为。", + "Redis 客户端测试补充 Vercel 缺少 Upstash Token 时的降级断言,避免 /api/user/sync 因不完整 Redis 配置再次返回 500。" + ] + }, { "version": "4.9.4", "publishedAt": "2026-05-02", diff --git a/components/settings/PlayerSettings.tsx b/components/settings/PlayerSettings.tsx index 0e155d4..ec18f9f 100644 --- a/components/settings/PlayerSettings.tsx +++ b/components/settings/PlayerSettings.tsx @@ -164,10 +164,10 @@ export function PlayerSettings({

- 代理播放模式 + 多环境播放策略

- 控制视频播放时的网络请求策略 + 控制不同浏览器、iOS Safari 和托管部署下的视频请求策略

{mediaProxyEnabled ? (
@@ -179,7 +179,7 @@ export function PlayerSettings({ }`} >
智能重试 (推荐)
-
直连优先,失败时尝试代理
+
直连优先,失败时尝试代理,适合 iOS/Safari 兼容
) : ( diff --git a/docker-compose.yml b/docker-compose.yml index dc223e1..b631369 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,9 @@ services: REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} UPSTASH_REDIS_REST_URL: ${UPSTASH_REDIS_REST_URL:-} UPSTASH_REDIS_REST_TOKEN: ${UPSTASH_REDIS_REST_TOKEN:-} + KVIDEO_MEDIA_PROXY_MODE: ${KVIDEO_MEDIA_PROXY_MODE:-auto} + KVIDEO_PUBLIC_RELAY_ENABLED: ${KVIDEO_PUBLIC_RELAY_ENABLED:-false} + KVIDEO_OUTBOUND_PRIVATE_HOST_ALLOWLIST: ${KVIDEO_OUTBOUND_PRIVATE_HOST_ALLOWLIST:-} depends_on: redis: condition: service_healthy diff --git a/lib/server/runtime-features.ts b/lib/server/runtime-features.ts index b0cc56b..bc00fbf 100644 --- a/lib/server/runtime-features.ts +++ b/lib/server/runtime-features.ts @@ -16,21 +16,50 @@ function isCloudflareDeployment(): boolean { ); } +function normalizeToggle(value: string | undefined): boolean | null { + const normalized = value?.trim().toLowerCase(); + if (!normalized) return null; + + if (['1', 'true', 'yes', 'on', 'enabled', 'enable'].includes(normalized)) { + return true; + } + + if (['0', 'false', 'no', 'off', 'disabled', 'disable'].includes(normalized)) { + return false; + } + + return null; +} + +function getMediaProxyOverride(): boolean | null { + const explicitMode = normalizeToggle(process.env.KVIDEO_MEDIA_PROXY_MODE); + if (explicitMode !== null) return explicitMode; + + return normalizeToggle(process.env.KVIDEO_MEDIA_PROXY_ENABLED); +} + function getRestrictedFeatures( deploymentProvider: RuntimeFeatures['deploymentProvider'], deploymentProviderLabel: string ): RuntimeFeatures { + const mediaProxyEnabled = getMediaProxyOverride() === true; + const restrictionSummary = mediaProxyEnabled + ? `${deploymentProviderLabel} 托管部署已通过部署变量显式开启外部媒体代理;IPTV 流中继仍关闭。未启用认证时,公共代理仍需要 KVIDEO_PUBLIC_RELAY_ENABLED=true。` + : `${deploymentProviderLabel} 托管部署默认关闭外部媒体代理、热链转发和 IPTV 流中继。若只需要修复 iOS/Safari HLS 播放兼容,可设置 KVIDEO_MEDIA_PROXY_MODE=enabled,并配置认证或 KVIDEO_PUBLIC_RELAY_ENABLED=true。`; + return { deploymentProvider, deploymentProviderLabel, restrictedManagedDeployment: true, - mediaProxyEnabled: false, + mediaProxyEnabled, iptvEnabled: false, - restrictionSummary: `${deploymentProviderLabel} 托管部署会启用合规模式:关闭外部媒体代理、热链转发和 IPTV 流中继。需要这些能力时请改用 Docker 或传统 Node.js 自托管。`, + restrictionSummary, }; } export function getRuntimeFeatures(): RuntimeFeatures { + const mediaProxyOverride = getMediaProxyOverride(); + if (isVercelDeployment()) { return getRestrictedFeatures('vercel', 'Vercel'); } @@ -43,9 +72,10 @@ export function getRuntimeFeatures(): RuntimeFeatures { deploymentProvider: 'self-hosted', deploymentProviderLabel: '自托管', restrictedManagedDeployment: false, - mediaProxyEnabled: true, + mediaProxyEnabled: mediaProxyOverride !== false, iptvEnabled: true, - restrictionSummary: null, + restrictionSummary: mediaProxyOverride === false + ? '外部媒体代理已通过部署变量显式关闭。' + : null, }; } - diff --git a/package-lock.json b/package-lock.json index f6f57d6..a550544 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.9.4", + "version": "4.9.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.9.4", + "version": "4.9.5", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index a88ad5c..f14f109 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.9.4", + "version": "4.9.5", "private": true, "scripts": { "dev": "next dev --port ${PORT:-3000}", diff --git a/tests/redis-client.test.ts b/tests/redis-client.test.ts index 4a0c230..ca1a887 100644 --- a/tests/redis-client.test.ts +++ b/tests/redis-client.test.ts @@ -148,3 +148,20 @@ test('TCP Redis client supports Docker-style redis:// storage', async () => { delete process.env.REDIS_URL; resetRedisClientForTests(); }); + +test('managed deployments ignore incomplete Upstash credentials without throwing', () => { + process.env.VERCEL = '1'; + process.env.UPSTASH_REDIS_REST_URL = 'https://example.upstash.io'; + delete process.env.UPSTASH_REDIS_REST_TOKEN; + process.env.REDIS_URL = 'redis://127.0.0.1:6379/0'; + resetRedisClientForTests(); + + try { + assert.equal(getRedisClient(), null); + } finally { + delete process.env.VERCEL; + delete process.env.UPSTASH_REDIS_REST_URL; + delete process.env.REDIS_URL; + resetRedisClientForTests(); + } +}); diff --git a/tests/runtime-features.test.ts b/tests/runtime-features.test.ts new file mode 100644 index 0000000..3b34ff7 --- /dev/null +++ b/tests/runtime-features.test.ts @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { getRuntimeFeatures } from '@/lib/server/runtime-features'; + +const ENV_KEYS = [ + 'VERCEL', + 'VERCEL_ENV', + 'CF_PAGES', + 'CF_PAGES_URL', + 'CLOUDFLARE_ACCOUNT_ID', + 'CF_ACCOUNT_ID', + 'WORKERS_CI', + 'KVIDEO_MEDIA_PROXY_MODE', + 'KVIDEO_MEDIA_PROXY_ENABLED', +] as const; + +function withRuntimeEnv(env: Record, callback: () => void): void { + const previous = new Map(); + + for (const key of ENV_KEYS) { + previous.set(key, process.env[key]); + delete process.env[key]; + } + + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + try { + callback(); + } finally { + for (const key of ENV_KEYS) { + const value = previous.get(key); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +test('managed deployments disable media proxy by default', () => { + withRuntimeEnv({ VERCEL: '1' }, () => { + const features = getRuntimeFeatures(); + + assert.equal(features.deploymentProvider, 'vercel'); + assert.equal(features.restrictedManagedDeployment, true); + assert.equal(features.mediaProxyEnabled, false); + assert.equal(features.iptvEnabled, false); + assert.match(features.restrictionSummary ?? '', /KVIDEO_MEDIA_PROXY_MODE=enabled/); + }); +}); + +test('managed deployments can explicitly enable media proxy for Safari compatibility', () => { + withRuntimeEnv({ + CF_PAGES: '1', + KVIDEO_MEDIA_PROXY_MODE: 'enabled', + }, () => { + const features = getRuntimeFeatures(); + + assert.equal(features.deploymentProvider, 'cloudflare'); + assert.equal(features.restrictedManagedDeployment, true); + assert.equal(features.mediaProxyEnabled, true); + assert.equal(features.iptvEnabled, false); + assert.match(features.restrictionSummary ?? '', /KVIDEO_PUBLIC_RELAY_ENABLED=true/); + }); +}); + +test('self-hosted deployments can explicitly disable media proxy', () => { + withRuntimeEnv({ KVIDEO_MEDIA_PROXY_MODE: 'disabled' }, () => { + const features = getRuntimeFeatures(); + + assert.equal(features.deploymentProvider, 'self-hosted'); + assert.equal(features.restrictedManagedDeployment, false); + assert.equal(features.mediaProxyEnabled, false); + assert.equal(features.iptvEnabled, true); + assert.ok(features.restrictionSummary); + }); +}); + +test('media proxy mode takes precedence over legacy boolean alias', () => { + withRuntimeEnv({ + VERCEL: '1', + KVIDEO_MEDIA_PROXY_MODE: 'disabled', + KVIDEO_MEDIA_PROXY_ENABLED: 'true', + }, () => { + const features = getRuntimeFeatures(); + + assert.equal(features.mediaProxyEnabled, false); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index f9c4e17..8d6f9fd 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -8,6 +8,10 @@ compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"] # wrangler secret put AUTH_SECRET # wrangler secret put UPSTASH_REDIS_REST_URL # wrangler secret put UPSTASH_REDIS_REST_TOKEN +# +# Optional iOS/Safari HLS compatibility mode: +# set KVIDEO_MEDIA_PROXY_MODE=enabled +# Public unauthenticated proxy access still requires KVIDEO_PUBLIC_RELAY_ENABLED=true. [assets] directory = ".open-next/assets"