diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20f0447..c1d7b69 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## 4.9.4 - 2026-07-08
+
+- 设置页的代理播放区域显示内置 `/api/proxy` 端点和部署限制,明确该功能不是第三方 HTTP/SOCKS 代理服务器配置。
+- 新增 `ALLOW_LAN_ACCESS` 环境变量:开启后 `npm run dev` / `npm run start` 会绑定 `0.0.0.0`,开发模式会放行常见内网 IPv4 来源。
+- 新增 `LAN_ALLOWED_DEV_ORIGINS`,允许自定义 Next.js 开发模式的额外来源主机。
+- README 补充局域网访问和内置媒体代理说明,并同步修正 Docker hostname 注释。
+
## 4.9.3 - 2026-04-15
- 新增 Redis 托管账户模式:支持用户名密码登录、超级管理员账户 CRUD、权限编辑和密码重置。
diff --git a/Dockerfile b/Dockerfile
index ee116a2..bd67474 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -79,7 +79,7 @@ USER nextjs
EXPOSE 3000
ENV PORT=3000
-# set hostname to localhost
+# Bind the standalone server to all container interfaces.
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
diff --git a/README.md b/README.md
index 7d19012..5b41e0e 100644
--- a/README.md
+++ b/README.md
@@ -611,6 +611,43 @@ PORT=8080 npm run start
docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
```
+## 局域网 IP 访问
+
+通过 `ALLOW_LAN_ACCESS` 环境变量控制本机开发或传统 Node.js 自托管时是否允许同一局域网设备直接访问当前设备 IP。
+
+| 变量名 | 说明 | 默认值 |
+|--------|------|--------|
+| `ALLOW_LAN_ACCESS` | 设为 `true` / `1` / `yes` / `on` 时,`npm run dev` 和 `npm run start` 会绑定 `0.0.0.0`,并在开发模式下放行常见内网 IPv4 来源;未开启时默认只绑定 `localhost` | `false` |
+| `NEXT_PUBLIC_ALLOW_LAN_ACCESS` | `ALLOW_LAN_ACCESS` 的构建时兼容名称;优先使用 `ALLOW_LAN_ACCESS` | `false` |
+| `LAN_ALLOWED_DEV_ORIGINS` | 追加 Next.js 开发模式允许访问 dev 资源的主机名或通配符,逗号、空格或换行分隔 | - |
+
+**示例:**
+
+```bash
+# 开发模式允许同局域网设备访问
+ALLOW_LAN_ACCESS=true npm run dev
+
+# 传统 Node.js 生产模式允许同局域网设备访问
+ALLOW_LAN_ACCESS=true npm run start
+
+# 追加自定义开发来源;Next.js 需要主机名,不要填写路径
+ALLOW_LAN_ACCESS=true LAN_ALLOWED_DEV_ORIGINS="kvideo.lan,192.168.50.10" npm run dev
+```
+
+开启后访问地址通常是 `http://<当前设备内网 IP>:3000`。如果仍然无法访问,优先检查系统防火墙、路由器客户端隔离、端口映射、反向代理监听地址以及实际运行端口;这些不是 KVideo 前端代码可以绕过的问题。Docker 镜像已经在容器内绑定 `0.0.0.0`,是否能从局域网访问主要取决于 `-p` 端口映射和宿主机网络策略。
+
+## 内置媒体代理说明
+
+设置页的“代理播放模式”使用的是当前 KVideo 部署内置的 `/api/proxy` 媒体转发端点,不是让用户填写第三方 HTTP/SOCKS 代理服务器。
+
+| 模式 | 行为 |
+|------|------|
+| 智能重试 | 优先直连,播放失败后自动切换到 `/api/proxy` |
+| 仅直连 | 不使用 `/api/proxy`,失败时直接报错 |
+| 总是代理 | 播放地址始终通过 `/api/proxy` 转发 |
+
+`/api/proxy?url=` 只在 Docker 或传统 Node.js 自托管完整模式下启用。Vercel / Cloudflare 托管部署运行合规模式,会禁用外部媒体代理、热链转发和 IPTV 流中继;这种部署环境下设置页会显示仅支持直连播放。
+
## 自定义源 JSON 格式
如果你想创建自己的订阅源或批量导入源,可以使用以下 JSON 格式。
@@ -689,6 +726,8 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
| `PREMIUM_PASSWORD` | 高级内容独立密码,访问 `/premium` 时需输入 | - |
| `PERSIST_SESSION` | 是否持久化登录会话 | `true` |
| `PORT` | 自定义应用端口 | `3000` |
+| `ALLOW_LAN_ACCESS` / `NEXT_PUBLIC_ALLOW_LAN_ACCESS` | 允许同局域网设备通过当前设备 IP 访问本机开发或传统 Node.js 自托管服务 | `false` |
+| `LAN_ALLOWED_DEV_ORIGINS` / `NEXT_PUBLIC_LAN_ALLOWED_DEV_ORIGINS` | 追加 Next.js 开发模式允许访问 dev 资源的主机名或通配符 | - |
| `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `KVideo - 视频聚合平台` |
| `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `视频聚合平台` |
| `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `KVideo` |
@@ -823,6 +862,8 @@ npm start
应用将在 `http://localhost:3000` 启动。
+同一局域网设备需要直接访问当前设备 IP 时,使用 `ALLOW_LAN_ACCESS=true npm run start`,并确认系统防火墙允许入站访问对应端口。
+
#### 选项 3:Vercel / Cloudflare 托管部署(合规模式)
适用于只需要搜索、账户、设置、直连播放等基础能力,不需要外部媒体代理 / IPTV 中继的场景。
diff --git a/app-release.json b/app-release.json
index e87e30e..ad2e9ed 100644
--- a/app-release.json
+++ b/app-release.json
@@ -4,8 +4,30 @@
"name": "KVideo",
"branch": "main"
},
- "currentVersion": "4.9.2",
+ "currentVersion": "4.9.4",
"releases": [
+ {
+ "version": "4.9.4",
+ "publishedAt": "2026-07-08",
+ "title": "补充代理说明与局域网访问开关",
+ "notes": [
+ "设置页的代理播放区域显示内置 /api/proxy 端点和部署限制,明确该功能不是第三方 HTTP/SOCKS 代理服务器配置。",
+ "新增 ALLOW_LAN_ACCESS 环境变量:开启后 npm run dev / npm run start 会绑定 0.0.0.0,开发模式会放行常见内网 IPv4 来源。",
+ "新增 LAN_ALLOWED_DEV_ORIGINS,允许自定义 Next.js 开发模式的额外来源主机。",
+ "README 补充局域网访问和内置媒体代理说明,并同步修正 Docker hostname 注释。"
+ ]
+ },
+ {
+ "version": "4.9.3",
+ "publishedAt": "2026-04-15",
+ "title": "新增 Redis 托管账户模式",
+ "notes": [
+ "新增 Redis 托管账户模式:支持用户名密码登录、超级管理员账户 CRUD、权限编辑和密码重置。",
+ "认证改为服务端签名 HTTP-only 会话 Cookie,/api/user/config 与 /api/user/sync 不再信任客户端自报 profileId。",
+ "播放页线路列表补齐剩余问题:分辨率探测会按当前集数探测并缓存,打开线路列表时会自动定位当前线路,必要时自动展开隐藏项。",
+ "仓库补充了与新认证和线路列表逻辑对应的单元测试,并修正了本地 eslint 版本与 Next.js 规则链不兼容的问题。"
+ ]
+ },
{
"version": "4.9.2",
"publishedAt": "2026-04-12",
diff --git a/components/settings/PlayerSettings.tsx b/components/settings/PlayerSettings.tsx
index 0e155d4..f709a34 100644
--- a/components/settings/PlayerSettings.tsx
+++ b/components/settings/PlayerSettings.tsx
@@ -170,37 +170,48 @@ export function PlayerSettings({
控制视频播放时的网络请求策略
{mediaProxyEnabled ? (
-
-
-
-
+
+
+
+
+
+
+
+
内置代理端点
+
+ /api/proxy?url=<encoded-video-url>
+
+
+ 此处不是第三方 HTTP/SOCKS 代理配置。播放器会按上方模式把播放地址交给当前 KVideo 部署的内置代理;该能力只在 Docker 或传统 Node.js 自托管完整模式下启用。
+
+
) : (
@@ -208,6 +219,9 @@ export function PlayerSettings({
{restrictionSummary}
+
+ 内置代理端点为 /api/proxy,但当前部署模式已禁用该端点。
+
)}
diff --git a/lib/config/lan-access.ts b/lib/config/lan-access.ts
new file mode 100644
index 0000000..3475363
--- /dev/null
+++ b/lib/config/lan-access.ts
@@ -0,0 +1,72 @@
+type EnvLike = Record;
+
+const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'y', 'on', 'enabled', 'enable']);
+
+export const LAN_ACCESS_ENV_KEYS = ['ALLOW_LAN_ACCESS', 'NEXT_PUBLIC_ALLOW_LAN_ACCESS'] as const;
+export const LAN_ALLOWED_DEV_ORIGINS_ENV_KEYS = [
+ 'LAN_ALLOWED_DEV_ORIGINS',
+ 'NEXT_PUBLIC_LAN_ALLOWED_DEV_ORIGINS',
+] as const;
+
+export const DEFAULT_LAN_ALLOWED_DEV_ORIGINS = [
+ '10.*.*.*',
+ ...Array.from({ length: 16 }, (_, index) => `172.${16 + index}.*.*`),
+ '192.168.*.*',
+] as const;
+
+export function parseBooleanEnv(value: string | undefined | null): boolean {
+ if (!value) {
+ return false;
+ }
+
+ return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
+}
+
+export function isLanAccessEnabled(env: EnvLike = process.env): boolean {
+ return LAN_ACCESS_ENV_KEYS.some((key) => parseBooleanEnv(env[key]));
+}
+
+function normalizeAllowedOriginHost(rawOrigin: string): string | null {
+ const trimmed = rawOrigin.trim();
+
+ if (!trimmed) {
+ return null;
+ }
+
+ try {
+ const hostname = new URL(trimmed).hostname;
+ if (hostname) {
+ return hostname.toLowerCase();
+ }
+ } catch {
+ // Fall through to host[:port] parsing.
+ }
+
+ const withoutPort = trimmed.match(/^([^:/\s]+):\d+$/)?.[1] ?? trimmed;
+ return withoutPort.toLowerCase();
+}
+
+export function parseLanAllowedDevOrigins(value: string | undefined | null): string[] {
+ if (!value) {
+ return [];
+ }
+
+ const origins = value
+ .split(/[\s,]+/)
+ .map(normalizeAllowedOriginHost)
+ .filter((origin): origin is string => Boolean(origin));
+
+ return [...new Set(origins)];
+}
+
+export function getLanAllowedDevOrigins(env: EnvLike = process.env): string[] {
+ if (!isLanAccessEnabled(env)) {
+ return [];
+ }
+
+ const customOrigins = LAN_ALLOWED_DEV_ORIGINS_ENV_KEYS.flatMap((key) =>
+ parseLanAllowedDevOrigins(env[key])
+ );
+
+ return [...new Set([...DEFAULT_LAN_ALLOWED_DEV_ORIGINS, ...customOrigins])];
+}
diff --git a/next.config.ts b/next.config.ts
index 955d5ec..0b46ff3 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,6 +1,11 @@
import type { NextConfig } from "next";
+import { getLanAllowedDevOrigins } from "./lib/config/lan-access";
+
+const lanAllowedDevOrigins = getLanAllowedDevOrigins();
const nextConfig: NextConfig = {
+ ...(lanAllowedDevOrigins.length > 0 ? { allowedDevOrigins: lanAllowedDevOrigins } : {}),
+
// Performance optimizations
reactStrictMode: true,
poweredByHeader: false,
diff --git a/package-lock.json b/package-lock.json
index 38ab6a6..3cdac9f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "kvideo",
- "version": "4.9.2",
+ "version": "4.9.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "kvideo",
- "version": "4.9.2",
+ "version": "4.9.4",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
diff --git a/package.json b/package.json
index 5ba6ba2..966330c 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,11 @@
{
"name": "kvideo",
- "version": "4.9.2",
+ "version": "4.9.4",
"private": true,
"scripts": {
- "dev": "next dev --port ${PORT:-3000}",
+ "dev": "node scripts/next-with-lan-access.mjs dev",
"build": "next build --webpack",
- "start": "next start --port ${PORT:-3000}",
+ "start": "node scripts/next-with-lan-access.mjs start",
"lint": "eslint",
"test": "tsx --test tests/**/*.test.ts",
"pages:build": "next-on-pages"
diff --git a/scripts/next-with-lan-access.mjs b/scripts/next-with-lan-access.mjs
new file mode 100644
index 0000000..cd0328c
--- /dev/null
+++ b/scripts/next-with-lan-access.mjs
@@ -0,0 +1,48 @@
+#!/usr/bin/env node
+
+import { spawn } from 'node:child_process';
+
+const mode = process.argv[2];
+const forwardedArgs = process.argv.slice(3);
+const validModes = new Set(['dev', 'start']);
+
+if (!validModes.has(mode)) {
+ console.error('Usage: node scripts/next-with-lan-access.mjs [next args...]');
+ process.exit(1);
+}
+
+function isTruthyEnv(value) {
+ if (!value) {
+ return false;
+ }
+
+ return ['1', 'true', 'yes', 'y', 'on', 'enabled', 'enable'].includes(
+ value.trim().toLowerCase()
+ );
+}
+
+const lanAccessEnabled = isTruthyEnv(
+ process.env.ALLOW_LAN_ACCESS ?? process.env.NEXT_PUBLIC_ALLOW_LAN_ACCESS
+);
+const hostname = process.env.HOSTNAME || process.env.NEXT_HOSTNAME || (lanAccessEnabled ? '0.0.0.0' : 'localhost');
+const args = [mode, '--port', process.env.PORT || '3000'];
+
+if (hostname) {
+ args.push('--hostname', hostname);
+}
+
+args.push(...forwardedArgs);
+
+const child = spawn('next', args, {
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+});
+
+child.on('exit', (code, signal) => {
+ if (signal) {
+ process.kill(process.pid, signal);
+ return;
+ }
+
+ process.exit(code ?? 0);
+});
diff --git a/tests/lan-access.test.ts b/tests/lan-access.test.ts
new file mode 100644
index 0000000..431341d
--- /dev/null
+++ b/tests/lan-access.test.ts
@@ -0,0 +1,48 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ DEFAULT_LAN_ALLOWED_DEV_ORIGINS,
+ getLanAllowedDevOrigins,
+ isLanAccessEnabled,
+ parseBooleanEnv,
+ parseLanAllowedDevOrigins,
+} from '@/lib/config/lan-access';
+
+test('parseBooleanEnv only enables explicit truthy values', () => {
+ assert.equal(parseBooleanEnv('true'), true);
+ assert.equal(parseBooleanEnv(' ON '), true);
+ assert.equal(parseBooleanEnv('1'), true);
+ assert.equal(parseBooleanEnv('false'), false);
+ assert.equal(parseBooleanEnv('0'), false);
+ assert.equal(parseBooleanEnv(undefined), false);
+});
+
+test('isLanAccessEnabled supports server and public env names', () => {
+ assert.equal(isLanAccessEnabled({}), false);
+ assert.equal(isLanAccessEnabled({ ALLOW_LAN_ACCESS: 'true' }), true);
+ assert.equal(isLanAccessEnabled({ NEXT_PUBLIC_ALLOW_LAN_ACCESS: 'yes' }), true);
+});
+
+test('parseLanAllowedDevOrigins accepts hostnames, URLs, ports, and comma lists', () => {
+ assert.deepEqual(
+ parseLanAllowedDevOrigins('http://192.168.1.10:3000, local.kvideo.test:8080 *.lan.test'),
+ ['192.168.1.10', 'local.kvideo.test', '*.lan.test']
+ );
+});
+
+test('getLanAllowedDevOrigins returns defaults and custom origins only when enabled', () => {
+ assert.deepEqual(getLanAllowedDevOrigins({ LAN_ALLOWED_DEV_ORIGINS: '192.168.50.10' }), []);
+
+ const origins = getLanAllowedDevOrigins({
+ ALLOW_LAN_ACCESS: 'true',
+ LAN_ALLOWED_DEV_ORIGINS: '192.168.50.10',
+ });
+
+ assert.ok(origins.includes('10.*.*.*'));
+ assert.ok(origins.includes('172.16.*.*'));
+ assert.ok(origins.includes('172.31.*.*'));
+ assert.ok(origins.includes('192.168.*.*'));
+ assert.ok(origins.includes('192.168.50.10'));
+ assert.equal(origins.length, DEFAULT_LAN_ALLOWED_DEV_ORIGINS.length + 1);
+});