fix(player): allow managed media proxy opt-in

This commit is contained in:
kuekhaoyang
2026-05-02 16:53:15 +08:00
parent e0ba3b887f
commit 470213bffa
12 changed files with 204 additions and 21 deletions
+9
View File
@@ -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=
+8
View File
@@ -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` 启用自托管托管账户存储。
+11 -7
View File
@@ -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
+13 -1
View File
@@ -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",
+5 -5
View File
@@ -164,10 +164,10 @@ export function PlayerSettings({
<div>
<h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2">
<Icons.Globe size={18} className="text-[var(--accent-color)]" />
</h3>
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
iOS Safari
</p>
{mediaProxyEnabled ? (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
@@ -179,7 +179,7 @@ export function PlayerSettings({
}`}
>
<div className="font-semibold"> ()</div>
<div className="text-sm opacity-80 mt-1"></div>
<div className="text-sm opacity-80 mt-1"> iOS/Safari </div>
</button>
<button
onClick={() => onProxyModeChange('none')}
@@ -189,7 +189,7 @@ export function PlayerSettings({
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1">使</div>
<div className="text-sm opacity-80 mt-1">使</div>
</button>
<button
onClick={() => onProxyModeChange('always')}
@@ -199,7 +199,7 @@ export function PlayerSettings({
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
</div>
) : (
+3
View File
@@ -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
+35 -5
View File
@@ -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,
};
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.9.4",
"version": "4.9.5",
"private": true,
"scripts": {
"dev": "next dev --port ${PORT:-3000}",
+17
View File
@@ -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();
}
});
+96
View File
@@ -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<string, string | undefined>, callback: () => void): void {
const previous = new Map<string, string | undefined>();
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);
});
});
+4
View File
@@ -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"