diff --git a/README.md b/README.md
index 9b490a1..5d691a3 100644
--- a/README.md
+++ b/README.md
@@ -38,20 +38,47 @@
- **自动连播**:支持自动播放下一集
- **移动端优化**:专门为移动设备优化的播放器界面和双击手势控制
- **Chromecast 投屏**:支持通过 Google Cast 投屏到电视等设备
+- **画中画 (PiP)**:支持浮动窗口模式播放
- **全屏模式选择**:支持系统全屏和网页全屏两种模式
- **代理播放**:三种代理模式(智能重试、仅直连、总是代理),灵活应对不同网络环境
- **卡顿检测**:自动检测播放卡顿并提示
+- **键盘快捷键**:空格/K 播放暂停、F 全屏、M 静音、P 画中画、J/L/方向键 快进快退、上下键 音量调节
### 多源并行搜索
-- **聚合搜索引擎**:同时在多个视频源中并行搜索,大幅提升搜索速度
+- **聚合搜索引擎**:同时在多个视频源中并行搜索,通过 Server-Sent Events (SSE) 实时返回结果
- **自定义视频源**:支持添加、编辑和管理自定义视频源
+- **个人视频源**:非管理员用户可添加个人视频源,不影响其他用户(数据按用户隔离)
- **订阅源管理**:支持通过 JSON 链接批量导入和自动更新视频源
+- **JSON 批量导入**:在导入设置中直接粘贴 JSON 数组格式的视频源进行批量添加
- **智能解析**:统一的解析器系统,自动处理不同源的数据格式
- **搜索历史**:自动保存搜索历史,支持快速重新搜索
- **搜索结果显示**:支持默认显示和合并同名源两种模式
- **实时延迟监测**:可选实时显示各源的网络延迟
-- **源过滤**:支持按源和类型筛选搜索结果
+- **源过滤**:支持按源和类型筛选搜索结果,源标签支持按类型分组显示
+- **多级标签**:搜索结果和播放器中显示源名称和内容类型双重标签
+
+### 多线路折叠
+
+- **智能折叠**:播放页面的线路列表默认只显示前 5 个源,避免长列表影响体验
+- **展开/收起**:超过 5 个源时显示"展开更多 (N)"按钮,可随时展开查看全部线路
+- **按类型分组**:当线路包含类型信息时,自动按内容类型分组显示(如"电影"、"电视剧"等)
+- **延迟排序**:线路按网络延迟自动排序,最快的源排在前面
+- **源切换**:在线路列表中快速切换到其他源,支持断点续播
+
+### IPTV 直播
+
+- **M3U 播放列表**:支持导入和管理 M3U/M3U8 格式的 IPTV 源
+- **频道网格**:按分组展示频道,支持分页浏览
+- **自定义请求头**:自动解析 M3U 中的 `http-user-agent` 和 `http-referrer` 属性,通过代理传递
+- **流媒体代理**:内置 HLS 流代理,自动处理 CORS 问题和 M3U8 URL 重写
+- **智能内容检测**:当 content-type 不明确时,检查响应体内容自动识别 M3U8 格式
+- **重定向跟随**:自动跟随 HTTP 3xx 重定向,提升兼容性
+- **超时保护**:15 秒请求超时、30 秒加载超时、20 秒分片加载超时、3 次清单重试
+- **逐源频道缓存**:每个 IPTV 源的频道独立缓存,避免重复加载
+- **并发控制**:最多同时拉取 3 个源,防止网络拥堵
+- **权限控制**:通过 `iptv_access` 权限控制谁可以访问 IPTV 功能
+- **键盘快捷键**:播放器内支持空格暂停/继续、F 全屏、M 静音、方向键调节音量等
### 豆瓣集成
@@ -74,6 +101,7 @@
- **一键收藏**:在搜索结果和播放页面快速收藏视频
- **收藏列表**:独立的收藏侧边栏,快速访问已收藏的视频
+- **容量限制**:每个模式最多 100 条收藏
- **普通/高级隔离**:普通模式和高级模式的收藏互相独立
- **数据隔离**:多账户场景下,每个用户拥有独立的收藏数据
@@ -81,14 +109,35 @@
- **自动记录**:自动记录观看进度和历史
- **断点续播**:从上次观看位置继续播放
+- **智能去重**:按标题去重(V2),相同标题不同源的历史合并为一条
+- **容量限制**:最多保存 50 条历史记录
- **历史管理**:支持删除单条历史或清空全部历史
- **隐私保护**:所有数据存储在本地,不上传到服务器
+### 弹幕 (Danmaku)
+
+- **弹幕聚合**:接入自建弹幕聚合 API(兼容 [danmu_api](https://github.com/huangxd-/danmu_api) 格式),自动匹配当前播放内容
+- **Canvas 渲染**:基于 Canvas 的高性能弹幕渲染,支持数百条弹幕同时滚动,不影响播放交互
+- **滚动/顶部/底部弹幕**:支持三种弹幕类型,自动分配轨道防止重叠
+- **自定义显示**:可调节弹幕透明度(10%-100%)和字号(14/18/20/24/28px)
+- **显示区域**:可选 25%/50%/75%/100% 的屏幕区域显示弹幕
+- **播放器联动**:暂停时弹幕冻结,跳转时自动清除,全屏模式下正常显示
+- **多 API 管理**:每个用户可添加多个弹幕 API,选择当前使用的 API
+- **优先级规则**:用户选择的弹幕 API 优先于系统默认配置
+- **环境变量预设**:可通过 `NEXT_PUBLIC_DANMAKU_API_URL` 为所有用户预设弹幕 API 地址
+
+### 广告过滤
+
+- **多模式选择**:支持关闭、关键词过滤、智能启发式过滤(Beta)和激进模式
+- **UI 集成**:在播放器设置菜单中直接切换模式,实时生效
+- **自定义关键词**:支持通过环境变量或文件扩展过滤关键词
+- **高性能**:基于流式处理,对播放加载速度几乎无影响
+
### 响应式设计
- **全端适配**:完美支持桌面、平板、移动设备和 TV/机顶盒
- **移动优先**:专门的移动端组件和交互设计
-- **触摸优化**:针对触摸屏优化的手势和交互
+- **触摸优化**:针对触摸屏优化的手势和交互(双击快进/快退、滑动音量控制)
- **TV 适配**:遥控器方向键导航和大屏 UI 优化
### 主题系统
@@ -115,6 +164,8 @@
- **内容隔离**:高级内容与普通内容完全物理隔离,互不干扰
- **专属设置**:拥有独立的内容源管理和功能设置(播放器、显示、弹幕等设置完全独立)
- **独立推荐**:基于高级模式观看历史的个性化推荐,与普通模式互不影响
+- **分类浏览**:支持模糊匹配合并多源的相同分类标签,提供统一浏览体验
+- **交错排列**:多源结果智能交错排列,平衡各源展示
### TV/大屏适配
@@ -139,27 +190,13 @@
- **TV 模式注入**:页面加载后自动注入 `tv-mode` CSS 类,激活大屏优化样式
- **可配置 URL**:在 `ContentView.swift` 中修改 `kvideoURL` 常量指向你的部署实例
-### 广告过滤
-
-- **多模式选择**:支持关闭、关键词过滤、智能启发式过滤(Beta)和激进模式
-- **UI 集成**:在播放器设置菜单中直接切换模式,实时生效
-- **自定义关键词**:支持通过环境变量或文件扩展过滤关键词
-- **高性能**:基于流式处理,对播放加载速度几乎无影响
-
-### 弹幕 (Danmaku)
-
-- **弹幕聚合**:接入自建弹幕聚合 API(兼容 [danmu_api](https://github.com/huangxd-/danmu_api) 格式),自动匹配当前播放内容
-- **Canvas 渲染**:基于 Canvas 的高性能弹幕渲染,支持数百条弹幕同时滚动,不影响播放交互
-- **滚动/顶部/底部弹幕**:支持三种弹幕类型,自动分配轨道防止重叠
-- **自定义显示**:可调节弹幕透明度(10%-100%)和字号(14/18/20/24/28px)
-- **播放器联动**:暂停时弹幕冻结,跳转时自动清除,全屏模式下正常显示
-- **环境变量预设**:可通过 `NEXT_PUBLIC_DANMAKU_API_URL` 为所有用户预设弹幕 API 地址
-
### 数据管理
- **设置导出/导入**:支持将所有设置导出为 JSON 文件,方便备份和迁移
+- **JSON 批量导入**:支持粘贴 JSON 数组格式的视频源进行批量导入
- **滚动位置记忆**:退出或刷新页面后,自动恢复到之前的滚动位置
- **返回顶部**:一键返回页面顶部
+- **数据隔离**:多账户场景下,所有用户数据(历史、收藏、设置、个人源)按 profileId 完全隔离
## 隐私保护
@@ -168,11 +205,12 @@
- **本地存储**:所有数据存储在本地浏览器中
- **无服务器数据**:不收集或上传任何用户数据
- **自定义源**:用户可自行配置视频源
-- **数据隔离**:多账户场景下,每个用户的数据(历史、收藏、设置)完全隔离
+- **数据隔离**:多账户场景下,每个用户的数据(历史、收藏、设置、个人源)完全隔离
+- **Profile ID**:基于密码的 SHA-256 哈希生成唯一用户标识,密码不可逆推
## 账户与访问控制
-KVideo 支持基于环境变量的账户认证系统,支持角色区分(管理员/观众)和数据隔离。
+KVideo 支持基于环境变量的账户认证系统,支持角色区分和细粒度权限控制。
### 方式一:单管理员密码
@@ -183,27 +221,59 @@ KVideo 支持基于环境变量的账户认证系统,支持角色区分(管
docker run -d -p 3000:3000 -e ADMIN_PASSWORD=your_password --name kvideo kuekhaoyang/kvideo:latest
```
-登录后自动获得管理员权限,可管理所有设置。
+登录后自动获得超级管理员权限,可管理所有设置。
> **向后兼容**:`ACCESS_PASSWORD` 环境变量仍然有效,当 `ADMIN_PASSWORD` 未设置时,`ACCESS_PASSWORD` 将作为管理员密码使用。
### 方式二:多账户系统
-通过 `ACCOUNTS` 环境变量配置多个账户,每个账户拥有独立的数据空间(收藏、历史、设置等)。
+通过 `ACCOUNTS` 环境变量配置多个账户,每个账户拥有独立的数据空间(收藏、历史、设置、个人源等)。
-**格式:** `密码:名称[:角色]`,多个账户用逗号分隔。角色可选 `admin`(管理员)或 `viewer`(观众,默认)。
+**格式:** `密码:名称[:角色[:权限1|权限2|...]]`,多个账户用逗号分隔。
+
+- **角色**:`super_admin`(超级管理员)、`admin`(管理员)或 `viewer`(观众,默认)
+- **权限**(可选):使用 `|` 分隔,为该账户添加其角色之外的额外权限
```bash
-# Docker
+# 基本用法
docker run -d -p 3000:3000 \
-e ACCOUNTS="pass1:张三:admin,pass2:李四:viewer,pass3:王五" \
--name kvideo kuekhaoyang/kvideo:latest
+
+# 为观众添加额外权限(如 IPTV 访问和源管理)
+docker run -d -p 3000:3000 \
+ -e ACCOUNTS="pass1:张三:admin,pass2:李四:viewer:iptv_access|source_management" \
+ --name kvideo kuekhaoyang/kvideo:latest
```
**特点:**
-- 每个账户拥有独立的收藏、历史和设置数据
-- 管理员可管理源和设置,观众仅可查看和播放
+- 每个账户拥有独立的收藏、历史、设置和个人视频源数据
- 可同时配置 `ADMIN_PASSWORD`(作为超级管理员入口)
+- 支持为任何角色添加额外的自定义权限
+
+### 角色与权限
+
+| 权限 | 说明 | super_admin | admin | viewer |
+|------|------|:-----------:|:-----:|:------:|
+| `source_management` | 管理系统视频源 | ✓ | - | - |
+| `account_management` | 查看账户列表 | ✓ | - | - |
+| `danmaku_api` | 配置系统弹幕 API | ✓ | - | - |
+| `data_management` | 导出/导入/重置数据 | ✓ | - | - |
+| `player_settings` | 播放器设置 | ✓ | ✓ | - |
+| `danmaku_appearance` | 弹幕外观设置 | ✓ | ✓ | - |
+| `view_settings` | 显示设置 | ✓ | ✓ | ✓ |
+| `iptv_access` | 访问 IPTV 功能 | ✓ | ✓ | - |
+
+> 通过 ACCOUNTS 的第 4 个字段,可以为任何角色添加上表中的额外权限。例如让观众也能访问 IPTV:`password:name:viewer:iptv_access`
+
+### 个人视频源与弹幕 API
+
+所有已登录的用户(包括观众)都可以:
+
+- **添加个人视频源**:在设置页面添加自己的视频源,仅对自己可见,不影响其他用户
+- **管理弹幕 API**:添加多个弹幕 API 端点,选择当前使用的 API(优先于系统默认)
+
+这些数据按用户 profileId 隔离存储,切换账户后自动加载对应的个人配置。
### 方式三:会话持久化设置
@@ -337,6 +407,8 @@ docker run -d -p 3000:3000 \
设置后用户在播放器菜单中即可直接开启弹幕,也可在设置页面中覆盖此地址。
+> 用户还可以在设置页面的「弹幕 API」区域添加多个 API 端点并选择当前使用的,用户选择的 API 优先于系统默认配置。
+
## 自定义源 JSON 格式
如果你想创建自己的订阅源或批量导入源,可以使用以下 JSON 格式。
@@ -349,12 +421,15 @@ docker run -d -p 3000:3000 \
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
-| `id` | string | 是 | 唯一标识符,建议使用英文 |
+| `id` | string | 否 | 唯一标识符,自动生成或自定义 |
| `name` | string | 是 | 显示名称 |
| `baseUrl` | string | 是 | API 地址 (例如: `https://example.com/api.php/provide/vod`) |
+| `searchPath` | string | 否 | 搜索路径,默认 `/provide/vod` |
+| `detailPath` | string | 否 | 详情路径,默认 `/provide/vod` |
| `group` | string | 否 | 分组,可选值: `"normal"` (默认) 或 `"premium"` |
| `enabled` | boolean | 否 | 是否启用,默认为 `true` |
| `priority` | number | 否 | 优先级,数字越小优先级越高,默认为 1 |
+| `headers` | object | 否 | 自定义请求头 |
**示例 JSON:**
@@ -377,6 +452,10 @@ docker run -d -p 3000:3000 \
]
```
+### JSON 批量导入
+
+除了通过订阅 URL 或文件导入外,还可以在导入设置中选择 **JSON** 标签页,直接粘贴上述格式的 JSON 数组进行批量导入。系统会解析、预览数量后再确认导入。
+
### 重要的区别说明:订阅源 vs 视频源
**这是一个常见的误区,请仔细阅读:**
@@ -402,7 +481,7 @@ docker run -d -p 3000:3000 \
|--------|------|--------|
| `ADMIN_PASSWORD` | 管理员密码 | - |
| `ACCESS_PASSWORD` | 访问密码(向后兼容,等同于 `ADMIN_PASSWORD`) | - |
-| `ACCOUNTS` | 多账户配置,格式:`密码:名称[:角色]`,逗号分隔 | - |
+| `ACCOUNTS` | 多账户配置,格式:`密码:名称[:角色[:权限1\|权限2]]`,逗号分隔 | - |
| `PERSIST_SESSION` | 是否持久化登录会话 | `true` |
| `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `KVideo - 视频聚合平台` |
| `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `视频聚合平台` |
@@ -438,11 +517,13 @@ docker run -d -p 3000:3000 \
### 架构特点
- **App Router**:Next.js 的新路由系统,支持服务端组件和流式渲染
-- **API Routes**:内置 API 端点,处理认证、豆瓣数据和视频源代理
+- **API Routes**:内置 API 端点,处理认证、豆瓣数据、视频源代理、IPTV 代理和弹幕聚合
+- **Server-Sent Events**:搜索 API 使用 SSE 流式返回实时结果
- **Service Worker**:PWA 支持和资源缓存
- **Server Components**:优化首屏加载性能
- **Client Components**:复杂交互和状态管理
- **Edge Runtime**:API 路由运行在 Edge Runtime 上,提供更低延迟
+- **Profile-based Storage**:所有用户数据按 profileId(SHA-256 哈希)隔离存储
## 快速部署
@@ -518,6 +599,18 @@ docker run -d -p 3000:3000 --name kvideo kvideo
docker-compose up -d
```
+**完整配置示例(Docker):**
+
+```bash
+docker run -d -p 3000:3000 \
+ -e ADMIN_PASSWORD="admin123" \
+ -e ACCOUNTS="user1:用户一:admin,user2:用户二:viewer:iptv_access" \
+ -e NEXT_PUBLIC_SITE_NAME="我的视频" \
+ -e NEXT_PUBLIC_DANMAKU_API_URL="https://danmaku.example.com" \
+ -e SUBSCRIPTION_SOURCES='[{"name":"默认源","url":"https://example.com/sources.json"}]' \
+ --name kvideo kuekhaoyang/kvideo:latest
+```
+
#### 选项 4:传统 Node.js 部署
```bash
diff --git a/app/api/auth/accounts/route.ts b/app/api/auth/accounts/route.ts
index b18599d..d7fcb9d 100644
--- a/app/api/auth/accounts/route.ts
+++ b/app/api/auth/accounts/route.ts
@@ -16,6 +16,7 @@ const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
interface AccountInfo {
name: string;
role: 'super_admin' | 'admin' | 'viewer';
+ customPermissions?: string[];
}
function getAccountList(): AccountInfo[] {
@@ -37,8 +38,12 @@ function getAccountList(): AccountInfo[] {
const name = parts[1].trim();
const parsedRole = parts[2]?.trim();
const role = parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer';
+ const perms = parts[3]?.trim();
+ const customPermissions = perms
+ ? perms.split('|').map(p => p.trim()).filter(p => p.length > 0)
+ : undefined;
if (name) {
- accounts.push({ name, role });
+ accounts.push({ name, role, ...(customPermissions && customPermissions.length > 0 ? { customPermissions } : {}) });
}
}
});
diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts
index 8220e43..c031124 100644
--- a/app/api/auth/route.ts
+++ b/app/api/auth/route.ts
@@ -20,6 +20,7 @@ interface AccountEntry {
password: string;
name: string;
role: 'super_admin' | 'admin' | 'viewer';
+ customPermissions: string[];
}
function parseAccounts(): AccountEntry[] {
@@ -31,12 +32,16 @@ function parseAccounts(): AccountEntry[] {
.map(entry => {
const parts = entry.split(':');
if (parts.length < 2) return null;
- const [password, name, role] = parts;
+ const [password, name, role, perms] = parts;
const parsedRole = role?.trim();
+ const customPermissions = perms
+ ? perms.split('|').map(p => p.trim()).filter(p => p.length > 0)
+ : [];
return {
password: password.trim(),
name: name.trim(),
role: (parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer') as 'super_admin' | 'admin' | 'viewer',
+ customPermissions,
};
})
.filter((a): a is AccountEntry => a !== null && a.password.length > 0 && a.name.length > 0);
@@ -96,6 +101,7 @@ export async function POST(request: NextRequest) {
role: account.role,
profileId,
persistSession: PERSIST_SESSION,
+ customPermissions: account.customPermissions.length > 0 ? account.customPermissions : undefined,
});
}
}
diff --git a/app/api/iptv/stream/route.ts b/app/api/iptv/stream/route.ts
index 23e7fbd..f9446ec 100644
--- a/app/api/iptv/stream/route.ts
+++ b/app/api/iptv/stream/route.ts
@@ -46,6 +46,8 @@ function rewriteM3u8(content: string, baseUrl: string, proxyBase: string): strin
export async function GET(request: NextRequest) {
const url = request.nextUrl.searchParams.get('url');
+ const customUa = request.nextUrl.searchParams.get('ua');
+ const customReferer = request.nextUrl.searchParams.get('referer');
if (!url) {
return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
@@ -54,9 +56,9 @@ export async function GET(request: NextRequest) {
try {
const parsedUrl = new URL(url);
const fetchHeaders: Record = {
- 'User-Agent': 'Mozilla/5.0 (compatible; KVideo/1.0)',
+ 'User-Agent': customUa || 'Mozilla/5.0 (compatible; KVideo/1.0)',
'Accept': '*/*',
- 'Referer': `${parsedUrl.protocol}//${parsedUrl.host}/`,
+ 'Referer': customReferer || `${parsedUrl.protocol}//${parsedUrl.host}/`,
'Origin': `${parsedUrl.protocol}//${parsedUrl.host}`,
};
@@ -66,7 +68,15 @@ export async function GET(request: NextRequest) {
fetchHeaders['Range'] = rangeHeader;
}
- const response = await fetch(url, { headers: fetchHeaders });
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 15000);
+
+ const response = await fetch(url, {
+ headers: fetchHeaders,
+ redirect: 'follow',
+ signal: controller.signal,
+ });
+ clearTimeout(timeout);
if (!response.ok) {
return NextResponse.json(
@@ -76,10 +86,44 @@ export async function GET(request: NextRequest) {
}
const contentType = response.headers.get('content-type') || '';
- const isM3u8 = url.includes('.m3u8') ||
+ let isM3u8 = url.includes('.m3u8') ||
contentType.includes('mpegurl') ||
contentType.includes('x-mpegURL');
+ // If content-type is ambiguous, check the response body for M3U header
+ if (!isM3u8 && (contentType.includes('text/plain') || contentType.includes('application/octet-stream') || !contentType)) {
+ const text = await response.text();
+ if (text.trimStart().startsWith('#EXTM3U') || text.trimStart().startsWith('#EXT-X-')) {
+ isM3u8 = true;
+ }
+ // For detected M3U8 from body check, process inline
+ if (isM3u8) {
+ const proxyBase = `/api/iptv/stream?${customUa ? `ua=${encodeURIComponent(customUa)}&` : ''}${customReferer ? `referer=${encodeURIComponent(customReferer)}&` : ''}url=`;
+ const rewritten = rewriteM3u8(text, url, proxyBase);
+ return new NextResponse(rewritten, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/vnd.apple.mpegurl',
+ 'Cache-Control': 'no-cache',
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
+ 'Access-Control-Allow-Headers': '*',
+ },
+ });
+ }
+ // Not M3U8, return original text as binary-like response
+ return new NextResponse(text, {
+ status: response.status,
+ headers: {
+ 'Content-Type': contentType || 'video/mp2t',
+ 'Cache-Control': 'public, max-age=60',
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
+ 'Access-Control-Allow-Headers': '*',
+ },
+ });
+ }
+
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
@@ -89,7 +133,7 @@ export async function GET(request: NextRequest) {
if (isM3u8) {
// Parse and rewrite manifest
const text = await response.text();
- const proxyBase = `/api/iptv/stream?url=`;
+ const proxyBase = `/api/iptv/stream?${customUa ? `ua=${encodeURIComponent(customUa)}&` : ''}${customReferer ? `referer=${encodeURIComponent(customReferer)}&` : ''}url=`;
const rewritten = rewriteM3u8(text, url, proxyBase);
return new NextResponse(rewritten, {
diff --git a/app/player/page.tsx b/app/player/page.tsx
index c5998f8..b9d6bea 100644
--- a/app/player/page.tsx
+++ b/app/player/page.tsx
@@ -161,6 +161,7 @@ function PlayerContent() {
sourceName: match.sourceDisplayName || getSourceName(match.source),
latency: match.latency,
pic: match.vod_pic,
+ typeName: match.type_name,
});
// Update state incrementally
setDiscoveredSources([...found]);
diff --git a/app/settings/page.tsx b/app/settings/page.tsx
index 99d9ca0..8e07268 100644
--- a/app/settings/page.tsx
+++ b/app/settings/page.tsx
@@ -11,6 +11,8 @@ import { AccountSettings } from '@/components/settings/AccountSettings';
import { DisplaySettings } from '@/components/settings/DisplaySettings';
import { PlayerSettings } from '@/components/settings/PlayerSettings';
import { SettingsHeader } from '@/components/settings/SettingsHeader';
+import { UserSourceSettings } from '@/components/settings/UserSourceSettings';
+import { UserDanmakuSettings } from '@/components/settings/UserDanmakuSettings';
import { PermissionGate } from '@/components/PermissionGate';
import { hasPermission } from '@/lib/store/auth-store';
import { useSettingsPage } from './hooks/useSettingsPage';
@@ -102,6 +104,12 @@ export default function SettingsPage() {
onRememberScrollPositionChange={handleRememberScrollPositionChange}
/>
+ {/* Per-User Source Settings (visible to all logged-in users) */}
+
+
+ {/* Per-User Danmaku Settings (visible to all logged-in users) */}
+
+
{/* Source Management */}
= {
lowLatencyMode: true,
liveDurationInfinity: true,
manifestLoadingTimeOut: 10000,
- manifestLoadingMaxRetry: 2,
+ manifestLoadingMaxRetry: 3,
levelLoadingTimeOut: 10000,
- fragLoadingTimeOut: 15000,
+ fragLoadingTimeOut: 20000,
};
-const LOADING_TIMEOUT_MS = 20000;
+const LOADING_TIMEOUT_MS = 30000;
interface IPTVPlayerProps {
channel: M3UChannel;
@@ -30,8 +30,12 @@ interface IPTVPlayerProps {
onChannelChange: (channel: M3UChannel) => void;
}
-function getProxiedUrl(url: string): string {
- return `/api/iptv/stream?url=${encodeURIComponent(url)}`;
+function getProxiedUrl(url: string, ua?: string, referer?: string): string {
+ let proxyUrl = `/api/iptv/stream?`;
+ if (ua) proxyUrl += `ua=${encodeURIComponent(ua)}&`;
+ if (referer) proxyUrl += `referer=${encodeURIComponent(referer)}&`;
+ proxyUrl += `url=${encodeURIComponent(url)}`;
+ return proxyUrl;
}
function formatTime(seconds: number): string {
@@ -161,7 +165,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
video.removeAttribute('src');
video.load();
- const proxiedUrl = getProxiedUrl(url);
+ const proxiedUrl = getProxiedUrl(url, channel.httpUserAgent, channel.httpReferrer);
// Global loading timeout
let loadingResolved = false;
@@ -309,7 +313,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
}, { once: true });
}, { once: true });
}
- }, []);
+ }, [channel.httpUserAgent, channel.httpReferrer]);
// Load on channel/route change
useEffect(() => {
diff --git a/components/player/EpisodeList.tsx b/components/player/EpisodeList.tsx
index cdefba5..3e3d93a 100644
--- a/components/player/EpisodeList.tsx
+++ b/components/player/EpisodeList.tsx
@@ -20,6 +20,7 @@ export interface SourceInfo {
sourceName?: string;
latency?: number;
pic?: string;
+ typeName?: string;
}
interface EpisodeListProps {
@@ -47,6 +48,7 @@ export function EpisodeList({
const listRef = useRef(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [sourceExpanded, setSourceExpanded] = useState(false);
+ const [showAllSources, setShowAllSources] = useState(false);
// Source latency state
const [latencies, setLatencies] = useState>({});
@@ -233,74 +235,184 @@ export function EpisodeList({
刷新延迟
-
- {sortedSources.map((source, index) => {
- const isCurrent = source.source === currentSource;
- const latency = latencies[source.source] ?? source.latency;
+ {(() => {
+ const MAX_VISIBLE = 5;
+ const visibleSources = showAllSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE);
+ const hasMoreSources = sortedSources.length > MAX_VISIBLE;
- return (
-
+ )}
+ >
+ );
+ })()}
)}
diff --git a/components/player/hooks/useDanmaku.ts b/components/player/hooks/useDanmaku.ts
index 7f7e1dc..d56ffd6 100644
--- a/components/player/hooks/useDanmaku.ts
+++ b/components/player/hooks/useDanmaku.ts
@@ -2,6 +2,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { settingsStore } from '@/lib/store/settings-store';
+import { userSourcesStore } from '@/lib/store/user-sources-store';
import { parseDanmakuResponse, parseSearchResults, matchEpisode, fuzzyMatchTitle } from '@/lib/utils/danmaku-utils';
import type { DanmakuComment } from '@/lib/types/danmaku';
@@ -27,18 +28,22 @@ export function useDanmaku({ videoTitle, episodeName, episodeIndex }: UseDanmaku
const [error, setError] = useState(null);
const fetchedKeyRef = useRef('');
- // Sync with settings store
+ // Sync with settings store + user danmaku API override
useEffect(() => {
- const s = settingsStore.getSettings();
- setDanmakuEnabledState(s.danmakuEnabled);
- setApiUrl(s.danmakuApiUrl);
+ const updateApi = () => {
+ const s = settingsStore.getSettings();
+ setDanmakuEnabledState(s.danmakuEnabled);
- const unsub = settingsStore.subscribe(() => {
- const ns = settingsStore.getSettings();
- setDanmakuEnabledState(ns.danmakuEnabled);
- setApiUrl(ns.danmakuApiUrl);
- });
- return unsub;
+ // User's active danmaku API takes priority over system setting
+ const userApi = userSourcesStore.getActiveDanmakuApi();
+ setApiUrl(userApi ? userApi.url : s.danmakuApiUrl);
+ };
+
+ updateApi();
+
+ const unsub1 = settingsStore.subscribe(updateApi);
+ const unsub2 = userSourcesStore.subscribe(updateApi);
+ return () => { unsub1(); unsub2(); };
}, []);
const setDanmakuEnabled = useCallback((v: boolean) => {
diff --git a/components/search/SourceBadgeList.tsx b/components/search/SourceBadgeList.tsx
index 5b507e4..c537871 100644
--- a/components/search/SourceBadgeList.tsx
+++ b/components/search/SourceBadgeList.tsx
@@ -6,7 +6,7 @@
'use client';
-import { useState, useRef, useCallback, useEffect } from 'react';
+import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { Icons } from '@/components/ui/Icon';
import { SourceBadgeItem } from './SourceBadgeItem';
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
@@ -17,6 +17,7 @@ interface Source {
id: string;
name: string;
count: number;
+ typeName?: string;
}
interface SourceBadgeListProps {
@@ -37,6 +38,19 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So
const badgeContainerRef = useRef(null);
const badgeRefs = useRef<(HTMLButtonElement | null)[]>([]);
+ // Group sources by typeName if type info is available
+ const typeGroups = useMemo(() => {
+ const groups = new Map();
+ for (const source of sources) {
+ const type = source.typeName || '';
+ if (!groups.has(type)) groups.set(type, []);
+ groups.get(type)!.push(source);
+ }
+ // Only use grouping if there are meaningful type names
+ const hasTypes = groups.size > 1 || (groups.size === 1 && !groups.has(''));
+ return hasTypes ? groups : null;
+ }, [sources]);
+
const toggleExpanded = useCallback(() => {
setIsExpanded(prev => {
const next = !prev;
@@ -97,19 +111,47 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So
ref={badgeContainerRef}
className="flex items-center gap-2 flex-wrap p-1"
>
- {sources.map((source, index) => (
- onToggleSource(source.id)}
- isFocused={focusedIndex === index}
- onFocus={() => setFocusedIndex(index)}
- innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }}
- />
- ))}
+ {typeGroups ? (
+ Array.from(typeGroups.entries()).map(([typeName, typeSources]) => (
+
+ {typeName && (
+
+ {typeName}:
+
+ )}
+ {typeSources.map((source) => {
+ const globalIndex = sources.indexOf(source);
+ return (
+ onToggleSource(source.id)}
+ isFocused={focusedIndex === globalIndex}
+ onFocus={() => setFocusedIndex(globalIndex)}
+ innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[globalIndex] = el; }}
+ />
+ );
+ })}
+
+ ))
+ ) : (
+ sources.map((source, index) => (
+ onToggleSource(source.id)}
+ isFocused={focusedIndex === index}
+ onFocus={() => setFocusedIndex(index)}
+ innerRef={(el: HTMLButtonElement | null) => { badgeRefs.current[index] = el; }}
+ />
+ ))
+ )}
diff --git a/components/search/VideoCard.tsx b/components/search/VideoCard.tsx
index 3de0141..4091fbc 100644
--- a/components/search/VideoCard.tsx
+++ b/components/search/VideoCard.tsx
@@ -90,11 +90,18 @@ export const VideoCard = memo(({
{/* Badge Container */}
- {video.sourceName && (
-
- {video.sourceName}
-
- )}
+
+ {video.sourceName && (
+
+ {video.sourceName}
+
+ )}
+ {video.type_name && (
+
+ {video.type_name}
+
+ )}
+
{displayLatency !== undefined && (
diff --git a/components/search/VideoGroupCard.tsx b/components/search/VideoGroupCard.tsx
index 7110c24..884acf6 100644
--- a/components/search/VideoGroupCard.tsx
+++ b/components/search/VideoGroupCard.tsx
@@ -66,6 +66,7 @@ export const VideoGroupCard = memo
(({
sourceName: v.sourceName,
latency: v.latency,
pic: v.vod_pic,
+ typeName: v.type_name,
}));
params.set('groupedSources', JSON.stringify(groupData));
}
diff --git a/components/settings/AccountSettings.tsx b/components/settings/AccountSettings.tsx
index 170f7a7..a55cdfe 100644
--- a/components/settings/AccountSettings.tsx
+++ b/components/settings/AccountSettings.tsx
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
-import { getSession, clearSession, hasPermission, type Role } from '@/lib/store/auth-store';
+import { getSession, clearSession, hasPermission, type Role, type Permission } from '@/lib/store/auth-store';
import { SettingsSection } from './SettingsSection';
import { Icons } from '@/components/ui/Icon';
import { LogOut, Shield, Info } from 'lucide-react';
@@ -9,14 +9,32 @@ import { LogOut, Shield, Info } from 'lucide-react';
interface AccountInfo {
name: string;
role: Role;
+ customPermissions?: string[];
}
interface ConfigEntry {
password: string;
name: string;
role: Role;
+ customPermissions: Permission[];
}
+const ALL_PERMISSIONS: { key: Permission; label: string }[] = [
+ { key: 'source_management', label: '视频源管理' },
+ { key: 'account_management', label: '账户管理' },
+ { key: 'danmaku_api', label: '弹幕 API' },
+ { key: 'data_management', label: '数据管理' },
+ { key: 'player_settings', label: '播放器设置' },
+ { key: 'danmaku_appearance', label: '弹幕外观' },
+ { key: 'iptv_access', label: 'IPTV 访问' },
+];
+
+const ROLE_PERMISSIONS: Record = {
+ super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'],
+ admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'],
+ viewer: ['view_settings'],
+};
+
export function AccountSettings() {
const [session, setSessionState] = useState>(null);
const [hasAuth, setHasAuth] = useState(false);
@@ -54,7 +72,7 @@ export function AccountSettings() {
// Config generator helpers
const addConfigEntry = () => {
- setConfigEntries([...configEntries, { password: '', name: '', role: 'viewer' }]);
+ setConfigEntries([...configEntries, { password: '', name: '', role: 'viewer', customPermissions: [] }]);
};
const updateConfigEntry = (index: number, field: keyof ConfigEntry, value: string) => {
@@ -63,6 +81,18 @@ export function AccountSettings() {
setConfigEntries(updated);
};
+ const toggleConfigPermission = (index: number, perm: Permission) => {
+ const updated = [...configEntries];
+ const entry = updated[index];
+ const perms = entry.customPermissions || [];
+ if (perms.includes(perm)) {
+ entry.customPermissions = perms.filter(p => p !== perm);
+ } else {
+ entry.customPermissions = [...perms, perm];
+ }
+ setConfigEntries(updated);
+ };
+
const removeConfigEntry = (index: number) => {
setConfigEntries(configEntries.filter((_, i) => i !== index));
};
@@ -70,7 +100,17 @@ export function AccountSettings() {
const generateAccountsString = () => {
return configEntries
.filter(e => e.password.trim() && e.name.trim())
- .map(e => `${e.password}:${e.name}${e.role !== 'viewer' ? ':' + e.role : ''}`)
+ .map(e => {
+ let str = `${e.password}:${e.name}`;
+ const hasCustomPerms = e.customPermissions && e.customPermissions.length > 0;
+ if (e.role !== 'viewer' || hasCustomPerms) {
+ str += ':' + e.role;
+ }
+ if (hasCustomPerms) {
+ str += ':' + e.customPermissions.join('|');
+ }
+ return str;
+ })
.join(',');
};
@@ -92,6 +132,7 @@ export function AccountSettings() {
password: '',
name: a.name,
role: a.role,
+ customPermissions: (a.customPermissions || []) as Permission[],
}));
setConfigEntries(existingEntries);
setShowConfigGen(true);
@@ -279,6 +320,30 @@ export function AccountSettings() {
+ {/* Custom permissions: show only those not in the selected role */}
+ {(() => {
+ const rolePerms = ROLE_PERMISSIONS[entry.role] || [];
+ const extraPerms = ALL_PERMISSIONS.filter(p => !rolePerms.includes(p.key));
+ if (extraPerms.length === 0) return null;
+ return (
+
+ {extraPerms.map(p => {
+ const checked = entry.customPermissions?.includes(p.key) ?? false;
+ return (
+
+ );
+ })}
+
+ );
+ })()}
removeConfigEntry(index)}
@@ -331,7 +396,7 @@ export function AccountSettings() {
ADMIN_PASSWORD — 单管理员密码
-
ACCOUNTS — 多账户(密码:名称[:角色])
+
ACCOUNTS — 多账户(密码:名称[:角色[:权限1|权限2]])
diff --git a/components/settings/ImportModal.tsx b/components/settings/ImportModal.tsx
index de151ef..0bfb6db 100644
--- a/components/settings/ImportModal.tsx
+++ b/components/settings/ImportModal.tsx
@@ -5,6 +5,7 @@ import { ImportModalTabs } from './import/ImportModalTabs';
import { FileImportTab } from './import/FileImportTab';
import { LinkImportTab } from './import/LinkImportTab';
import { SubscriptionImportTab } from './import/SubscriptionImportTab';
+import { JsonImportTab } from './import/JsonImportTab';
import type { ImportResult } from '@/lib/utils/source-import-utils';
import type { SourceSubscription } from '@/lib/types';
import { ModalBackdrop } from '@/components/ui/ModalBackdrop';
@@ -33,7 +34,7 @@ export function ImportModal({
onRemoveSubscription,
onRefreshSubscription
}: ImportModalProps) {
- const [activeTab, setActiveTab] = useState<'file' | 'link' | 'subscription'>('file');
+ const [activeTab, setActiveTab] = useState<'file' | 'link' | 'subscription' | 'json'>('file');
// Reset tab on open
useEffect(() => {
@@ -90,6 +91,10 @@ export function ImportModal({
onRefresh={onRefreshSubscription}
/>
)}
+
+ {activeTab === 'json' && (
+
+ )}
diff --git a/components/settings/UserDanmakuSettings.tsx b/components/settings/UserDanmakuSettings.tsx
new file mode 100644
index 0000000..0a90f6e
--- /dev/null
+++ b/components/settings/UserDanmakuSettings.tsx
@@ -0,0 +1,141 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { SettingsSection } from './SettingsSection';
+import { Icons } from '@/components/ui/Icon';
+import { userSourcesStore, type DanmakuApiEntry } from '@/lib/store/user-sources-store';
+import { settingsStore } from '@/lib/store/settings-store';
+
+export function UserDanmakuSettings() {
+ const [apis, setApis] = useState([]);
+ const [activeId, setActiveId] = useState(null);
+ const [systemApiUrl, setSystemApiUrl] = useState('');
+ const [name, setName] = useState('');
+ const [url, setUrl] = useState('');
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ const state = userSourcesStore.getState();
+ setApis(state.danmakuApis);
+ setActiveId(state.activeDanmakuApiId);
+ setSystemApiUrl(settingsStore.getSettings().danmakuApiUrl);
+
+ const unsub = userSourcesStore.subscribe(() => {
+ const s = userSourcesStore.getState();
+ setApis(s.danmakuApis);
+ setActiveId(s.activeDanmakuApiId);
+ });
+ const unsub2 = settingsStore.subscribe(() => {
+ setSystemApiUrl(settingsStore.getSettings().danmakuApiUrl);
+ });
+ return () => { unsub(); unsub2(); };
+ }, []);
+
+ const handleAdd = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!name.trim() || !url.trim()) {
+ setError('名称和 URL 不能为空');
+ return;
+ }
+ try {
+ new URL(url);
+ } catch {
+ setError('请输入有效的 URL');
+ return;
+ }
+ const id = `danmaku-${Date.now().toString(36)}`;
+ userSourcesStore.addDanmakuApi({ id, name: name.trim(), url: url.trim() });
+ setName('');
+ setUrl('');
+ setError('');
+ };
+
+ return (
+
+
+ {/* Add form */}
+
+
+ {/* API list with radio select */}
+
+ {/* System default option */}
+
userSourcesStore.setActiveDanmakuApi(null)}
+ className={`w-full flex items-center gap-3 px-4 py-2.5 bg-[var(--glass-bg)] border rounded-[var(--radius-2xl)] text-left transition-all cursor-pointer ${
+ activeId === null ? 'border-[var(--accent-color)] bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)]' : 'border-[var(--glass-border)]'
+ }`}
+ >
+
+ {activeId === null && }
+
+
+
使用系统默认
+ {systemApiUrl && (
+
{systemApiUrl}
+ )}
+ {!systemApiUrl && (
+
未配置系统弹幕 API
+ )}
+
+
+
+ {apis.map(api => (
+
+
userSourcesStore.setActiveDanmakuApi(api.id)}
+ className="flex items-center gap-3 flex-1 min-w-0 text-left cursor-pointer"
+ >
+
+ {activeId === api.id && }
+
+
+
{api.name}
+
{api.url}
+
+
+
userSourcesStore.removeDanmakuApi(api.id)}
+ className="p-1 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer flex-shrink-0"
+ >
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/components/settings/UserSourceSettings.tsx b/components/settings/UserSourceSettings.tsx
new file mode 100644
index 0000000..431b9d9
--- /dev/null
+++ b/components/settings/UserSourceSettings.tsx
@@ -0,0 +1,127 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { SettingsSection } from './SettingsSection';
+import { Icons } from '@/components/ui/Icon';
+import { userSourcesStore } from '@/lib/store/user-sources-store';
+import type { VideoSource } from '@/lib/types';
+
+export function UserSourceSettings() {
+ const [sources, setSources] = useState([]);
+ const [name, setName] = useState('');
+ const [baseUrl, setBaseUrl] = useState('');
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ setSources(userSourcesStore.getSources());
+ const unsub = userSourcesStore.subscribe(() => {
+ setSources(userSourcesStore.getSources());
+ });
+ return unsub;
+ }, []);
+
+ const handleAdd = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!name.trim() || !baseUrl.trim()) {
+ setError('名称和接口地址不能为空');
+ return;
+ }
+ try {
+ new URL(baseUrl);
+ } catch {
+ setError('请输入有效的 URL');
+ return;
+ }
+
+ const id = `user-${name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Date.now().toString(36)}`;
+ const source: VideoSource = {
+ id,
+ name: name.trim(),
+ baseUrl: baseUrl.trim(),
+ searchPath: '/provide/vod',
+ detailPath: '/provide/vod',
+ enabled: true,
+ };
+ userSourcesStore.addSource(source);
+ setName('');
+ setBaseUrl('');
+ setError('');
+ };
+
+ return (
+
+
+ {/* Add form */}
+
+
+ {/* Source list */}
+ {sources.length > 0 && (
+
+ {sources.map(source => (
+
+
+
userSourcesStore.toggleSource(source.id)}
+ className={`w-8 h-5 rounded-full transition-colors cursor-pointer relative flex-shrink-0 ${
+ source.enabled !== false ? 'bg-[var(--accent-color)]' : 'bg-[var(--glass-border)]'
+ }`}
+ >
+
+
+
+
{source.name}
+
{source.baseUrl}
+
+
+
userSourcesStore.removeSource(source.id)}
+ className="p-1 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer flex-shrink-0"
+ >
+
+
+
+ ))}
+
+ )}
+
+ {sources.length === 0 && (
+
+ 还没有个人视频源,添加一个试试吧。
+
+ )}
+
+
+ );
+}
diff --git a/components/settings/import/ImportModalTabs.tsx b/components/settings/import/ImportModalTabs.tsx
index b029830..3816a3a 100644
--- a/components/settings/import/ImportModalTabs.tsx
+++ b/components/settings/import/ImportModalTabs.tsx
@@ -1,8 +1,8 @@
'use client';
interface ImportModalTabsProps {
- activeTab: 'file' | 'link' | 'subscription';
- onTabChange: (tab: 'file' | 'link' | 'subscription') => void;
+ activeTab: 'file' | 'link' | 'subscription' | 'json';
+ onTabChange: (tab: 'file' | 'link' | 'subscription' | 'json') => void;
}
export function ImportModalTabs({ activeTab, onTabChange }: ImportModalTabsProps) {
@@ -10,6 +10,7 @@ export function ImportModalTabs({ activeTab, onTabChange }: ImportModalTabsProps
{ id: 'file', label: '文件导入' },
{ id: 'link', label: '链接导入' },
{ id: 'subscription', label: '订阅管理' },
+ { id: 'json', label: 'JSON' },
] as const;
return (
diff --git a/components/settings/import/JsonImportTab.tsx b/components/settings/import/JsonImportTab.tsx
new file mode 100644
index 0000000..5c032a9
--- /dev/null
+++ b/components/settings/import/JsonImportTab.tsx
@@ -0,0 +1,142 @@
+'use client';
+
+import { useState } from 'react';
+import { Icons } from '@/components/ui/Icon';
+import { settingsStore } from '@/lib/store/settings-store';
+import type { VideoSource } from '@/lib/types';
+
+export function JsonImportTab() {
+ const [jsonText, setJsonText] = useState('');
+ const [error, setError] = useState('');
+ const [preview, setPreview] = useState(null);
+ const [imported, setImported] = useState(false);
+
+ const handleParse = () => {
+ setError('');
+ setPreview(null);
+ if (!jsonText.trim()) {
+ setError('请粘贴 JSON 内容');
+ return;
+ }
+
+ try {
+ const parsed = JSON.parse(jsonText);
+ if (!Array.isArray(parsed)) {
+ setError('JSON 格式错误:应为数组');
+ return;
+ }
+
+ const sources: VideoSource[] = [];
+ for (let i = 0; i < parsed.length; i++) {
+ const item = parsed[i];
+ if (!item.name || !item.baseUrl) {
+ setError(`第 ${i + 1} 项缺少 name 或 baseUrl`);
+ return;
+ }
+ sources.push({
+ id: item.id || `json-${item.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${i}`,
+ name: item.name,
+ baseUrl: item.baseUrl,
+ searchPath: item.searchPath || '/provide/vod',
+ detailPath: item.detailPath || '/provide/vod',
+ enabled: item.enabled !== false,
+ headers: item.headers,
+ priority: item.priority,
+ group: item.group,
+ });
+ }
+
+ if (sources.length === 0) {
+ setError('没有有效的视频源');
+ return;
+ }
+
+ setPreview(sources);
+ } catch (e) {
+ setError('JSON 解析失败:' + (e instanceof Error ? e.message : '格式错误'));
+ }
+ };
+
+ const handleImport = () => {
+ if (!preview) return;
+
+ const settings = settingsStore.getSettings();
+ const existingIds = new Set(settings.sources.map(s => s.id));
+ const newSources = preview.filter(s => !existingIds.has(s.id));
+ const updatedSources = [...settings.sources, ...newSources];
+ settingsStore.saveSettings({ ...settings, sources: updatedSources });
+ setImported(true);
+ setPreview(null);
+ setJsonText('');
+ };
+
+ return (
+
+
+ 粘贴 JSON 数组格式的视频源配置。格式:
+
+ {'[{ "name": "...", "baseUrl": "..." }]'}
+
+
+
+
+ );
+}
diff --git a/lib/hooks/useHomePage.ts b/lib/hooks/useHomePage.ts
index 1adc447..51f42b7 100644
--- a/lib/hooks/useHomePage.ts
+++ b/lib/hooks/useHomePage.ts
@@ -4,6 +4,7 @@ import { useSearchCache } from '@/lib/hooks/useSearchCache';
import { useParallelSearch } from '@/lib/hooks/useParallelSearch';
import { useSubscriptionSync } from '@/lib/hooks/useSubscriptionSync';
import { settingsStore, type SortOption } from '@/lib/store/settings-store';
+import { userSourcesStore } from '@/lib/store/user-sources-store';
export function useHomePage() {
useSubscriptionSync();
@@ -48,11 +49,20 @@ export function useHomePage() {
const settings = settingsStore.getSettings();
const enabledSources = settings.sources.filter(s => s.enabled);
- if (enabledSources.length === 0) {
+ // Merge user personal sources
+ const userSources = userSourcesStore.getSources().filter(s => s.enabled !== false);
+ const allSources = [...enabledSources];
+ for (const us of userSources) {
+ if (!allSources.find(s => s.id === us.id)) {
+ allSources.push(us);
+ }
+ }
+
+ if (allSources.length === 0) {
return false;
}
- performSearch(searchQuery, enabledSources, settings.sortBy);
+ performSearch(searchQuery, allSources, settings.sortBy);
hasSearchedWithSourcesRef.current = true;
return true;
}, [performSearch]);
diff --git a/lib/store/auth-store.ts b/lib/store/auth-store.ts
index 5d141bb..e925e5d 100644
--- a/lib/store/auth-store.ts
+++ b/lib/store/auth-store.ts
@@ -25,6 +25,7 @@ export interface AuthSession {
profileId: string;
name: string;
role: Role;
+ customPermissions?: Permission[];
}
const SESSION_KEY = 'kvideo-session';
@@ -76,7 +77,9 @@ export function isAdmin(): boolean {
export function hasPermission(permission: Permission): boolean {
const session = getSession();
if (!session) return true; // No auth configured = full access
- return ROLE_PERMISSIONS[session.role]?.includes(permission) ?? false;
+ if (ROLE_PERMISSIONS[session.role]?.includes(permission)) return true;
+ if (session.customPermissions?.includes(permission)) return true;
+ return false;
}
export function hasRole(minimumRole: Role): boolean {
diff --git a/lib/store/user-sources-store.ts b/lib/store/user-sources-store.ts
new file mode 100644
index 0000000..2afbaf8
--- /dev/null
+++ b/lib/store/user-sources-store.ts
@@ -0,0 +1,116 @@
+/**
+ * User Sources Store - Per-user video sources and danmaku APIs
+ * Uses localStorage keyed by profileId for isolation between users
+ */
+
+import { getProfileId } from './auth-store';
+import type { VideoSource } from '@/lib/types';
+
+export interface DanmakuApiEntry {
+ id: string;
+ name: string;
+ url: string;
+}
+
+interface UserSourcesState {
+ sources: VideoSource[];
+ danmakuApis: DanmakuApiEntry[];
+ activeDanmakuApiId: string | null;
+}
+
+function getStorageKey(): string {
+ const profileId = getProfileId();
+ return `kvideo-user-sources-${profileId}`;
+}
+
+function getState(): UserSourcesState {
+ if (typeof window === 'undefined') {
+ return { sources: [], danmakuApis: [], activeDanmakuApiId: null };
+ }
+ try {
+ const raw = localStorage.getItem(getStorageKey());
+ if (!raw) return { sources: [], danmakuApis: [], activeDanmakuApiId: null };
+ const parsed = JSON.parse(raw);
+ return {
+ sources: Array.isArray(parsed.sources) ? parsed.sources : [],
+ danmakuApis: Array.isArray(parsed.danmakuApis) ? parsed.danmakuApis : [],
+ activeDanmakuApiId: parsed.activeDanmakuApiId ?? null,
+ };
+ } catch {
+ return { sources: [], danmakuApis: [], activeDanmakuApiId: null };
+ }
+}
+
+function saveState(state: UserSourcesState): void {
+ if (typeof window === 'undefined') return;
+ localStorage.setItem(getStorageKey(), JSON.stringify(state));
+ userSourcesStore.notifyListeners();
+}
+
+export const userSourcesStore = {
+ listeners: new Set<() => void>(),
+
+ subscribe(listener: () => void): () => void {
+ this.listeners.add(listener);
+ return () => { this.listeners.delete(listener); };
+ },
+
+ notifyListeners(): void {
+ this.listeners.forEach(l => l());
+ },
+
+ getState,
+
+ getSources(): VideoSource[] {
+ return getState().sources;
+ },
+
+ getDanmakuApis(): DanmakuApiEntry[] {
+ return getState().danmakuApis;
+ },
+
+ getActiveDanmakuApi(): DanmakuApiEntry | null {
+ const state = getState();
+ if (!state.activeDanmakuApiId) return null;
+ return state.danmakuApis.find(a => a.id === state.activeDanmakuApiId) ?? null;
+ },
+
+ addSource(source: VideoSource): void {
+ const state = getState();
+ if (state.sources.find(s => s.id === source.id)) return;
+ saveState({ ...state, sources: [...state.sources, { ...source, enabled: true }] });
+ },
+
+ removeSource(id: string): void {
+ const state = getState();
+ saveState({ ...state, sources: state.sources.filter(s => s.id !== id) });
+ },
+
+ toggleSource(id: string): void {
+ const state = getState();
+ saveState({
+ ...state,
+ sources: state.sources.map(s =>
+ s.id === id ? { ...s, enabled: !s.enabled } : s
+ ),
+ });
+ },
+
+ addDanmakuApi(entry: DanmakuApiEntry): void {
+ const state = getState();
+ if (state.danmakuApis.find(a => a.id === entry.id)) return;
+ saveState({ ...state, danmakuApis: [...state.danmakuApis, entry] });
+ },
+
+ removeDanmakuApi(id: string): void {
+ const state = getState();
+ const newApis = state.danmakuApis.filter(a => a.id !== id);
+ const newActiveId = state.activeDanmakuApiId === id ? null : state.activeDanmakuApiId;
+ saveState({ ...state, danmakuApis: newApis, activeDanmakuApiId: newActiveId });
+ },
+
+ setActiveDanmakuApi(id: string | null): void {
+ const state = getState();
+ saveState({ ...state, activeDanmakuApiId: id });
+ },
+};
diff --git a/lib/types/index.ts b/lib/types/index.ts
index 26dae12..7aa2af3 100644
--- a/lib/types/index.ts
+++ b/lib/types/index.ts
@@ -51,6 +51,7 @@ export interface SourceBadge {
id: string;
name: string;
count: number;
+ typeName?: string;
}
export interface TypeBadge {
diff --git a/lib/utils/m3u-parser.ts b/lib/utils/m3u-parser.ts
index 4d3a567..6bd90d5 100644
--- a/lib/utils/m3u-parser.ts
+++ b/lib/utils/m3u-parser.ts
@@ -13,6 +13,8 @@ export interface M3UChannel {
routes?: string[];
sourceId?: string;
sourceName?: string;
+ httpUserAgent?: string;
+ httpReferrer?: string;
}
export interface M3UPlaylist {
@@ -40,6 +42,8 @@ export function parseM3U(content: string): M3UPlaylist {
const tvgLogoMatch = line.match(/tvg-logo="([^"]*)"/i);
const groupTitleMatch = line.match(/group-title="([^"]*)"/i);
const tvgIdMatch = line.match(/tvg-id="([^"]*)"/i);
+ const httpUserAgentMatch = line.match(/http-user-agent="([^"]*)"/i);
+ const httpReferrerMatch = line.match(/http-referrer="([^"]*)"/i);
if (tvgNameMatch) channel.tvgName = tvgNameMatch[1];
if (tvgLogoMatch) channel.logo = tvgLogoMatch[1];
@@ -48,6 +52,8 @@ export function parseM3U(content: string): M3UPlaylist {
if (channel.group) groupSet.add(channel.group);
}
if (tvgIdMatch) channel.tvgId = tvgIdMatch[1];
+ if (httpUserAgentMatch) channel.httpUserAgent = httpUserAgentMatch[1];
+ if (httpReferrerMatch) channel.httpReferrer = httpReferrerMatch[1];
// Extract channel name (after last comma)
const commaIndex = line.lastIndexOf(',');
diff --git a/package-lock.json b/package-lock.json
index 587eb77..c68f852 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "kvideo",
- "version": "4.4.2",
+ "version": "4.4.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kvideo",
- "version": "4.4.2",
+ "version": "4.4.3",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
diff --git a/package.json b/package.json
index 74c9555..7712d01 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "kvideo",
- "version": "4.4.2",
+ "version": "4.4.3",
"private": true,
"scripts": {
"dev": "next dev",