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"