-```
-
-### 组件复用
-
-优先复用 `components/ui/` 下的基础组件:
-
-```typescript
-// ✅ 好:复用基础组件
-import { Button } from '@/components/ui/Button';
-import { Modal } from '@/components/ui/Modal';
-
-export function Feature() {
- return (
-
-
-
- );
-}
-
-// ❌ 不好:重新实现基础组件
-export function Feature() {
- return (
-
-
-
- );
-}
-```
-
-## 🧪 测试要求
-
-### 手动测试
-
-在提交 PR 前,请手动测试以下内容:
-
-#### 功能测试
-
-- [ ] 新功能按预期工作
-- [ ] 没有破坏现有功能
-- [ ] 边界情况处理正确
-
-#### 浏览器测试
-
-在以下浏览器中测试:
-
-- [ ] Chrome/Edge(最新版)
-- [ ] Firefox(最新版)
-- [ ] Safari(最新版)
-
-#### 响应式测试
-
-在以下设备尺寸测试:
-
-- [ ] 移动端(375px - 428px)
-- [ ] 平板端(768px - 1024px)
-- [ ] 桌面端(1280px+)
-
-#### 无障碍测试
-
-- [ ] 键盘导航正常工作
-- [ ] 焦点状态清晰可见
-- [ ] 屏幕阅读器友好
-
-### 代码检查
-
-```bash
-# 运行 ESLint
-npm run lint
-
-# 检查文件长度
-find . -type f -not -path "*/node_modules/*" -not -path "*/.next/*" -not -path "*/.git/*" -not -name "package-lock.json" -not -name "*.png" -not -name "*.md" | xargs wc -l | awk '$1 > 150 && $2 != "total" {print $2 " - " $1 "行"}'
-```
-
-## ❓ 常见问题
-
-### Q1: 我应该从哪里开始?
-
-**A:** 查看标记为 `good first issue` 的 Issues,这些通常比较简单,适合新手。
-
-### Q2: 如何让文件保持在 150 行以内?
-
-**A:** 参考 [文件长度限制](#1-文件长度限制-️) 部分的重构策略。关键是:
-- 提取组件
-- 提取 Hook
-- 提取工具函数
-- 模块化
-
-注:系统文件(如 README.md、CONTRIBUTING.md 等文档)不受此限制。
-
-### Q3: 我的 PR 多久会被审查?
-
-**A:** 通常在 1-3 个工作日内。如果超过一周没有回应,可以在 PR 中添加评论提醒。
-
-### Q4: 可以同时提交多个 PR 吗?
-
-**A:** 可以,但建议每个 PR 专注于一个功能或修复。避免在一个 PR 中做太多不相关的改动。
-
-### Q5: 如何解决合并冲突?
-
-```bash
-# 1. 同步上游
-git fetch upstream
-git checkout main
-git merge upstream/main
-
-# 2. 切换到功能分支并 rebase
-git checkout feat/your-feature
-git rebase main
-
-# 3. 解决冲突后
-git add .
-git rebase --continue
-
-# 4. 强制推送(因为 rebase 改变了历史)
-git push origin feat/your-feature --force
-```
-
-### Q6: 我的提交信息写错了怎么办?
-
-```bash
-# 修改最后一次提交
-git commit --amend -m "新的提交信息"
-
-# 如果已经推送了
-git push origin feat/your-feature --force
-```
-
-### Q7: 如何测试我的改动?
-
-1. 启动开发服务器:`npm run dev`
-2. 在浏览器中手动测试功能
-3. 测试不同的设备尺寸
-4. 运行 `npm run build` 确保生产构建成功
-
-### Q8: Liquid Glass 设计系统在哪里定义?
-
-在 `app/styles/glass.css` 文件中。所有组件都应该基于这个设计系统。
-
-### Q9: 我需要更新文档吗?
-
-如果你的 PR 包含以下内容,请更新相应文档:
-
-- 新功能:更新 README.md
-- API 变化:更新相关注释和文档
-- 配置变化:更新配置说明
-
-### Q10: 如何报告安全漏洞?
-
-请查看 [SECURITY.md](SECURITY.md) 了解安全漏洞报告流程。不要在公开 Issue 中讨论安全问题。
-
-## 📞 需要帮助?
-
-如果你有任何问题:
-
-1. **查看文档**:README.md 和本指南
-2. **搜索 Issues**:可能已经有人问过相同的问题
-3. **提出问题**:在 Discussions 或 Issues 中提问
-4. **联系维护者**:[@KuekHaoYang](https://github.com/KuekHaoYang)
-
-## 🎉 感谢你的贡献!
-
-感谢你花时间阅读本指南,并为 KVideo 做出贡献。每一个贡献,无论大小,都让这个项目变得更好。
-
-我们期待看到你的 Pull Request!
-
----
-
-
- 让我们一起打造更好的 KVideo!
-
+If a change affects relay, auth, sync, IPTV, PWA behavior, Workers deployment, or Android TV, say so explicitly in the PR body.
diff --git a/README.md b/README.md
index c3d6479..4faeeb2 100644
--- a/README.md
+++ b/README.md
@@ -1,1086 +1,194 @@
-[](https://github.com/sky06walker/KVideo/actions/workflows/Github_Upstream_Sync.yml)
+# KVideo
-[](https://deepwiki.com/KuekHaoYang/KVideo)
+KVideo is a Next.js 16 + React 19 video aggregation app focused on self-hosted deployments, multi-source search, player ergonomics, IPTV playback, and account-aware local or Redis-backed persistence.
-# Buy Me A Coffee
-[](https://www.buymeacoffee.com/kuekhaoyang)
+This branch aligns the project with the 2026-04-16 audit:
-# 视频聚合平台 (KVideo)
+- outbound requests now go through a shared server-side policy
+- relay routes are private by default
+- auth throttling is enforced
+- Cloudflare support is Workers/OpenNext, not `next-on-pages`
+- Apple TV is no longer a supported product target
+- the Android wrapper is TV-only
+- offline support is limited to same-origin shell/static assets
-
+## Support Matrix
-> 一个基于 Next.js 16 构建的现代化视频聚合播放平台。采用独特的 "Liquid Glass" 设计语言,提供流畅的视觉体验和强大的视频搜索功能。
+Supported:
-**在线体验:[https://kvideo.pages.dev/](https://kvideo.pages.dev/)**
+- Desktop browsers
+- Mobile browsers / PWA install flow
+- Android TV wrapper in [`android-tv`](/Users/haoyangkuek/development/KVideo/android-tv)
+- Self-hosted Node.js
+- Docker
+- Cloudflare Workers via OpenNext
-[](https://nextjs.org/)
-[](https://react.dev/)
-[](https://tailwindcss.com/)
-[](https://www.typescriptlang.org/)
-[](LICENSE)
+Not supported:
-## 项目简介
+- Apple TV / tvOS app packaging
-**KVideo** 是一个高性能、现代化的视频聚合与播放应用,专注于提供极致的用户体验和视觉设计。本项目利用 Next.js 16 的最新特性,结合 React 19 和 Tailwind CSS v4,打造了一个既美观又强大的视频浏览平台。
+Apple TV users should use the web app in a browser or an AirPlay-style fallback instead of a native tvOS client.
-> **说明**:仓库默认不内置任何视频源、高级源或 IPTV 源。部署者必须自行配置已获授权、可合法使用且允许当前部署方式访问的内容来源。
+## Deployment Modes
-### 核心设计理念:Liquid Glass(液态玻璃)
+### Self-hosted Node.js / Docker
-项目的视觉设计基于 **"Liquid Glass"** 设计系统,这是一套融合了以下特性的现代化 UI 设计语言:
+Recommended when you need the full product surface:
-- **玻璃拟态效果**:通过 `backdrop-filter` 实现的磨砂半透明效果,让 UI 元素如同真实的玻璃材质
-- **通用柔和度**:统一使用 `rounded-2xl` 和 `rounded-full` 两种圆角半径,创造和谐的视觉体验
-- **光影交互**:悬停和聚焦状态下的内发光效果,模拟光线被"捕获"的物理现象
-- **流畅动画**:基于物理的 `cubic-bezier` 曲线,实现自然的加速和减速过渡
-- **深度层级**:清晰的 z-axis 层次结构,增强空间感和交互反馈
+- external media proxy
+- IPTV relay
+- account management with Redis-backed managed auth
+- cross-device config sync
-## 核心功能
-
-### 智能视频播放
-
-- **HLS 流媒体支持**:基于 [hls.js](https://github.com/video-dev/hls.js/) 原生支持 HLS (.m3u8) 格式,提供流畅的视频播放体验
-- **播放控制**:完整的播放控制功能,包括进度条、音量控制、播放速度调节、全屏模式等
-- **自动跳过片头/片尾**:可设置自动跳过片头和片尾的秒数
-- **自动连播**:支持自动播放下一集
-- **移动端优化**:专门为移动设备优化的播放器界面和双击手势控制
-- **Chromecast 投屏**:支持通过 Google Cast 投屏到电视等设备
-- **画中画 (PiP)**:支持浮动窗口模式播放
-- **全屏模式选择**:支持系统全屏和网页全屏两种模式
-- **代理播放**:三种代理模式(智能重试、仅直连、总是代理),灵活应对不同网络环境
-- **卡顿检测**:自动检测播放卡顿并提示
-- **一起看 (VideoTogether)**:播放器页面内置官方网页集成脚本,可直接创建或加入房间与朋友同步观看
-- **键盘快捷键**:空格/K 播放暂停、F 全屏、M 静音、P 画中画、J/L/方向键 快进快退、上下键 音量调节
-
-### 多源并行搜索
-
-- **聚合搜索引擎**:同时在多个视频源中并行搜索,通过 Server-Sent Events (SSE) 实时返回结果
-- **自定义视频源**:支持添加、编辑和管理自定义视频源
-- **个人视频源**:非管理员用户可添加个人视频源,不影响其他用户(数据按用户隔离)
-- **订阅源管理**:支持通过 JSON 链接批量导入和自动更新视频源
-- **JSON 批量导入**:在导入设置中直接粘贴 JSON 数组格式的视频源进行批量添加
-- **智能解析**:统一的解析器系统,自动处理不同源的数据格式
-- **搜索历史**:自动保存搜索历史,支持快速重新搜索
-- **搜索结果显示**:支持默认显示和合并同名源两种模式
-- **实时延迟监测**:可选实时显示各源的网络延迟
-- **清晰度标签**:自动解析并显示视频清晰度(4K/蓝光/1080P/720P/HD 等),方便快速分辨源质量
-- **实际分辨率检测**:播放视频时自动检测并显示实际视频分辨率(如 1920x1080),不依赖源标签,显示真实清晰度。分辨率标签 5 秒后自动隐藏,鼠标移动时重新显示
-- **全源分辨率探测**:播放器源列表中所有源均自动通过 m3u8 清单探测实际分辨率,显示精确的分辨率标签(1080P/720P/4K 等),不仅限于当前播放的源
-- **繁体中文搜索**:自动将繁体中文转换为简体中文进行搜索,确保繁体输入也能搜到结果
-- **源过滤**:支持按源和类型筛选搜索结果,源标签支持按类型分组显示,智能合并同名分类标签,展开/折叠状态持久化记忆
-- **多级标签**:搜索结果和播放器中显示源名称和内容类型双重标签
-- **搜索取消**:搜索进行中可随时点击"取消"按钮终止搜索,释放资源
-- **内容类目过滤**:在设置中添加屏蔽关键词(如"伦理"),匹配类目的视频将自动从搜索结果中过滤
-- **搜索性能优化**:服务端支持客户端断开检测(AbortSignal),每源超时保护,总结果数上限,防止内存溢出
-
-### 多线路折叠
-
-- **智能折叠**:播放页面的线路列表默认只显示前 5 个源,避免长列表影响体验
-- **展开/收起**:超过 5 个源时显示"展开更多 (N)"按钮,可随时展开查看全部线路
-- **按类型分组**:当线路包含类型信息时,自动按内容类型分组显示(如"电影"、"电视剧"等)
-- **延迟排序**:线路按网络延迟自动排序,最快的源排在前面
-- **源切换**:在线路列表中快速切换到其他源,支持断点续播
-- **自动切源**:当当前源不可用时,自动切换到延迟最低的可用源
-- **短链接优化**:使用 sessionStorage 缓存源数据,避免 URL 过长导致 CDN 414 错误
-
-### IPTV 直播
-
-- **M3U 播放列表**:支持导入和管理 M3U/M3U8 格式的 IPTV 源
-- **JSON 频道列表**:支持导入 JSON 格式的频道列表(数组或对象格式,自动识别)
-- **HEVC 智能兼容**:自动检测 HEVC/H.265 编码流,优先选择 H.264 级别以避免音画不同步或仅有声音问题
-- **频道网格**:按分组展示频道,支持分页浏览,大列表搜索优化
-- **多级频道列表**:播放器内按源分组 → 按分类分组 → 频道的三级列表导航
-- **多线路折叠**:频道多线路默认显示前 3 条,可点击展开查看全部
-- **自动切源**:当视频源不可用时,自动选择延迟最低的可用源
-- **自定义请求头**:自动解析 M3U 中的 `http-user-agent` 和 `http-referrer` 属性,通过代理传递
-- **User-Agent 智能代理**:当频道指定自定义 User-Agent 时,自动走代理路径避免浏览器限制,解决 CCTV 等频道仅有声音无画面的问题
-- **流媒体代理**:内置 HLS 流代理,自动处理 CORS 问题和 M3U8 URL 重写
-- **智能内容检测**:当 content-type 不明确时,检查响应体内容自动识别 M3U8 格式,同时保持二进制流数据完整性
-- **重定向跟随**:自动跟随 HTTP 3xx 重定向,提升兼容性
-- **超时保护**:15 秒请求超时、30 秒加载超时、20 秒分片加载超时、3 次清单重试
-- **逐源频道缓存**:每个 IPTV 源的频道独立缓存,避免重复加载
-- **并发控制**:最多同时拉取 3 个源,防止网络拥堵
-- **权限控制**:通过 `iptv_access` 权限控制谁可以访问 IPTV 功能
-- **键盘快捷键**:播放器内支持空格暂停/继续、F 全屏、M 静音、方向键调节音量等
-- **搜索优化**:播放器内搜索使用 `useTransition` 非阻塞渲染,避免大列表卡顿
-
-### 豆瓣集成
-
-- **电影 & 电视剧分类**:支持在电影和电视剧之间无缝切换,方便查找不同类型的影视资源
-- **详细影视信息**:自动获取豆瓣评分、演员阵容、剧情简介等详细信息
-- **推荐系统**:基于豆瓣数据的相关推荐
-- **自定义标签管理**:支持拖拽排序的标签管理器,自定义首页推荐分类
-- **可点击演员/导演**:播放页面的演员、导演名字可直接点击搜索其他作品
-
-### 个性化推荐
-
-- **基于观看历史**:根据观看历史自动分析偏好,推荐相关影视内容
-- **智能分析**:分析最常观看的类型、演员和地区,生成精准推荐
-- **标签集成**:推荐作为首个标签「为你推荐」出现在标签栏中(需观看 2 部以上作品)
-- **自动加载**:无限滚动自动加载更多推荐内容
-- **独立模式**:普通模式和高级模式的推荐互相独立
-- **缓存优化**:推荐结果缓存 30 分钟,避免重复请求
-
-### 收藏管理
-
-- **一键收藏**:在搜索结果和播放页面快速收藏视频
-- **收藏列表**:独立的收藏侧边栏,快速访问已收藏的视频
-- **容量限制**:每个模式最多 100 条收藏
-- **普通/高级隔离**:普通模式和高级模式的收藏互相独立
-- **数据隔离**:多账户场景下,每个用户拥有独立的收藏数据
-
-### 观看历史管理
-
-- **自动记录**:自动记录观看进度和历史
-- **断点续播**:从上次观看位置继续播放
-- **智能去重**:按标题去重(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 优先于系统默认配置
-- **环境变量预设**:可通过 `DANMAKU_API_URL` 或 `NEXT_PUBLIC_DANMAKU_API_URL` 为所有用户预设弹幕 API 地址
-
-### 广告过滤
-
-- **多模式选择**:支持关闭、关键词过滤、智能启发式过滤(Beta)和激进模式
-- **UI 集成**:在播放器设置菜单中直接切换模式,实时生效
-- **自定义关键词**:支持通过环境变量或文件扩展过滤关键词
-- **高性能**:基于流式处理,对播放加载速度几乎无影响
-
-### 响应式设计
-
-- **全端适配**:完美支持桌面、平板、移动设备和 TV/机顶盒
-- **移动优先**:专门的移动端组件和交互设计
-- **触摸优化**:针对触摸屏优化的手势和交互(双击快进/快退、滑动音量控制)
-- **TV 适配**:遥控器方向键导航和大屏 UI 优化
-
-### 主题系统
-
-- **深色/浅色模式**:支持系统级主题切换
-- **动态主题**:基于 CSS Variables 的动态主题系统
-- **无缝过渡**:主题切换时的平滑过渡动画
-
-### PWA 支持
-
-- **可安装应用**:支持将 KVideo 安装为独立应用
-- **Service Worker**:离线缓存和资源预加载
-- **全屏体验**:独立应用模式下的沉浸式体验
-- **配置同步**:iOS Safari 添加到主屏幕后,视频源和设置自动从服务端同步,无需重新配置
-
-### 跨设备配置同步
-
-解决 iOS Safari「添加到主屏幕」后 PWA 与浏览器之间 localStorage 不共享的问题([#119](https://github.com/KuekHaoYang/KVideo/issues/119)),同时实现多设备配置共享([#115](https://github.com/KuekHaoYang/KVideo/issues/115))。
-
-**工作原理:**
-
-1. **服务端存储(Upstash Redis)**:用户配置通过 `/api/user/config` API 存储在 Upstash Redis 中,使用 `user:config:{profileId}` 作为 key。Edge Runtime 兼容,Cloudflare Pages / Vercel 均可部署。
-2. **自动拉取(Pull)**:应用加载时,`useConfigSync` hook 从服务端拉取配置,与本地 `updatedAt` 时间戳比较——服务端更新时自动合并到本地。
-3. **自动推送(Push)**:本地设置变更后,自动延迟 3 秒推送到服务端(防抖),避免频繁写入。
-4. **同步范围**:视频源 (`sources`)、高级源 (`premiumSources`)、订阅列表 (`subscriptions`)、屏蔽分类 (`blockedCategories`)、排序偏好 (`sortBy`)、语言 (`locale`)。
-5. **数据隔离**:按 `profileId`(SHA-256 哈希)隔离,不同账户互不影响。
-
-**使用前提:**
-
-- 需配置 Upstash Redis 环境变量(`UPSTASH_REDIS_REST_URL` 和 `UPSTASH_REDIS_REST_TOKEN`),与观看历史/收藏同步共用同一 Redis 实例。
-- 未配置 Redis 时,配置同步功能静默降级——应用正常运行,仅本地存储生效。
-
-**典型场景:**
-
-- 电脑浏览器配置了视频源 → 手机打开同一实例 → 自动拉取到相同配置
-- iOS Safari「添加到主屏幕」后打开 PWA → 自动从 Redis 同步配置,无需重新设置
-
-### 无障碍设计
-
-- **键盘导航**:完整的键盘快捷键支持(播放控制、音量、进度等)
-- **ARIA 标签**:符合 WCAG 标准的无障碍实现
-- **语义化 HTML**:使用语义化标签提升可访问性
-
-### 高级模式
-
-- **独立入口**:在浏览器地址栏直接输入 `/premium` 即可进入独立的高级视频专区
-- **内容隔离**:高级内容与普通内容完全物理隔离,互不干扰
-- **专属设置**:拥有独立的内容源管理和功能设置(播放器、显示、弹幕等设置完全独立)
-- **独立推荐**:基于高级模式观看历史的个性化推荐,与普通模式互不影响
-- **分类浏览**:支持模糊匹配合并多源的相同分类标签,提供统一浏览体验
-- **交错排列**:多源结果智能交错排列,平衡各源展示
-
-### TV/大屏适配
-
-- **自动检测**:自动检测 TV 浏览器(Smart TV、Tizen、WebOS、Fire TV 等)
-- **空间导航**:支持遥控器/方向键在页面元素间导航
-- **10 英尺 UI**:TV 模式下自动放大字体、交互元素和间距
-- **焦点高亮**:TV 模式下聚焦元素显示醒目的高亮边框和缩放效果
-- **播放器兼容**:播放器区域不受空间导航干扰,方向键正常控制播放
-
-### Android TV 应用
-
-- **WebView 封装**:基于 Android WebView 的轻量 APK,直接加载 KVideo 网页
-- **遥控器支持**:D-pad 中心键映射为 Enter,Back 键映射为网页后退
-- **全屏沉浸**:自动横屏、全屏、硬件加速
-- **Leanback 启动器**:支持从 Android TV 主屏直接启动
-- **可配置 URL**:在 `MainActivity.kt` 中修改 `KVIDEO_URL` 常量指向你的部署实例
-
-### Apple TV 应用
-
-- **WKWebView 封装**:基于 tvOS WKWebView 的轻量 SwiftUI 应用,直接加载 KVideo 网页
-- **遥控器支持**:滑动手势映射为滚动,点击映射为聚焦/选择,Menu 按钮支持网页后退
-- **TV 模式注入**:页面加载后自动注入 `tv-mode` CSS 类,激活大屏优化样式
-- **可配置 URL**:在 `ContentView.swift` 中修改 `kvideoURL` 常量指向你的部署实例
-
-### 数据管理
-
-- **设置导出/导入**:支持将所有设置导出为 JSON 文件,方便备份和迁移
-- **JSON 批量导入**:支持粘贴 JSON 数组格式的视频源进行批量导入
-- **滚动位置记忆**:退出或刷新页面后,自动恢复到之前的滚动位置
-- **返回顶部**:一键返回页面顶部
-- **数据隔离**:多账户场景下,所有用户数据(历史、收藏、设置、个人源)按 profileId 完全隔离
-
-## 隐私保护
-
-本应用注重用户隐私:
-
-- **本地存储**:所有数据存储在本地浏览器中
-- **无服务器数据**:不收集或上传任何用户数据
-- **自定义源**:用户可自行配置视频源
-- **数据隔离**:多账户场景下,每个用户的数据(历史、收藏、设置、个人源)完全隔离
-- **Profile ID**:基于密码的 SHA-256 哈希生成唯一用户标识,密码不可逆推
-
-## 账户与访问控制
-
-KVideo 现在支持两套认证模式:
-
-- **托管账户模式(推荐)**:配置 `AUTH_SECRET` + Upstash Redis 后,登录改为 **用户名 + 密码**,超级管理员可直接在设置页创建、修改、重置和删除账户。
-- **环境变量模式(兼容旧部署)**:未启用托管账户时,继续使用 `ADMIN_PASSWORD` / `ACCESS_PASSWORD` / `ACCOUNTS` 进行密码登录。
-
-### 方式一:托管账户模式(推荐)
-
-启用条件:
-
-- 配置 `AUTH_SECRET`
-- 配置 `UPSTASH_REDIS_REST_URL`
-- 配置 `UPSTASH_REDIS_REST_TOKEN`
-
-启用后:
-
-- 主登录页使用 **用户名 + 密码**
-- 服务端使用 HTTP-only 签名会话 Cookie 作为认证真源
-- 超级管理员可在设置页直接管理账户和权限
-- 配置同步、历史、收藏等跨设备数据会按登录账户自动隔离
-
-首次启用时,如果 Redis 里还没有账户,会自动使用 `ADMIN_PASSWORD` 和 `ACCOUNTS` 作为引导种子创建首批托管账户。
-
-### 方式二:单管理员密码(环境变量模式)
-
-通过 `ADMIN_PASSWORD` 环境变量设置管理员密码:
+Commands:
```bash
-# Docker
-docker run -d -p 3000:3000 -e ADMIN_PASSWORD=your_password --name kvideo kuekhaoyang/kvideo:latest
+npm install
+npm run build
+npm start
```
-登录后自动获得超级管理员权限,可管理所有设置。
-
-> **向后兼容**:`ACCESS_PASSWORD` 环境变量仍然有效,当 `ADMIN_PASSWORD` 未设置时,`ACCESS_PASSWORD` 将作为管理员密码使用。
-
-### 方式三:多账户系统(环境变量模式)
-
-通过 `ACCOUNTS` 环境变量配置多个账户,每个账户拥有独立的数据空间(收藏、历史、设置、个人源等)。
-
-**兼容格式:**
-
-- 旧格式:`密码:名称[:角色[:权限1|权限2|...]]`
-- 新格式:`用户名:密码:名称[:角色[:权限1|权限2|...]]`
-
-多个账户之间用逗号分隔。
-
-- **角色**:`super_admin`(超级管理员)、`admin`(管理员)或 `viewer`(观众,默认)
-- **权限**(可选):使用 `|` 分隔,为该账户添加其角色之外的额外权限
+Docker:
```bash
-# 基本用法
-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 隔离存储,切换账户后自动加载对应的个人配置。
-
-> 说明:旧环境变量模式下仍然支持“仅输入密码”登录;托管账户模式下则统一改为“用户名 + 密码”登录。
-
-### 方式四:高级内容独立密码
-
-通过 `PREMIUM_PASSWORD` 环境变量为高级内容(`/premium`)设置独立的访问密码,实现与主密码的分离控制。
-
-适合场景:给家人分享普通密码,但高级内容需要额外密码才能访问。
-
-```bash
-# Docker
-docker run -d -p 3000:3000 \
- -e ADMIN_PASSWORD="admin123" \
- -e PREMIUM_PASSWORD="premium456" \
- --name kvideo kuekhaoyang/kvideo:latest
-```
-
-**特点:**
-- 访问 `/premium` 页面时需输入此专用密码
-- 管理员密码和 admin/super_admin 账号也可以解锁高级内容
-- 密码仅在当前浏览器会话有效,关闭浏览器后需重新输入
-- 不设置此变量时,高级内容无额外密码保护
-
-### 方式五:会话持久化设置
-
-通过 `PERSIST_SESSION` 环境变量控制用户登录后是否在设备上记住会话:
-
-| 变量名 | 选项 | 说明 | 默认值 |
-|--------|------|------|--------|
-| `PERSIST_SESSION` | `true` / `false` | 是否在本地浏览器持久化保存登录状态。设置为 `true` 时,用户只需登录一次,后续访问无需再次登录。 | `true` |
-
-> [!NOTE]
-> 此功能仅在设置了 `ADMIN_PASSWORD`、`ACCESS_PASSWORD` 或 `ACCOUNTS` 时才会生效。
-
-## 站点名称自定义配置
-
-通过环境变量可以自定义站点名称、标题和描述,无需修改源代码。
-
-### 可用环境变量:
-
-| 变量名 | 说明 | 默认值 |
-|--------|------|--------|
-| `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `KVideo - 视频聚合平台` |
-| `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `视频聚合平台` |
-| `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `KVideo` |
-
-### 配置示例:
-
-**Vercel 部署:**
-在 Vercel 项目设置中添加环境变量:
-
-- 变量名:`NEXT_PUBLIC_SITE_NAME`
-- 变量值:`我的视频平台`
-
-**Cloudflare Pages 部署:**
-在 Cloudflare Pages 项目设置中添加环境变量:
-- 变量名:`NEXT_PUBLIC_SITE_NAME`
-- 变量值:`我的视频平台`
-
-**本地开发:**
-在项目根目录创建 `.env.local` 文件:
-```env
-NEXT_PUBLIC_SITE_NAME=我的视频平台
-NEXT_PUBLIC_SITE_TITLE=我的视频 - 聚合播放平台
-NEXT_PUBLIC_SITE_DESCRIPTION=专属视频聚合播放平台
-```
-
-> [!NOTE]
-> `NEXT_PUBLIC_SITE_*` 属于构建时变量。直接运行 Docker Hub 的预构建镜像时,`docker run -e NEXT_PUBLIC_SITE_* ...` 不会覆盖已经打包进前端的文案。
-
-## Docker 图标自定义
-
-Docker 预构建镜像支持在运行时替换图标,无需重新构建镜像。该配置会作用于顶部 Logo 和浏览器 favicon;如果你还要同步替换安装后的 PWA 图标,请直接覆盖仓库中的 `public/icon.png` 后重新构建镜像。
-
-### 可用环境变量:
-
-| 变量名 | 说明 |
-|--------|------|
-| `SITE_ICON_FILE` | 从容器内文件路径读取图标,适合 Docker 挂载,优先级高于 `SITE_ICON_URL` |
-| `SITE_ICON_URL` | 直接使用外部 URL 或站内路径作为图标 |
-
-### 配置示例:
-
-**Docker 挂载文件(推荐):**
-```bash
-docker run -d -p 3000:3000 \
- -v /path/to/icon.png:/app/custom/icon.png:ro \
- -e SITE_ICON_FILE=/app/custom/icon.png \
- --name kvideo kuekhaoyang/kvideo:latest
-```
-
-**Docker 使用 URL:**
-```bash
-docker run -d -p 3000:3000 \
- -e SITE_ICON_URL="https://example.com/icon.png" \
- --name kvideo kuekhaoyang/kvideo:latest
-```
-
-**Docker 使用站内路径:**
-```bash
-docker run -d -p 3000:3000 \
- -e SITE_ICON_URL="/placeholder-poster.svg" \
- --name kvideo kuekhaoyang/kvideo:latest
-```
-
-## 自动订阅源配置
-
-可以通过环境变量自动配置订阅源,应用启动时会自动加载并设置为自动更新。
-
-支持两种环境变量名:`SUBSCRIPTION_SOURCES`(服务端) 和 `NEXT_PUBLIC_SUBSCRIPTION_SOURCES`(客户端构建时嵌入)。
-
-**格式:** JSON 数组字符串,包含 `name` 和 `url` 字段;或直接提供订阅 URL(逗号分隔多个)。
-
-**示例:**
-
-```bash
-# JSON 格式
-SUBSCRIPTION_SOURCES='[{"name":"每日更新源","url":"https://example.com/api.json"},{"name":"备用源","url":"https://backup.com/api.json"}]'
-
-# 简单 URL 格式
-SUBSCRIPTION_SOURCES='https://example.com/api.json,https://backup.com/api.json'
-```
-
-**Docker 部署:**
-
-```bash
-docker run -d -p 3000:3000 -e SUBSCRIPTION_SOURCES='[{"name":"MySource","url":"..."}]' --name kvideo kuekhaoyang/kvideo:latest
-```
-
-**Vercel 部署:**
-
-在 Vercel 项目设置中添加环境变量:
-- 变量名:`SUBSCRIPTION_SOURCES`
-- 变量值:`[{"name":"...","url":"..."}]`
-
-**Cloudflare Pages 部署:**
-
-在 Cloudflare Pages 项目设置中添加环境变量:
-- 变量名:`NEXT_PUBLIC_SUBSCRIPTION_SOURCES`
-- 变量值:`[{"name":"...","url":"..."}]`
-
-## 广告过滤关键词配置
-
-通过环境变量自定义广告过滤关键词,用于在 HLS 播放时识别和过滤广告片段。
-
-| 变量名 | 说明 |
-|--------|------|
-| `AD_KEYWORDS` 或 `NEXT_PUBLIC_AD_KEYWORDS` | 广告关键词,逗号或换行分隔 |
-| `AD_KEYWORDS_FILE` | 广告关键词文件路径(适用于 Docker 挂载) |
-
-**示例:**
-
-```bash
-# 环境变量方式
-AD_KEYWORDS="ad,sponsor,preroll,midroll"
-
-# Docker 挂载文件方式
-docker run -d -p 3000:3000 \
- -v /path/to/keywords.txt:/app/keywords.txt \
- -e AD_KEYWORDS_FILE=/app/keywords.txt \
- --name kvideo kuekhaoyang/kvideo:latest
-```
-
-## 弹幕 API 配置
-
-通过环境变量预设弹幕聚合 API 地址,用户无需手动配置即可使用弹幕功能。
-
-| 变量名 | 说明 | 默认值 |
-|--------|------|--------|
-| `DANMAKU_API_URL` / `NEXT_PUBLIC_DANMAKU_API_URL` | 弹幕聚合 API 地址 | - |
-
-需要自建或使用兼容 [danmu_api](https://github.com/huangxd-/danmu_api) 格式的弹幕聚合服务。
-
-**示例:**
-
-```bash
-# Docker
-docker run -d -p 3000:3000 \
- -e NEXT_PUBLIC_DANMAKU_API_URL="https://your-danmu-api.example.com" \
- --name kvideo kuekhaoyang/kvideo:latest
-```
-
-设置后用户在播放器菜单中即可直接开启弹幕,也可在设置页面中覆盖此地址。
-
-> 用户还可以在设置页面的「弹幕 API」区域添加多个 API 端点并选择当前使用的,用户选择的 API 优先于系统默认配置。
-
-## 一起看 (VideoTogether) 配置
-
-播放器页面和 IPTV 页面支持集成 [VideoTogether](https://videotogether.github.io/zh-cn/guide/website.html) 官方网页脚本。应用内默认关闭,用户可在设置页手动开启;开启后仅在播放器和 IPTV 页面显示,并默认折叠为小图标。
-
-| 变量名 | 说明 | 默认值 |
-|--------|------|--------|
-| `VIDEOTOGETHER_ENABLED` | 设为 `false` 时彻底禁用一起看集成(即使用户在设置页开启也不会加载) | `true` |
-| `VIDEOTOGETHER_SCRIPT_URL` | 自定义 VideoTogether 脚本地址,适合自托管或替换 CDN | `https://fastly.jsdelivr.net/gh/VideoTogether/VideoTogether@latest/release/extension.website.user.js` |
-| `VIDEOTOGETHER_SETTING_URL` | 自定义 VideoTogether 设置页地址,对应官方 `window.videoTogetherWebsiteSettingUrl` 接口 | - |
-
-**示例:**
-
-```bash
-# Docker
-docker run -d -p 3000:3000 \
- -e VIDEOTOGETHER_SCRIPT_URL="https://your-domain.example.com/extension.website.user.js" \
- -e VIDEOTOGETHER_SETTING_URL="https://your-domain.example.com/videotogether-settings.html" \
- --name kvideo kuekhaoyang/kvideo:latest
-```
-
-> 如果部署环境无法稳定访问 jsDelivr,直接自托管 `extension.website.user.js` 并通过 `VIDEOTOGETHER_SCRIPT_URL` 指向自己的地址即可。
-
-## IPTV 直播源配置
-
-通过环境变量预设 IPTV 直播源,应用启动时会自动添加到直播源列表中。
-
-> **注意**:在 Vercel / Cloudflare 托管部署的合规模式下,IPTV 页面与流中继默认关闭,`IPTV_SOURCES` / `NEXT_PUBLIC_IPTV_SOURCES` 不会生效。
-
-| 变量名 | 说明 | 默认值 |
-|--------|------|--------|
-| `IPTV_SOURCES` | IPTV 直播源配置(服务端) | - |
-| `NEXT_PUBLIC_IPTV_SOURCES` | IPTV 直播源配置(客户端) | - |
-
-**格式:** JSON 数组字符串,包含 `name` 和 `url` 字段;或直接提供 M3U 链接(逗号分隔多个)。
-
-**示例:**
-
-```bash
-# JSON 格式
-IPTV_SOURCES='[{"name":"央视","url":"https://example.com/cctv.m3u"},{"name":"地方台","url":"https://example.com/local.m3u"}]'
-
-# 简单 URL 格式
-IPTV_SOURCES='https://example.com/cctv.m3u,https://example.com/local.m3u'
-```
-
-## 合并同名源配置
-
-通过环境变量设置默认启用搜索结果的合并同名源显示模式。
-
-| 变量名 | 说明 | 默认值 |
-|--------|------|--------|
-| `MERGE_SOURCES` | 启用合并同名源(`true` 或 `1`) | - |
-| `NEXT_PUBLIC_MERGE_SOURCES` | 启用合并同名源(客户端) | - |
-
-**示例:**
-
-```bash
-MERGE_SOURCES=true
-```
-
-设置后搜索结果会自动以合并模式显示,将来自不同源的同名视频合并为一个卡片。用户仍可在设置页面中手动切换显示模式。
-
-## 自定义端口
-
-通过 `PORT` 环境变量自定义应用端口,默认为 3000。
-
-```bash
-# 开发模式
-PORT=8080 npm run dev
-
-# 生产模式
-PORT=8080 npm run start
-
-# Docker
-docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
-```
-
-## 自定义源 JSON 格式
-
-如果你想创建自己的订阅源或批量导入源,可以使用以下 JSON 格式。
-
-**基本结构:**
-
-可以是单个对象数组,也可以是包含 `sources` 或 `list` 字段的对象。
-
-**源对象字段说明:**
-
-| 字段 | 类型 | 必填 | 说明 |
-|------|------|------|------|
-| `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:**
-
-```json
-[
- {
- "id": "my_source_1",
- "name": "我的精选源",
- "baseUrl": "https://api.example.com/vod",
- "group": "normal",
- "priority": 1
- },
- {
- "id": "premium_source_1",
- "name": "特殊资源",
- "baseUrl": "https://api.premium-source.com/vod",
- "group": "premium",
- "enabled": true
- }
-]
-```
-
-### JSON 批量导入
-
-除了通过订阅 URL 或文件导入外,还可以在导入设置中选择 **JSON** 标签页,直接粘贴上述格式的 JSON 数组进行批量导入。系统会解析、预览数量后再确认导入。
-
-### 重要的区别说明:订阅源 vs 视频源
-
-**这是一个常见的误区,请仔细阅读:**
-
-- **视频源 (Source)**:
- - 指向单个 CMS/App API 接口
- - 例如:`https://api.example.com/vod`
- - 这种链接**不能**直接作为"订阅"添加
- - 只能在"自定义源管理"中作为单个源添加
-
-- **订阅源 (Subscription)**:
- - 指向一个 **JSON 文件**(如上面的示例)的 URL
- - 这个 JSON 文件里包含了一个或多个视频源的列表
- - 例如:`https://mysite.com/kvideo-sources.json`
- - 这是一个**配置文件**的链接,不是视频 API 的链接
- - 只有这种返回 JSON 列表的链接才能在"订阅管理"中添加
-
-> **简单来说**:如果你只有一个 m3u8 或 API 接口地址,请去"自定义源"添加。如果你有一个包含多个源的 JSON 文件链接,请去"订阅管理"添加。
-
-## 全部环境变量参考
-
-| 变量名 | 说明 | 默认值 |
-|--------|------|--------|
-| `AUTH_SECRET` | 托管账户模式的会话签名密钥;启用 Redis 托管账户时必填 | - |
-| `ADMIN_PASSWORD` | 管理员密码;环境变量模式直接生效,也可作为托管模式首次引导的超级管理员种子 | - |
-| `ACCESS_PASSWORD` | 访问密码(向后兼容,等同于 `ADMIN_PASSWORD`) | - |
-| `ACCOUNTS` | 多账户配置;支持 `密码:名称[:角色[:权限1\|权限2]]` 和 `用户名:密码:名称[:角色[:权限1\|权限2]]` 两种格式 | - |
-| `PREMIUM_PASSWORD` | 高级内容独立密码,访问 `/premium` 时需输入 | - |
-| `PERSIST_SESSION` | 是否持久化登录会话 | `true` |
-| `PORT` | 自定义应用端口 | `3000` |
-| `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `KVideo - 视频聚合平台` |
-| `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `视频聚合平台` |
-| `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `KVideo` |
-| `SITE_ICON_FILE` | Docker 运行时图标文件路径(优先于 `SITE_ICON_URL`) | - |
-| `SITE_ICON_URL` | Docker 运行时图标 URL 或站内路径 | - |
-| `SUBSCRIPTION_SOURCES` | 自动订阅源配置(服务端) | - |
-| `NEXT_PUBLIC_SUBSCRIPTION_SOURCES` | 自动订阅源配置(客户端) | - |
-| `IPTV_SOURCES` / `NEXT_PUBLIC_IPTV_SOURCES` | IPTV 直播源配置 | - |
-| `MERGE_SOURCES` / `NEXT_PUBLIC_MERGE_SOURCES` | 启用合并同名源显示(`true`/`1`) | - |
-| `AD_KEYWORDS` / `NEXT_PUBLIC_AD_KEYWORDS` | 广告过滤关键词 | - |
-| `AD_KEYWORDS_FILE` | 广告关键词文件路径 | - |
-| `DANMAKU_API_URL` / `NEXT_PUBLIC_DANMAKU_API_URL` | 弹幕聚合 API 地址 | - |
-| `VIDEOTOGETHER_ENABLED` | 是否允许 VideoTogether 一起看集成(`false` 时关闭) | `true` |
-| `VIDEOTOGETHER_SCRIPT_URL` | VideoTogether 脚本地址 | `https://fastly.jsdelivr.net/gh/VideoTogether/VideoTogether@latest/release/extension.website.user.js` |
-| `VIDEOTOGETHER_SETTING_URL` | VideoTogether 设置页地址 | - |
-| `UPSTASH_REDIS_REST_URL` | Upstash Redis REST URL(跨设备同步:配置、历史、收藏) | - |
-| `UPSTASH_REDIS_REST_TOKEN` | Upstash Redis REST Token | - |
-
-## 技术栈
-
-### 前端核心
-
-| 技术 | 版本 | 用途 |
-|------|------|------|
-| **[Next.js](https://nextjs.org/)** | 16.1.7 | React 框架,使用 App Router |
-| **[React](https://react.dev/)** | 19.2.4 | UI 组件库 |
-| **[TypeScript](https://www.typescriptlang.org/)** | 5.x | 类型安全的 JavaScript |
-| **[Tailwind CSS](https://tailwindcss.com/)** | 4.x | 实用优先的 CSS 框架 |
-| **[Zustand](https://github.com/pmndrs/zustand)** | 5.x | 轻量级状态管理 |
-| **[hls.js](https://github.com/video-dev/hls.js/)** | 1.x | HLS 流媒体播放引擎 |
-| **[Lucide React](https://lucide.dev/)** | 0.x | 图标库 |
-| **[@dnd-kit](https://dndkit.com/)** | 6.x | 拖拽交互(标签排序等) |
-
-### 开发工具
-
-- **ESLint 10**:代码质量检查
-- **PostCSS 8**:CSS 处理器
-- **Vercel Analytics**:性能监控和分析
-- **Cloudflare Pages**:边缘部署支持
-
-### 架构特点
-
-- **App Router**:Next.js 的新路由系统,支持服务端组件和流式渲染
-- **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 哈希)隔离存储
-
-## 部署合规说明
-
-Issue [#127](https://github.com/KuekHaoYang/KVideo/issues/127) 之后,仓库的公开部署策略做了调整,目的只有一个:避免把这个项目继续包装成“GitHub 账号直连第三方平台的一键导入仓库”。
-
-- 不再推荐任何 `Deploy with Vercel`、`Connect GitHub`、`Fork 后直接授权第三方读取仓库` 这一类 GitHub 直连导入流程。
-- Vercel / Cloudflare 托管部署现在默认运行在**合规模式**:自动关闭外部媒体代理、热链转发和 IPTV 流中继,仅保留直连播放路径。
-- 需要 `/api/proxy`、`/api/iptv/stream`、自定义 `User-Agent` / `Referer` 转发、IPTV 中继等能力时,请使用 Docker 或传统 Node.js 自托管。
-- 无论部署到哪里,都只应接入你有权使用、且允许当前部署环境访问的内容来源。
-- 相关政策请直接阅读官方原文:
- - [GitHub Terms of Service](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service)
- - [GitHub Acceptable Use Policies](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies)
- - [Vercel: why running another CDN / proxy on top of Vercel is not recommended](https://vercel.com/guides/why-running-another-cdn-on-top-of-vercel-is-not-recommended)
- - [Cloudflare abuse approach](https://www.cloudflare.com/trust-hub/abuse-approach/)
-
-## 快速部署
-
-### 部署到自己的服务器
-
-#### 选项 1:Docker 部署(推荐,完整功能)
-
-**从 Docker Hub 拉取(最简单):**
-
-```bash
-# 拉取最新版本
-docker pull kuekhaoyang/kvideo:latest
-docker run -d -p 3000:3000 --name kvideo kuekhaoyang/kvideo:latest
-```
-
-应用将在 `http://localhost:3000` 启动。
-
-> **多架构支持**:镜像支持 2 种主流平台架构:
-> - `linux/amd64` - Intel/AMD 64位(大多数服务器、PC、Intel Mac)
-> - `linux/arm64` - ARM 64位(Apple Silicon Mac、AWS Graviton、树莓派 4/5)
-
-**自己构建镜像:**
-
-```bash
-git clone https://github.com/KuekHaoYang/KVideo.git
-cd KVideo
docker build -t kvideo .
-docker run -d -p 3000:3000 --name kvideo kvideo
+docker compose up -d
```
-**使用 Docker Compose:**
+### Cloudflare Workers
+
+Cloudflare support is provided through OpenNext.
+
+Commands:
```bash
-docker-compose up -d
+npm run cf:build
+npm run cf:preview
```
-**完整配置示例(Docker):**
+Important:
+
+- use Workers/OpenNext, not Cloudflare Pages direct upload
+- managed Cloudflare/Vercel deployments run in restricted mode in this codebase
+- restricted mode disables external media relay and IPTV relay on those managed platforms
+
+## Security Defaults
+
+This project no longer behaves like a public generic fetch service.
+
+- Only `http` and `https` outbound targets are allowed.
+- Loopback, private, link-local, metadata, and reserved ranges are blocked by default.
+- Hostnames are resolved before fetch, and redirects into blocked ranges are rejected.
+- Relay endpoints do not forward cookies or spoof client IP / origin / referer headers.
+- Public relay access is disabled unless explicitly enabled.
+- `AUTH_SECRET` is required whenever auth is enabled.
+- Login failures are throttled and can return `429` with `Retry-After`.
+
+## Environment Variables
+
+Core auth and access:
+
+| Variable | Required | Notes |
+| --- | --- | --- |
+| `AUTH_SECRET` | Required when auth is enabled | Session signing secret. Missing secret disables authenticated relay/account flows. |
+| `ADMIN_PASSWORD` | Optional | Legacy/admin bootstrap password. |
+| `ACCESS_PASSWORD` | Optional | Legacy compatibility alias for `ADMIN_PASSWORD`. |
+| `ACCOUNTS` | Optional | Bootstrap account list. Supports legacy and username-based formats. |
+| `PREMIUM_PASSWORD` | Optional | Separate password for `/premium`. |
+
+Relay and outbound policy:
+
+| Variable | Required | Notes |
+| --- | --- | --- |
+| `KVIDEO_PUBLIC_RELAY_ENABLED` | Optional | Defaults to disabled. Public unauthenticated relay stays off unless set to `true`. |
+| `KVIDEO_OUTBOUND_PRIVATE_HOST_ALLOWLIST` | Optional | Comma-separated allowlist for intentional LAN/private targets. Server-only. |
+
+Redis / managed auth / sync:
+
+| Variable | Required | Notes |
+| --- | --- | --- |
+| `UPSTASH_REDIS_REST_URL` | Optional | Enables managed accounts and sync features when paired with token. |
+| `UPSTASH_REDIS_REST_TOKEN` | Optional | Enables managed accounts and sync features when paired with URL. |
+
+Player integrations:
+
+| Variable | Required | Notes |
+| --- | --- | --- |
+| `DANMAKU_API_URL` | Optional | Server-side default Danmaku API URL. |
+| `NEXT_PUBLIC_DANMAKU_API_URL` | Optional | Client-visible default Danmaku API URL. |
+| `VIDEOTOGETHER_ENABLED` | Optional | Defaults to disabled unless explicitly set to `true`. |
+| `VIDEOTOGETHER_SCRIPT_URL` | Required when `VIDEOTOGETHER_ENABLED=true` | Must be an explicit HTTPS URL. No `@latest` default is used anymore. |
+| `VIDEOTOGETHER_SETTING_URL` | Optional | Explicit settings page URL for the VideoTogether integration. |
+
+## Runtime Behavior Changes
+
+- `/api/proxy` and related relay routes now reject unsafe targets with 4xx responses.
+- `/api/auth` may return `429` after repeated failed login attempts.
+- `/api/user/config` and `/api/user/sync` silently no-op when Redis is absent.
+- `clear all data` now also calls `DELETE /api/auth/session` and removes the httpOnly session.
+
+## Offline / PWA Scope
+
+Offline support is intentionally narrow.
+
+- cached: same-origin shell and static assets
+- fallback: navigation fallback to [`public/offline.html`](/Users/haoyangkuek/development/KVideo/public/offline.html)
+- not cached for offline playback: remote media, proxy responses, IPTV streams, API data
+
+## Android TV
+
+The Android wrapper is TV-only.
+
+- no standard phone launcher entry
+- release behavior enforces HTTPS
+- mixed content is disabled
+- WebView navigation is restricted to the configured app origin
+- the remaining JavaScript bridge is only for PiP-related behavior
+
+Build commands:
```bash
-docker run -d -p 3000:3000 \
- -e ADMIN_PASSWORD="admin123" \
- -e PREMIUM_PASSWORD="premium456" \
- -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"}]' \
- -e IPTV_SOURCES='[{"name":"央视","url":"https://example.com/cctv.m3u"}]' \
- -e MERGE_SOURCES=true \
- --name kvideo kuekhaoyang/kvideo:latest
+cd android-tv
+./gradlew --no-daemon lint test assembleDebug assembleRelease
```
-#### 选项 2:传统 Node.js 部署(完整功能)
+## Development
+
+Install dependencies:
```bash
-# 1. 克隆仓库
-git clone https://github.com/KuekHaoYang/KVideo.git
-cd KVideo
-
-# 2. 安装依赖
npm install
+```
-# 3. 构建项目
+Useful commands:
+
+```bash
+npm run lint
+npm test
+npm run test:e2e
npm run build
-
-# 4. 启动生产服务器
-npm start
+npm run cf:build
+docker compose config
+docker build -t kvideo .
+cd android-tv && ./gradlew --no-daemon lint test assembleDebug assembleRelease
```
-应用将在 `http://localhost:3000` 启动。
+## CI Gates
-#### 选项 3:Vercel / Cloudflare 托管部署(合规模式)
+This repo is expected to stay green on:
-适用于只需要搜索、账户、设置、直连播放等基础能力,不需要外部媒体代理 / IPTV 中继的场景。
+- ESLint
+- Node unit tests
+- Playwright smoke tests
+- Next.js production build
+- OpenNext / Workers build
+- `npm audit --omit=dev`
+- `docker compose config`
+- Docker image build
+- Android TV lint / test / debug / release builds
-1. **只从本地工作副本部署**:不要使用 GitHub 一键导入、Fork 后导入、Connect GitHub 读取仓库等流程。
-2. **Vercel**:使用本地 CLI 部署现有项目,参考官方文档:[Import an existing project](https://vercel.com/docs/getting-started-with-vercel/import)。
-3. **Cloudflare**:使用官方 **Direct Upload** 或 CLI 工作流,不要使用 Git integration,参考官方文档:[Getting started with Pages](https://developers.cloudflare.com/pages/get-started)。
-4. **Cloudflare Direct Upload 正确流程**:
- ```bash
- npm install
- npm run pages:build
- ```
- 构建完成后,上传 **`.vercel/output/static`** 目录,而不是仓库根目录、`.next` 目录或源码文件。上传仓库根目录只会把源码当静态文件托管,根路径没有构建产物可供 Pages 入口加载,结果就是 404。
-5. **Cloudflare CLI 发布**:如果你使用 CLI,请发布同一个构建产物目录:
- ```bash
- npx wrangler pages deploy .vercel/output/static
- ```
-6. **Cloudflare 构建链路**:`npm run pages:build` 现在固定使用仓库内安装的 `next-on-pages` 和兼容版本的 `vercel`,避免构建时临时拉取不受控版本。`wrangler.toml` 继续提供 `nodejs_compat` 兼容标志。
-7. **功能限制**:托管平台会自动禁用外部媒体代理和 IPTV 流中继;请仅使用允许浏览器直连、允许当前来源访问且具备合法授权的内容源。
-8. **如果你需要完整能力**:直接改用 Docker 或传统 Node.js 自托管,不要在托管平台上强行恢复这些能力。
+## Repository Notes
-#### 选项 4:Android TV APK 构建
-
-项目内置了一个轻量的 Android TV WebView 壳应用,可以将 KVideo 打包成 APK 安装到 Android TV 或机顶盒上。
-
-**直接下载:**
-
-- 仓库维护者可以通过 GitHub Actions 的 `Android TV APK` 工作流直接发布预构建 APK 到 **Releases**
-- 用户下载安装后,首次启动时填写自己的 KVideo 地址即可,不需要再改源码重新编译
-
-**前置要求:**
-
-- [Android Studio](https://developer.android.com/studio)(推荐)或 Android SDK Command-line Tools
-- JDK 17+
-
-**步骤:**
-
-1. **使用 Android Studio 构建(推荐)**:
- - 用 Android Studio 打开 `android-tv/` 目录
- - 等待 Gradle 同步完成
- - 点击 **Build → Build Bundle(s) / APK(s) → Build APK(s)**
- - APK 输出在 `android-tv/app/build/outputs/apk/debug/app-debug.apk`
-
-2. **使用命令行构建**:
- ```bash
- cd android-tv
- ./gradlew assembleDebug
- ```
- APK 输出在 `app/build/outputs/apk/debug/app-debug.apk`
-
- 如果你希望在构建时预置默认地址,可额外传入 `-PkvideoUrl`:
- ```bash
- cd android-tv
- ./gradlew assembleDebug -PkvideoUrl="https://your-kvideo-instance.com"
- ```
-
-3. **安装到 Android TV**:
- ```bash
- adb install app/build/outputs/apk/debug/app-debug.apk
- ```
- 或通过 U 盘、文件管理器等方式侧载安装。
-
-4. **首次启动配置**:
- - 首次打开 APK 时输入你的 KVideo 部署地址
- - 保存后应用会记住该地址,后续直接打开即可
- - 需要修改地址时,可在站点根页按返回,或使用部分遥控器的菜单键重新打开设置页
-
-> **注意**:此 APK 是一个 WebView 壳应用,需要你的 KVideo 实例已经部署并可访问。APK 本身不包含 KVideo 代码,仅作为 TV 端的浏览器入口。
->
-> **最低系统要求**:Android 8.0 (API 26) 及以上。Android 7.0 及更低版本的 WebView 不支持本项目使用的 ES2017+ JavaScript 特性和现代 CSS,可能导致白屏。如遇白屏问题,请升级系统 WebView 或使用 Android 8.0+ 设备。
->
-> **发布方式**:GitHub Actions 的 `Android TV APK` 工作流会持续验证壳应用可构建;如需对外发布预构建 APK,可手动触发该工作流并填写 `release_tag`,生成对应的 GitHub Release 资产。
-
-#### 选项 5:Apple TV 应用构建
-
-项目内置了一个轻量的 tvOS WKWebView 壳应用,可以将 KVideo 安装到 Apple TV 上。
-
-**前置要求:**
-
-- macOS + Xcode 15+
-- Apple Developer 账号(免费账号即可侧载到个人设备)
-
-**步骤:**
-
-1. **创建 Xcode 项目**:打开 Xcode → **File → New → Project** → 选择 **tvOS → App** → 设置 Product Name 为 `KVideoTV`,Interface 选 **SwiftUI**,Language 选 **Swift**
-
-2. **替换源文件**:将项目中 `apple-tv/KVideoTV/KVideoTV/` 目录下的 `KVideoTVApp.swift` 和 `ContentView.swift` 复制替换 Xcode 生成的同名文件
-
-3. **修改目标 URL**:编辑 `ContentView.swift`,将 `kvideoURL` 改为你的部署地址:
- ```swift
- let kvideoURL = "https://your-kvideo-instance.com"
- ```
-
-4. **设置部署目标**:将 Deployment Target 设置为 **tvOS 16.0** 或更高
-
-5. **构建运行**:连接 Apple TV(或使用 tvOS 模拟器),按 **Cmd+R** 构建运行
-
-**工作原理:**
-- 全屏 `WKWebView` 加载 KVideo URL
-- 页面加载后自动注入 `tv-mode` CSS 类,激活 TV 优化样式
-- Apple TV 遥控器滑动手势映射为滚动,点击映射为聚焦/选择
-- Menu 按钮支持网页后退导航
-
-> **注意**:Apple TV 应用如果仅是 Web 壳应用,不可上架 App Store。此功能仅供个人侧载使用。也可以直接从 iPhone/iPad/Mac 使用 AirPlay 投屏,无需此应用。
-
-## 如何更新
-
-### Vercel 部署
-
-不要依赖 GitHub 仓库自动导入。请在本地工作副本完成更新后,使用 Vercel CLI 重新发布当前目录。
-
-### Cloudflare 托管部署
-
-请在本地工作副本完成更新后,重新执行:
-
-```bash
-npm install
-npm run pages:build
-```
-
-然后使用 Direct Upload 或 CLI 发布 **`.vercel/output/static`**。不要上传仓库根目录,也不要上传 `.next`,否则 Pages 只会托管源码文件,访问站点时直接 404。
-
-### Docker 部署
-
-当有新版本发布时:
-
-```bash
-# 停止并删除旧容器
-docker stop kvideo
-docker rm kvideo
-
-# 拉取最新镜像
-docker pull kuekhaoyang/kvideo:latest
-
-# 运行新容器
-docker run -d -p 3000:3000 --name kvideo kuekhaoyang/kvideo:latest
-```
-
-### Node.js 部署
-
-```bash
-cd KVideo
-git pull origin main
-npm install
-npm run build
-npm start
-```
-
-> **自动化部署**:本项目使用 GitHub Actions 自动构建和发布 Docker 镜像。每次代码推送到 main 分支时,会自动构建多架构镜像并推送到 Docker Hub。
-
-## 常见问题
-
-### Cloudflare Pages 部署报 "Unknown internal error"
-
-这是 Cloudflare 的临时服务端错误,与代码无关。请在 Deployments 列表中重试部署即可。项目已内置 `wrangler.toml` 配置 `nodejs_compat` 兼容性标志。
-
-### Cloudflare Pages Direct Upload 部署后打开是 404
-
-这通常不是应用路由问题,而是上传了错误的目录。Cloudflare Pages 的 Direct Upload 需要上传已经构建完成的产物目录。
-
-正确做法:
-
-```bash
-npm install
-npm run pages:build
-```
-
-然后上传 **`.vercel/output/static`**。
-
-错误做法:
-
-- 上传仓库根目录
-- 上传 `.next`
-- 直接把源码压缩包丢给 Direct Upload
-
-这些做法都不会生成 Pages 可执行入口,部署成功后打开站点就会是 404。
-
-### IPv6 环境下 HTTPS 访问视频无法播放
-
-如果你的网络使用 IPv6 访问,且通过路由器端口映射(如 20443 → 443),请确保:
-- 反向代理(如 Caddy/Nginx)正确监听 IPv6 地址
-- 路由器的 IPv6 端口映射规则与 IPv4 一致
-- 如使用非标准端口,确保 IPv6 防火墙规则也已放行
-
-这是网络/反向代理配置问题,非 KVideo 代码问题。
-
-### Android 7.0 设备白屏
-
-Android 7.0 (API 24) 的 WebView 基于 Chrome 51,不支持本项目使用的现代 JavaScript(ES2017+)和 CSS 特性。最低要求 Android 8.0 (API 26) 及以上。
-
-### IPTV 部分直播流无法播放
-
-如果你部署在 Vercel / Cloudflare 托管平台,IPTV 在合规模式下是**默认禁用**的;这不是 Bug,而是刻意限制。下面的说明只适用于 Docker / Node.js 自托管完整模式。
-
-浏览器原生仅支持 HLS (m3u8) 和部分 MP4/WebM 格式。以下格式在浏览器中不受支持:
-- RTMP/RTSP 流(需要专用播放器如 VLC/PotPlayer)
-- 某些加密或受 DRM 保护的流
-- 需要特定客户端验证的流
-
-在自托管完整模式下,KVideo 的 IPTV 代理会处理 CORS 问题和 HLS URL 重写,大部分 HLS 直播流应能正常播放。
-
-### IPTV CCTV 等频道只有声音没有画面
-
-部分 CCTV 和卫视频道使用 HEVC (H.265) 编码,某些浏览器不支持硬件解码 HEVC。KVideo v4.5.0+ 已自动检测 HEVC 流并优先选择 H.264 级别以提高兼容性。如果问题仍存在,建议使用 Chrome 或 Edge 浏览器。
-
-### 部分浏览器无法播放视频
-
-一些内置浏览器(如 vivo 浏览器、QQ 浏览器等)的 WebView 可能不完整支持 MSE (Media Source Extensions) 和 HLS.js。建议使用以下浏览器:
-- Chrome(推荐)
-- Edge
-- Safari(iOS/macOS)
-- Firefox
-
-KVideo v4.5.0+ 已增加多级回退机制,会依次尝试 HLS.js、原生 HLS、代理播放等方式。
-
-## 贡献代码
-
-我们非常欢迎各种形式的贡献!无论是报告 Bug、提出新功能建议、改进文档,还是提交代码,你的每一份贡献都让这个项目变得更好。
-
-**想要参与开发?请查看 [贡献指南](CONTRIBUTING.md) 了解详细的开发规范和流程。**
-
-快速开始:
-1. **报告 Bug**:[提交 Issue](https://github.com/KuekHaoYang/KVideo/issues)
-2. **功能建议**:在 Issues 中提出你的想法
-3. **代码贡献**:Fork → Branch → PR
-4. **文档改进**:直接提交 PR
-
-## 许可证
-
-本项目基于 [MIT 许可证](LICENSE) 开源。
-
-## 致谢
-
-感谢以下开源项目:
-
-- [Next.js](https://nextjs.org/) - React 框架
-- [Tailwind CSS](https://tailwindcss.com/) - CSS 框架
-- [Zustand](https://github.com/pmndrs/zustand) - 状态管理
-- [React](https://react.dev/) - UI 库
-- [hls.js](https://github.com/video-dev/hls.js/) - HLS 播放引擎
-- [Lucide](https://lucide.dev/) - 图标库
-- [dnd-kit](https://dndkit.com/) - 拖拽交互
-
-## 联系方式
-
-- **作者**:[KuekHaoYang](https://github.com/KuekHaoYang)
-- **项目主页**:[https://github.com/KuekHaoYang/KVideo](https://github.com/KuekHaoYang/KVideo)
-- **问题反馈**:[GitHub Issues](https://github.com/KuekHaoYang/KVideo/issues)
-
----
-
-
- Made with ❤️ by
KuekHaoYang
-
- 如果这个项目对你有帮助,请考虑给一个 ⭐️
-
-
-# Buy Me A Coffee
-# Buy Me A Coffee
-[](https://www.buymeacoffee.com/kuekhaoyang)
-
-## Star History
-
-[](https://www.star-history.com/#KuekHaoYang/KVideo&type=date&legend=top-left)
+- `npm start` runs the standalone Next.js server output.
+- `pages:build` is kept only as a temporary compatibility alias to the Workers/OpenNext build path.
+- The old Apple TV sample app has been removed from the supported product path on purpose.
diff --git a/SECURITY.md b/SECURITY.md
index a231434..c75b4d0 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,10 +1,40 @@
-# 安全策略 (Security Policy)
+# Security Policy
-## 支持的版本
+## Supported Versions
-目前我们仅对项目的最新主分支 (`main`) 提供安全更新支持。
+Security fixes are applied to the current `main` branch only.
-| 版本 | 支持状态 |
-| :--- | :--- |
-| `main` (最新版) | ✅ 支持 |
-| 历史版本 | ❌ 不支持 |
+| Version | Supported |
+| --- | --- |
+| `main` | Yes |
+| Historical releases / stale forks | No |
+
+## Reporting
+
+For sensitive vulnerabilities, use GitHub Security Advisories / private reporting on the repository instead of opening a public issue first.
+
+For non-sensitive hardening bugs or follow-up cleanup, open a normal issue with:
+
+- affected route or component
+- exact deployment mode
+- reproduction steps
+- expected behavior
+- actual behavior
+
+## Current Security Posture
+
+This repository intentionally hardens several surfaces that were previously too permissive:
+
+- outbound requests are restricted to `http` / `https`
+- loopback, private, link-local, metadata, and reserved targets are blocked by default
+- redirects are revalidated before follow
+- raw relay routes are not public by default
+- relay forwarding excludes cookies and spoofed forwarding headers
+- auth-enabled deployments require `AUTH_SECRET`
+- login attempts are throttled and may return `429`
+
+## Deployment Guidance
+
+Use self-hosted Node.js or Docker when you need the full relay / IPTV surface.
+
+Cloudflare Workers via OpenNext is supported, but this codebase intentionally applies managed-platform restrictions there. Do not assume parity with unrestricted self-hosted Node deployments.
diff --git a/android-tv/app/build.gradle.kts b/android-tv/app/build.gradle.kts
index 374c98e..a19441b 100644
--- a/android-tv/app/build.gradle.kts
+++ b/android-tv/app/build.gradle.kts
@@ -22,11 +22,19 @@ android {
versionName = "1.0.0"
buildConfigField("String", "DEFAULT_KVIDEO_URL", "\"$defaultKVideoUrl\"")
+ buildConfigField("boolean", "ALLOW_CLEARTEXT", "false")
+ manifestPlaceholders["usesCleartextTraffic"] = "false"
}
buildTypes {
+ debug {
+ buildConfigField("boolean", "ALLOW_CLEARTEXT", "true")
+ manifestPlaceholders["usesCleartextTraffic"] = "true"
+ }
release {
isMinifyEnabled = true
+ buildConfigField("boolean", "ALLOW_CLEARTEXT", "false")
+ manifestPlaceholders["usesCleartextTraffic"] = "false"
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt")
)
@@ -48,7 +56,7 @@ android {
}
dependencies {
- implementation("androidx.core:core-ktx:1.12.0")
- implementation("androidx.activity:activity-ktx:1.8.2")
- implementation("androidx.webkit:webkit:1.9.0")
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.activity:activity-ktx:1.9.3")
+ implementation("androidx.webkit:webkit:1.14.0")
}
diff --git a/android-tv/app/src/main/AndroidManifest.xml b/android-tv/app/src/main/AndroidManifest.xml
index ac4a130..683283e 100644
--- a/android-tv/app/src/main/AndroidManifest.xml
+++ b/android-tv/app/src/main/AndroidManifest.xml
@@ -6,7 +6,7 @@
+ android:required="true" />
-
-
-
-
-
-
diff --git a/android-tv/app/src/main/java/com/kvideo/tv/MainActivity.kt b/android-tv/app/src/main/java/com/kvideo/tv/MainActivity.kt
index d7cbd23..a029681 100644
--- a/android-tv/app/src/main/java/com/kvideo/tv/MainActivity.kt
+++ b/android-tv/app/src/main/java/com/kvideo/tv/MainActivity.kt
@@ -6,6 +6,7 @@ import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Rect
import android.net.Uri
+import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.util.Log
@@ -16,8 +17,11 @@ import android.view.ViewGroup
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.webkit.JavascriptInterface
+import android.webkit.SslErrorHandler
import android.webkit.WebChromeClient
import android.webkit.WebChromeClient.CustomViewCallback
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
@@ -100,11 +104,55 @@ class MainActivity : ComponentActivity() {
loadWithOverviewMode = true
useWideViewPort = true
cacheMode = WebSettings.LOAD_DEFAULT
- mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
+ mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
databaseEnabled = true
+ allowFileAccess = false
+ allowContentAccess = false
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ safeBrowsingEnabled = true
+ }
}
- webViewClient = WebViewClient()
+ webViewClient = object : WebViewClient() {
+ override fun shouldOverrideUrlLoading(
+ view: WebView?,
+ request: WebResourceRequest?
+ ): Boolean {
+ val targetUrl = request?.url?.toString() ?: return true
+ if (!request.isForMainFrame) {
+ return false
+ }
+
+ if (targetUrl == "about:blank" || isAllowedNavigationTarget(targetUrl)) {
+ return false
+ }
+
+ showStatus(getString(R.string.status_blocked_navigation))
+ Log.w(TAG, "Blocked navigation to $targetUrl")
+ return true
+ }
+
+ override fun onReceivedSslError(
+ view: WebView?,
+ handler: SslErrorHandler?,
+ error: SslError?
+ ) {
+ handler?.cancel()
+ showStatus(getString(R.string.status_ssl_error))
+ Log.w(TAG, "Blocked page due to SSL error: ${error?.primaryError}")
+ }
+
+ override fun onReceivedError(
+ view: WebView?,
+ request: WebResourceRequest?,
+ error: WebResourceError?
+ ) {
+ super.onReceivedError(view, request, error)
+ if (request?.isForMainFrame == true) {
+ showSetup(getString(R.string.status_load_failed))
+ }
+ }
+ }
webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
if (view == null || callback == null) {
@@ -240,8 +288,15 @@ class MainActivity : ComponentActivity() {
}
private fun loadConfiguredUrl(url: String) {
+ if (!isValidUrl(url)) {
+ showSetup(getString(R.string.status_invalid_url))
+ return
+ }
+
setupContainer.visibility = View.GONE
+ webView.visibility = View.VISIBLE
statusText.text = getString(R.string.status_ready)
+ webView.stopLoading()
webView.loadUrl(url)
}
@@ -306,7 +361,49 @@ class MainActivity : ComponentActivity() {
val uri = Uri.parse(url)
val scheme = uri.scheme?.lowercase()
- return (scheme == "http" || scheme == "https") && !uri.host.isNullOrBlank()
+ val isHttp = scheme == "http"
+ val isHttps = scheme == "https"
+
+ if (!BuildConfig.ALLOW_CLEARTEXT && !isHttps) {
+ return false
+ }
+
+ if (BuildConfig.ALLOW_CLEARTEXT && !isHttp && !isHttps) {
+ return false
+ }
+
+ return !uri.host.isNullOrBlank()
+ }
+
+ private fun normalizedPort(uri: Uri): Int {
+ return when {
+ uri.port != -1 -> uri.port
+ uri.scheme.equals("https", ignoreCase = true) -> 443
+ uri.scheme.equals("http", ignoreCase = true) -> 80
+ else -> -1
+ }
+ }
+
+ private fun isAllowedNavigationTarget(url: String): Boolean {
+ if (!isValidUrl(url)) {
+ return false
+ }
+
+ val configuredUrl = getConfiguredUrl()
+ if (configuredUrl.isBlank()) {
+ return false
+ }
+
+ val configuredUri = Uri.parse(configuredUrl)
+ val targetUri = Uri.parse(url)
+
+ return configuredUri.scheme.equals(targetUri.scheme, ignoreCase = true) &&
+ configuredUri.host.equals(targetUri.host, ignoreCase = true) &&
+ normalizedPort(configuredUri) == normalizedPort(targetUri)
+ }
+
+ private fun showStatus(message: String) {
+ statusText.text = message
}
private fun applyImmersiveMode() {
diff --git a/android-tv/app/src/main/res/layout/activity_main.xml b/android-tv/app/src/main/res/layout/activity_main.xml
index 602e4c1..ca07f83 100644
--- a/android-tv/app/src/main/res/layout/activity_main.xml
+++ b/android-tv/app/src/main/res/layout/activity_main.xml
@@ -16,16 +16,15 @@
android:background="#E6101115"
android:fillViewport="true">
-
+
+
-
+
- KVideo
+ KVideo TV
连接你的 KVideo
- 首次使用时输入你的 KVideo 部署地址。保存后,APK 会记住这个地址,后续可直接打开。
+ 这是一个仅面向 Android TV 的轻量 WebView 壳。首次使用时输入你的 KVideo 部署地址,保存后可直接从电视主页打开。
+ 发布版默认只接受 HTTPS,并且应用只会继续停留在你配置的同一站点域名内。
https://example.com
- 提示:如果 APK 已打开站点,按返回直到页面根目录即可回到这里;部分遥控器也可直接按菜单键打开此设置页。
+ 提示:如果 APK 已打开站点,按菜单键可随时返回这个设置页;发布版不会加载 HTTP 地址或跳转到其他域名。
打开已保存地址
- 保存并重载
+ 保存并打开
退出
已准备就绪
首次使用请先填写可访问的 KVideo 地址
- 地址无效。请输入完整的 http:// 或 https:// 地址,或输入域名后让应用自动补全为 https://。
+ 地址无效。发布版仅允许 HTTPS;调试版可选 HTTP 或 HTTPS。输入域名时应用会自动补全为 https://。
你可以在这里修改服务器地址,然后重新打开 KVideo。
+ 已拦截跨站跳转。Android TV 壳仅允许当前已配置站点继续导航。
+ TLS 证书校验失败,已阻止当前加载。
+ 页面加载失败,请检查地址、证书和网络连通性后重试。
diff --git a/app/api/app-update/route.ts b/app/api/app-update/route.ts
index 9f52f8f..f28b88b 100644
--- a/app/api/app-update/route.ts
+++ b/app/api/app-update/route.ts
@@ -12,7 +12,7 @@ import type {
AppUpdateResponse,
} from '@/lib/types/app-update';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const MANIFEST_PATH = 'app-release.json';
diff --git a/app/api/auth/accounts/[accountId]/route.ts b/app/api/auth/accounts/[accountId]/route.ts
index c876b69..f0de0fa 100644
--- a/app/api/auth/accounts/[accountId]/route.ts
+++ b/app/api/auth/accounts/[accountId]/route.ts
@@ -7,9 +7,14 @@ import {
updateManagedAccount,
} from '@/lib/server/auth';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
async function requireManagedSuperAdmin(request: NextRequest) {
+ const config = await getPublicAuthConfig();
+ if (config.authError) {
+ return { error: NextResponse.json({ error: config.authError }, { status: 503 }) };
+ }
+
const session = await getServerSession(request);
if (!session) {
return { error: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) };
@@ -19,7 +24,6 @@ async function requireManagedSuperAdmin(request: NextRequest) {
return { error: NextResponse.json({ error: 'Super admin required' }, { status: 403 }) };
}
- const config = await getPublicAuthConfig();
if (config.loginMode !== 'managed') {
return { error: NextResponse.json({ error: 'Managed account mode is not enabled' }, { status: 400 }) };
}
diff --git a/app/api/auth/accounts/route.ts b/app/api/auth/accounts/route.ts
index d13740d..66a057a 100644
--- a/app/api/auth/accounts/route.ts
+++ b/app/api/auth/accounts/route.ts
@@ -7,9 +7,14 @@ import {
listAccountInfo,
} from '@/lib/server/auth';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
async function requireSuperAdmin(request: NextRequest) {
+ const config = await getPublicAuthConfig();
+ if (config.authError) {
+ return { error: NextResponse.json({ error: config.authError }, { status: 503 }) };
+ }
+
const session = await getServerSession(request);
if (!session) {
return { error: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) };
diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts
index 2c04aaa..9888fde 100644
--- a/app/api/auth/route.ts
+++ b/app/api/auth/route.ts
@@ -5,8 +5,13 @@ import {
getPublicAuthConfig,
validatePremiumAccess,
} from '@/lib/server/auth';
+import {
+ clearAuthFailures,
+ getAuthThrottleStatus,
+ recordAuthFailure,
+} from '@/lib/server/auth-rate-limit';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
export async function GET() {
return NextResponse.json(await getPublicAuthConfig());
@@ -14,23 +19,53 @@ export async function GET() {
export async function POST(request: NextRequest) {
try {
- const body = await request.json();
- const { username, password, type } = body || {};
+ const body = (await request.json()) as {
+ username?: unknown;
+ password?: unknown;
+ type?: unknown;
+ };
+ const username = typeof body.username === 'string' ? body.username : undefined;
+ const password = typeof body.password === 'string' ? body.password : undefined;
+ const type = body.type === 'premium' ? 'premium' : 'login';
+ const throttle = await getAuthThrottleStatus(request, username, type);
+
+ if (throttle.blocked) {
+ return NextResponse.json(
+ {
+ valid: false,
+ message: 'Too many failed attempts. Please try again later.',
+ retryAfter: throttle.retryAfterSeconds,
+ },
+ {
+ status: 429,
+ headers: {
+ 'Retry-After': String(throttle.retryAfterSeconds),
+ },
+ },
+ );
+ }
if (type === 'premium') {
const valid = await validatePremiumAccess(request, { username, password });
+ if (valid) {
+ await clearAuthFailures(request, username, type);
+ } else {
+ await recordAuthFailure(request, username, type);
+ }
return NextResponse.json({ valid });
}
- if (!password || typeof password !== 'string') {
+ if (!password) {
return NextResponse.json({ valid: false, message: 'Password required' }, { status: 400 });
}
const session = await authenticateLogin({ username, password });
if (!session) {
+ await recordAuthFailure(request, username, type);
return NextResponse.json({ valid: false });
}
+ await clearAuthFailures(request, username, type);
return createLoginResponse(session);
} catch {
return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 });
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index 7089665..62926b4 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -1,7 +1,7 @@
import { NextRequest } from 'next/server';
import { createSessionStatusResponse, logoutResponse } from '@/lib/server/auth';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
return createSessionStatusResponse(request);
diff --git a/app/api/config/route.ts b/app/api/config/route.ts
index 57bc4c5..6301b70 100644
--- a/app/api/config/route.ts
+++ b/app/api/config/route.ts
@@ -6,7 +6,7 @@
import { NextResponse } from 'next/server';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
const SUBSCRIPTION_SOURCES = process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || '';
diff --git a/app/api/danmaku/route.ts b/app/api/danmaku/route.ts
index b3bf439..1c09d95 100644
--- a/app/api/danmaku/route.ts
+++ b/app/api/danmaku/route.ts
@@ -1,63 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
+import { buildSameOriginOptionsResponse, requireAuthenticatedRequestIfConfigured } from '@/lib/server/api-access';
+import { fetchWithPolicy, OutboundPolicyError, assertOutboundUrlAllowed } from '@/lib/server/outbound-policy';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
-const CORS_HEADERS = {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
- 'Access-Control-Allow-Headers': 'Content-Type',
-};
+function buildDanmakuTarget(baseUrl: URL, action: 'search' | 'comments', keyword?: string, episodeId?: string): URL {
+ const normalizedBase = new URL(baseUrl.toString().replace(/\/+$/, '/'));
-export async function OPTIONS() {
- return new NextResponse(null, { headers: CORS_HEADERS });
+ if (action === 'search') {
+ normalizedBase.pathname = `${normalizedBase.pathname.replace(/\/$/, '')}/api/v2/search/episodes`;
+ normalizedBase.search = `anime=${encodeURIComponent(keyword || '')}`;
+ return normalizedBase;
+ }
+
+ normalizedBase.pathname = `${normalizedBase.pathname.replace(/\/$/, '')}/api/v2/comment/${encodeURIComponent(episodeId || '')}`;
+ normalizedBase.search = 'withRelated=true';
+ return normalizedBase;
+}
+
+export async function OPTIONS(request: NextRequest) {
+ return buildSameOriginOptionsResponse(request, 'GET, OPTIONS');
}
export async function GET(request: NextRequest) {
+ const access = await requireAuthenticatedRequestIfConfigured(request);
+ if (access.error) {
+ return access.error;
+ }
+
const { searchParams } = request.nextUrl;
const action = searchParams.get('action');
const apiUrl = searchParams.get('apiUrl');
- if (!action || !apiUrl) {
- return NextResponse.json(
- { error: 'Missing action or apiUrl parameter' },
- { status: 400, headers: CORS_HEADERS }
- );
+ if (!action || !apiUrl || (action !== 'search' && action !== 'comments')) {
+ return NextResponse.json({ error: 'Missing or invalid action/apiUrl parameter' }, { status: 400 });
}
- // Normalize base URL (remove trailing slash)
- const baseUrl = apiUrl.replace(/\/+$/, '');
-
try {
- let targetUrl: string;
+ const baseUrl = await assertOutboundUrlAllowed(apiUrl);
+ const keyword = searchParams.get('keyword') || undefined;
+ const episodeId = searchParams.get('episodeId') || undefined;
- if (action === 'search') {
- const keyword = searchParams.get('keyword');
- if (!keyword) {
- return NextResponse.json(
- { error: 'Missing keyword parameter' },
- { status: 400, headers: CORS_HEADERS }
- );
- }
- targetUrl = `${baseUrl}/api/v2/search/episodes?anime=${encodeURIComponent(keyword)}`;
- } else if (action === 'comments') {
- const episodeId = searchParams.get('episodeId');
- if (!episodeId) {
- return NextResponse.json(
- { error: 'Missing episodeId parameter' },
- { status: 400, headers: CORS_HEADERS }
- );
- }
- targetUrl = `${baseUrl}/api/v2/comment/${encodeURIComponent(episodeId)}?withRelated=true`;
- } else {
- return NextResponse.json(
- { error: 'Invalid action. Use "search" or "comments".' },
- { status: 400, headers: CORS_HEADERS }
- );
+ if (action === 'search' && !keyword) {
+ return NextResponse.json({ error: 'Missing keyword parameter' }, { status: 400 });
}
- const response = await fetch(targetUrl, {
+ if (action === 'comments' && !episodeId) {
+ return NextResponse.json({ error: 'Missing episodeId parameter' }, { status: 400 });
+ }
+
+ const targetUrl = buildDanmakuTarget(baseUrl, action, keyword, episodeId);
+ const response = await fetchWithPolicy(targetUrl, {
headers: {
- 'Accept': 'application/json',
+ Accept: 'application/json',
'User-Agent': 'KVideo/1.0',
},
});
@@ -65,21 +60,21 @@ export async function GET(request: NextRequest) {
if (!response.ok) {
return NextResponse.json(
{ error: `Upstream API returned ${response.status}` },
- { status: response.status, headers: CORS_HEADERS }
+ { status: response.status },
);
}
const data = await response.json();
return NextResponse.json(data, {
headers: {
- ...CORS_HEADERS,
- 'Cache-Control': 'public, max-age=3600', // Cache danmaku for 1 hour
+ 'Cache-Control': 'public, max-age=3600',
},
});
} catch (error) {
+ const status = error instanceof OutboundPolicyError ? error.status : 502;
return NextResponse.json(
- { error: 'Failed to fetch from danmaku API' },
- { status: 502, headers: CORS_HEADERS }
+ { error: error instanceof Error ? error.message : 'Failed to fetch from danmaku API' },
+ { status },
);
}
}
diff --git a/app/api/detail/route.ts b/app/api/detail/route.ts
index 6f20219..04d230a 100644
--- a/app/api/detail/route.ts
+++ b/app/api/detail/route.ts
@@ -1,112 +1,96 @@
-/**
- * Detail API Route
- * Fetches video details including episodes and M3U8 URLs with automatic source validation
- */
-
import { NextRequest, NextResponse } from 'next/server';
import { getVideoDetail } from '@/lib/api/client';
import { getSourceById } from '@/lib/api/video-sources';
+import { requireAuthenticatedRequestIfConfigured } from '@/lib/server/api-access';
+import { normalizeSourceConfig } from '@/lib/server/source-validation';
+import type { VideoSource } from '@/lib/types';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
-/**
- * Shared handler for fetching video details
- */
-async function handleDetailRequest(id: string | null, source: string | null, method: string) {
- // Validate input
+async function resolveSourceConfig(source: unknown): Promise {
+ if (typeof source === 'string') {
+ const builtInSource = getSourceById(source);
+ return builtInSource ? normalizeSourceConfig(builtInSource) : null;
+ }
+
+ return normalizeSourceConfig(source);
+}
+
+async function handleDetailRequest(id: string | null, source: unknown) {
if (!id) {
return NextResponse.json(
{ error: 'Missing video ID parameter' },
- { status: 400 }
+ { status: 400 },
);
}
- // Validate source
- if (!source) {
- return NextResponse.json(
- { error: 'Missing source parameter' },
- { status: 400 }
- );
- }
-
- let sourceConfig;
-
- // If source is an object (from POST), use it
- if (typeof source === 'object') {
- sourceConfig = source;
- } else {
- // If source is a string ID (from GET), try to look it up
- sourceConfig = getSourceById(source);
- }
-
+ const sourceConfig = await resolveSourceConfig(source);
if (!sourceConfig) {
return NextResponse.json(
{ error: 'Invalid source configuration' },
- { status: 400 }
+ { status: 400 },
);
}
- // Fetch video detail without validation (already validated during search)
try {
const videoDetail = await getVideoDetail(id, sourceConfig);
- // Skip validation - videos are already checked during search
- // Just return the episodes as-is
-
-
return NextResponse.json({
success: true,
data: videoDetail,
});
} catch (error) {
- console.error('Detail API error:', error);
-
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch video detail',
},
- { status: 500 }
+ { status: 500 },
);
}
}
export async function GET(request: NextRequest) {
+ const access = await requireAuthenticatedRequestIfConfigured(request);
+ if (access.error) {
+ return access.error;
+ }
+
try {
const searchParams = request.nextUrl.searchParams;
const id = searchParams.get('id');
const source = searchParams.get('source');
- return await handleDetailRequest(id, source, 'GET');
+ return handleDetailRequest(id, source);
} catch (error) {
- console.error('Detail API error:', error);
-
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
- { status: 500 }
+ { status: 500 },
);
}
}
-// Support POST method for complex requests
export async function POST(request: NextRequest) {
+ const access = await requireAuthenticatedRequestIfConfigured(request);
+ if (access.error) {
+ return access.error;
+ }
+
try {
- const body = await request.json();
- const { id, source } = body;
+ const body = (await request.json()) as { id?: unknown; source?: unknown };
+ const id = typeof body.id === 'string' || typeof body.id === 'number' ? String(body.id) : null;
- return await handleDetailRequest(id, source, 'POST');
+ return handleDetailRequest(id, body.source);
} catch (error) {
- console.error('Detail API error:', error);
-
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
- { status: 500 }
+ { status: 500 },
);
}
}
diff --git a/app/api/douban/image/route.ts b/app/api/douban/image/route.ts
index a51deef..6bcfba3 100644
--- a/app/api/douban/image/route.ts
+++ b/app/api/douban/image/route.ts
@@ -1,60 +1,69 @@
-import { NextResponse } from 'next/server';
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuthenticatedRequestIfConfigured } from '@/lib/server/api-access';
+import { assertOutboundUrlAllowed, fetchWithPolicy, OutboundPolicyError } from '@/lib/server/outbound-policy';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
-export async function GET(request: Request) {
- const { searchParams } = new URL(request.url);
- const imageUrl = searchParams.get('url');
+function isAllowedDoubanImageHost(hostname: string): boolean {
+ return hostname === 'doubanio.com' || hostname.endsWith('.doubanio.com');
+}
+
+export async function GET(request: NextRequest) {
+ const access = await requireAuthenticatedRequestIfConfigured(request);
+ if (access.error) {
+ return access.error;
+ }
+
+ const imageUrl = request.nextUrl.searchParams.get('url');
+ if (!imageUrl) {
+ return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
+ }
+
+ try {
+ const targetUrl = await assertOutboundUrlAllowed(imageUrl);
+ if (!isAllowedDoubanImageHost(targetUrl.hostname)) {
+ return NextResponse.json({ error: 'Only Douban image hosts are allowed' }, { status: 403 });
+ }
+
+ const imageResponse = await fetchWithPolicy(targetUrl, {
+ headers: {
+ 'User-Agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
+ Accept: 'image/avif,image/webp,image/jpeg,image/png,image/gif,*/*;q=0.8',
+ Referer: 'https://movie.douban.com/',
+ },
+ });
+
+ if (!imageResponse.ok) {
+ return NextResponse.json(
+ { error: imageResponse.statusText },
+ { status: imageResponse.status },
+ );
+ }
- if (!imageUrl) {
- return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
+ if (!imageResponse.body) {
+ return NextResponse.json(
+ { error: 'Image response has no body' },
+ { status: 500 },
+ );
}
- try {
- const imageResponse = await fetch(imageUrl, {
- headers: {
- 'User-Agent':
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
- Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8',
- Referer: 'https://movie.douban.com/',
- },
- });
-
- if (!imageResponse.ok) {
- return NextResponse.json(
- { error: imageResponse.statusText },
- { status: imageResponse.status }
- );
- }
-
- const contentType = imageResponse.headers.get('content-type');
-
- if (!imageResponse.body) {
- return NextResponse.json(
- { error: 'Image response has no body' },
- { status: 500 }
- );
- }
-
- // 创建响应头
- const headers = new Headers();
- if (contentType) {
- headers.set('Content-Type', contentType);
- }
-
- // 设置缓存头
- headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000');
-
- // 直接返回图片流
- // @ts-ignore
- return new Response(imageResponse.body, {
- status: 200,
- headers,
- });
- } catch (error) {
- return NextResponse.json(
- { error: 'Error fetching image' },
- { status: 500 }
- );
+ const headers = new Headers();
+ const contentType = imageResponse.headers.get('content-type');
+ if (contentType) {
+ headers.set('Content-Type', contentType);
}
+ headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000');
+
+ return new Response(imageResponse.body, {
+ status: 200,
+ headers,
+ });
+ } catch (error) {
+ const status = error instanceof OutboundPolicyError ? error.status : 500;
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Error fetching image' },
+ { status },
+ );
+ }
}
diff --git a/app/api/douban/recommend/route.ts b/app/api/douban/recommend/route.ts
index 2cc8f54..378d190 100644
--- a/app/api/douban/recommend/route.ts
+++ b/app/api/douban/recommend/route.ts
@@ -1,6 +1,18 @@
import { NextResponse } from 'next/server';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
+
+interface DoubanSubject {
+ id: string;
+ title: string;
+ cover?: string;
+ rate?: string;
+ url?: string;
+}
+
+interface DoubanRecommendResponse {
+ subjects?: DoubanSubject[];
+}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
@@ -24,11 +36,11 @@ export async function GET(request: Request) {
throw new Error(`Douban API returned ${response.status}`);
}
- const data = await response.json();
+ const data = (await response.json()) as DoubanRecommendResponse;
// 转换图片链接使用代理
if (data.subjects && Array.isArray(data.subjects)) {
- data.subjects = data.subjects.map((item: any) => ({
+ data.subjects = data.subjects.map((item) => ({
...item,
cover: item.cover ? `/api/douban/image?url=${encodeURIComponent(item.cover)}` : item.cover,
}));
diff --git a/app/api/douban/tags/route.ts b/app/api/douban/tags/route.ts
index bfd8baf..fb72e16 100644
--- a/app/api/douban/tags/route.ts
+++ b/app/api/douban/tags/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
diff --git a/app/api/iptv/route.ts b/app/api/iptv/route.ts
index 31dc6eb..49be74b 100644
--- a/app/api/iptv/route.ts
+++ b/app/api/iptv/route.ts
@@ -1,60 +1,60 @@
-/**
- * IPTV Proxy API Route
- * Fetches M3U playlist files to avoid CORS issues
- */
-
import { NextRequest, NextResponse } from 'next/server';
+import {
+ fetchWithPolicy,
+ OutboundPolicyError,
+ sanitizeReferer,
+ sanitizeUserAgent,
+} from '@/lib/server/outbound-policy';
+import { buildSameOriginOptionsResponse, requireRelayAccess } from '@/lib/server/api-access';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
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');
+ const access = await requireRelayAccess(request);
+ if (access.error) {
+ return access.error;
+ }
+ const url = request.nextUrl.searchParams.get('url');
if (!url) {
return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
}
try {
- const parsedUrl = new URL(url);
- let refererOrigin = `${parsedUrl.protocol}//${parsedUrl.host}`;
- if (customReferer) {
- try {
- refererOrigin = new URL(customReferer).origin;
- } catch {
- refererOrigin = `${parsedUrl.protocol}//${parsedUrl.host}`;
- }
- }
- const response = await fetch(url, {
+ const customUa = sanitizeUserAgent(request.nextUrl.searchParams.get('ua'));
+ const customReferer = await sanitizeReferer(request.nextUrl.searchParams.get('referer'));
+ const response = await fetchWithPolicy(url, {
headers: {
- 'User-Agent': customUa || 'Mozilla/5.0 (compatible; KVideo/1.0)',
- ...(customReferer ? { 'Referer': customReferer } : {}),
- 'Origin': refererOrigin,
+ 'Accept': 'text/plain, application/vnd.apple.mpegurl, application/x-mpegurl;q=0.9, */*;q=0.8',
+ ...(customUa ? { 'User-Agent': customUa } : {}),
+ ...(customReferer ? { Referer: customReferer } : {}),
},
});
if (!response.ok) {
return NextResponse.json(
{ error: `Failed to fetch: ${response.status}` },
- { status: response.status }
+ { status: response.status },
);
}
const text = await response.text();
-
return new NextResponse(text, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
- 'Access-Control-Allow-Origin': '*',
- 'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
+ 'Cache-Control': 'public, max-age=300',
},
});
- } catch (e) {
+ } catch (error) {
+ const status = error instanceof OutboundPolicyError ? error.status : 500;
return NextResponse.json(
- { error: 'Failed to fetch M3U playlist' },
- { status: 500 }
+ { error: error instanceof Error ? error.message : 'Failed to fetch M3U playlist' },
+ { status },
);
}
}
+
+export async function OPTIONS(request: NextRequest) {
+ return buildSameOriginOptionsResponse(request, 'GET, OPTIONS');
+}
diff --git a/app/api/iptv/stream/route.ts b/app/api/iptv/stream/route.ts
index ae69048..b76c10d 100644
--- a/app/api/iptv/stream/route.ts
+++ b/app/api/iptv/stream/route.ts
@@ -1,60 +1,62 @@
-/**
- * IPTV Stream Proxy API Route
- * Proxies HLS manifests and media segments to avoid CORS issues.
- * For .m3u8/.m3u manifests, rewrites URLs to also route through this proxy.
- * Supports HLS, MPEG-TS, and other stream formats with automatic content detection.
- */
-
import { NextRequest, NextResponse } from 'next/server';
+import { buildSameOriginOptionsResponse, requireRelayAccess } from '@/lib/server/api-access';
+import {
+ fetchWithPolicy,
+ OutboundPolicyError,
+ sanitizeReferer,
+ sanitizeUserAgent,
+} from '@/lib/server/outbound-policy';
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
const STREAM_TIMEOUT_MS = 20000;
-const REALISTIC_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
+const DEFAULT_USER_AGENT =
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
function resolveUrl(base: string, relative: string): string {
if (relative.startsWith('http://') || relative.startsWith('https://')) {
return relative;
}
- try {
- return new URL(relative, base).href;
- } catch {
- // Fallback: manual resolution
- const baseUrl = base.substring(0, base.lastIndexOf('/') + 1);
- return baseUrl + relative;
- }
+
+ return new URL(relative, base).href;
}
-function buildProxyBase(customUa?: string | null, customReferer?: string | null): string {
- let base = '/api/iptv/stream?';
- if (customUa) base += `ua=${encodeURIComponent(customUa)}&`;
- if (customReferer) base += `referer=${encodeURIComponent(customReferer)}&`;
- base += 'url=';
- return base;
+function buildProxyBase(customUa?: string, customReferer?: string): string {
+ const searchParams = new URLSearchParams();
+ if (customUa) {
+ searchParams.set('ua', customUa);
+ }
+ if (customReferer) {
+ searchParams.set('referer', customReferer);
+ }
+ searchParams.set('url', '');
+ return `/api/iptv/stream?${searchParams.toString()}`;
}
function rewriteM3u8(content: string, baseUrl: string, proxyBase: string): string {
- return content.split('\n').map(line => {
- const trimmed = line.trim();
- // Skip empty lines
- if (!trimmed) return line;
+ return content
+ .split('\n')
+ .map((line) => {
+ const trimmed = line.trim();
+ if (!trimmed) {
+ return line;
+ }
- // Rewrite URI="..." in EXT-X-KEY, EXT-X-MAP, etc.
- if (trimmed.startsWith('#') && trimmed.includes('URI="')) {
- return line.replace(/URI="([^"]+)"/g, (_match, uri) => {
- const absoluteUri = resolveUrl(baseUrl, uri);
- return `URI="${proxyBase}${encodeURIComponent(absoluteUri)}"`;
- });
- }
+ if (trimmed.startsWith('#') && trimmed.includes('URI="')) {
+ return line.replace(/URI="([^"]+)"/g, (_match, uri) => {
+ const absoluteUri = resolveUrl(baseUrl, uri);
+ return `URI="${proxyBase}${encodeURIComponent(absoluteUri)}"`;
+ });
+ }
- // Skip other comment lines
- if (trimmed.startsWith('#')) return line;
+ if (trimmed.startsWith('#')) {
+ return line;
+ }
- // This is a segment/playlist URL line - rewrite it
- const absoluteUrl = resolveUrl(baseUrl, trimmed);
- return `${proxyBase}${encodeURIComponent(absoluteUrl)}`;
- }).join('\n');
+ return `${proxyBase}${encodeURIComponent(resolveUrl(baseUrl, trimmed))}`;
+ })
+ .join('\n');
}
function isM3u8Url(url: string): boolean {
@@ -71,7 +73,10 @@ function isM3u8ContentType(contentType: string): boolean {
}
function isAmbiguousContentType(contentType: string): boolean {
- if (!contentType) return true;
+ if (!contentType) {
+ return true;
+ }
+
const lower = contentType.toLowerCase();
return lower.includes('text/plain') ||
lower.includes('application/octet-stream') ||
@@ -84,56 +89,65 @@ function isM3u8Content(text: string): boolean {
return trimmed.startsWith('#EXTM3U') || trimmed.startsWith('#EXT-X-');
}
-const CORS_HEADERS = {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
- 'Access-Control-Allow-Headers': '*',
- 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges',
-};
+function buildResponseHeaders(response: Response, fallbackContentType: string): Headers {
+ const headers = new Headers();
+ headers.set('Content-Type', response.headers.get('content-type') || fallbackContentType);
+ headers.set('Cache-Control', 'public, max-age=60');
+
+ const contentRange = response.headers.get('content-range');
+ if (contentRange) {
+ headers.set('Content-Range', contentRange);
+ }
+
+ const acceptRanges = response.headers.get('accept-ranges');
+ if (acceptRanges) {
+ headers.set('Accept-Ranges', acceptRanges);
+ }
+
+ const contentLength = response.headers.get('content-length');
+ if (contentLength) {
+ headers.set('Content-Length', contentLength);
+ }
+
+ return headers;
+}
export async function GET(request: NextRequest) {
const runtimeFeatures = getRuntimeFeatures();
-
if (!runtimeFeatures.iptvEnabled) {
return NextResponse.json(
{
error: 'IPTV relay is disabled on this deployment',
message: runtimeFeatures.restrictionSummary,
},
- { status: 403 }
+ { status: 403 },
);
}
- const url = request.nextUrl.searchParams.get('url');
- const customUa = request.nextUrl.searchParams.get('ua');
- const customReferer = request.nextUrl.searchParams.get('referer');
+ const access = await requireRelayAccess(request);
+ if (access.error) {
+ return access.error;
+ }
+ const url = request.nextUrl.searchParams.get('url');
if (!url) {
return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
}
try {
- const parsedUrl = new URL(url);
- const fetchHeaders: Record = {
- 'User-Agent': customUa || REALISTIC_USER_AGENT,
- 'Accept': '*/*',
- 'Referer': customReferer || `${parsedUrl.protocol}//${parsedUrl.host}/`,
- 'Origin': `${parsedUrl.protocol}//${parsedUrl.host}`,
- 'Connection': 'keep-alive',
- };
-
- // Forward Range header for partial content requests
+ const customUa = sanitizeUserAgent(request.nextUrl.searchParams.get('ua')) || DEFAULT_USER_AGENT;
+ const customReferer = await sanitizeReferer(request.nextUrl.searchParams.get('referer'));
const rangeHeader = request.headers.get('range');
- if (rangeHeader) {
- fetchHeaders['Range'] = rangeHeader;
- }
-
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS);
- const response = await fetch(url, {
- headers: fetchHeaders,
- redirect: 'follow',
+ const response = await fetchWithPolicy(url, {
+ headers: {
+ Accept: '*/*',
+ ...(customUa ? { 'User-Agent': customUa } : {}),
+ ...(customReferer ? { Referer: customReferer } : {}),
+ ...(rangeHeader ? { Range: rangeHeader } : {}),
+ },
signal: controller.signal,
});
clearTimeout(timeout);
@@ -141,7 +155,7 @@ export async function GET(request: NextRequest) {
if (!response.ok && response.status !== 206) {
return NextResponse.json(
{ error: `Upstream returned ${response.status}` },
- { status: response.status }
+ { status: response.status },
);
}
@@ -149,14 +163,14 @@ export async function GET(request: NextRequest) {
let isM3u8 = isM3u8Url(url) || isM3u8ContentType(contentType);
const proxyBase = buildProxyBase(customUa, customReferer);
- // If content-type is ambiguous, check the response body for M3U header
if (!isM3u8 && isAmbiguousContentType(contentType)) {
const cloned = response.clone();
- // Read first 1KB to check for M3U8 header without consuming too much
const reader = cloned.body?.getReader();
+
if (reader) {
const { value } = await reader.read();
reader.releaseLock();
+
if (value) {
const text = new TextDecoder().decode(value.slice(0, 1024));
if (isM3u8Content(text)) {
@@ -166,7 +180,6 @@ export async function GET(request: NextRequest) {
}
if (isM3u8) {
- // Re-read the full body for M3U8 rewriting
const fullText = await response.text();
const rewritten = rewriteM3u8(fullText, url, proxyBase);
return new NextResponse(rewritten, {
@@ -174,72 +187,44 @@ export async function GET(request: NextRequest) {
headers: {
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-cache, no-store',
- ...CORS_HEADERS,
},
});
}
- // Not M3U8 — stream original binary body directly
return new NextResponse(response.body, {
status: response.status,
- headers: {
- 'Content-Type': contentType || 'video/mp2t',
- 'Cache-Control': 'no-cache',
- ...CORS_HEADERS,
- },
+ headers: buildResponseHeaders(response, 'video/mp2t'),
});
}
if (isM3u8) {
- // Parse and rewrite manifest
const text = await response.text();
const rewritten = rewriteM3u8(text, url, proxyBase);
-
return new NextResponse(rewritten, {
status: 200,
headers: {
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-cache, no-store',
- ...CORS_HEADERS,
},
});
}
- // Non-M3U8 media content — pipe through directly
- const body = response.body;
- const forwardContentType = contentType || 'video/mp2t';
-
- const responseHeaders: Record = {
- 'Content-Type': forwardContentType,
- 'Cache-Control': 'public, max-age=60',
- ...CORS_HEADERS,
- };
-
- // Forward range-related headers
- const contentRange = response.headers.get('content-range');
- if (contentRange) responseHeaders['Content-Range'] = contentRange;
- const acceptRanges = response.headers.get('accept-ranges');
- if (acceptRanges) responseHeaders['Accept-Ranges'] = acceptRanges;
- const contentLength = response.headers.get('content-length');
- if (contentLength) responseHeaders['Content-Length'] = contentLength;
-
- return new NextResponse(body, {
+ return new NextResponse(response.body, {
status: response.status,
- headers: responseHeaders,
+ headers: buildResponseHeaders(response, 'video/mp2t'),
});
- } catch (e) {
- const message = e instanceof Error ? e.message : 'Unknown error';
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
const isTimeout = message.includes('abort');
+ const status = error instanceof OutboundPolicyError ? error.status : isTimeout ? 504 : 502;
+
return NextResponse.json(
- { error: isTimeout ? 'Stream request timed out' : 'Failed to proxy stream' },
- { status: isTimeout ? 504 : 502 }
+ { error: isTimeout ? 'Stream request timed out' : message },
+ { status },
);
}
}
-export async function OPTIONS() {
- return new NextResponse(null, {
- status: 204,
- headers: CORS_HEADERS,
- });
+export async function OPTIONS(request: NextRequest) {
+ return buildSameOriginOptionsResponse(request, 'GET, HEAD, OPTIONS');
}
diff --git a/app/api/ping/route.ts b/app/api/ping/route.ts
index 95d1176..4c74ae9 100644
--- a/app/api/ping/route.ts
+++ b/app/api/ping/route.ts
@@ -1,75 +1,73 @@
-/**
- * Ping API Route - Measures latency to video sources
- * Returns response time for real-time latency display
- */
-
import { NextRequest, NextResponse } from 'next/server';
+import { buildSameOriginOptionsResponse, requireRelayAccess } from '@/lib/server/api-access';
+import { fetchWithPolicy, OutboundPolicyError } from '@/lib/server/outbound-policy';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
+
+async function pingUrl(url: string, method: 'HEAD' | 'GET'): Promise {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
+
+ try {
+ await fetchWithPolicy(url, {
+ method,
+ signal: controller.signal,
+ });
+ } finally {
+ clearTimeout(timeoutId);
+ }
+}
export async function POST(request: NextRequest) {
- try {
- const body = await request.json();
- const { url } = body;
+ const access = await requireRelayAccess(request);
+ if (access.error) {
+ return access.error;
+ }
- if (!url || typeof url !== 'string') {
- return NextResponse.json({ error: 'Invalid URL' }, { status: 400 });
- }
+ try {
+ const body = (await request.json()) as { url?: unknown };
+ const url = typeof body.url === 'string' ? body.url : '';
- // Validate URL format
- try {
- new URL(url);
- } catch {
- return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 });
- }
-
- const startTime = performance.now();
-
- try {
- // Use HEAD request for faster ping (less data transfer)
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
-
- await fetch(url, {
- method: 'HEAD',
- signal: controller.signal,
- mode: 'no-cors', // Allow cross-origin requests
- });
-
- clearTimeout(timeoutId);
-
- const endTime = performance.now();
- const latency = Math.round(endTime - startTime);
-
- return NextResponse.json({ latency, success: true });
- } catch (fetchError) {
- // If HEAD fails, try GET with timeout
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 5000);
-
- try {
- await fetch(url, {
- method: 'GET',
- signal: controller.signal,
- });
- clearTimeout(timeoutId);
-
- const endTime = performance.now();
- const latency = Math.round(endTime - startTime);
- return NextResponse.json({ latency, success: true });
- } catch {
- clearTimeout(timeoutId);
- const endTime = performance.now();
- const latency = Math.round(endTime - startTime);
- // Still return latency even on error (timeout = slow)
- return NextResponse.json({ latency, success: false, timeout: true });
- }
- }
- } catch (error) {
- console.error('Ping error:', error);
- return NextResponse.json(
- { error: error instanceof Error ? error.message : 'Unknown error' },
- { status: 500 }
- );
+ if (!url) {
+ return NextResponse.json({ error: 'Invalid URL' }, { status: 400 });
}
+
+ const startTime = performance.now();
+
+ try {
+ await pingUrl(url, 'HEAD');
+ return NextResponse.json({
+ latency: Math.round(performance.now() - startTime),
+ success: true,
+ });
+ } catch {
+ try {
+ await pingUrl(url, 'GET');
+ return NextResponse.json({
+ latency: Math.round(performance.now() - startTime),
+ success: true,
+ });
+ } catch (error) {
+ const status = error instanceof OutboundPolicyError ? error.status : 200;
+ return NextResponse.json(
+ {
+ latency: Math.round(performance.now() - startTime),
+ success: false,
+ timeout: !(error instanceof OutboundPolicyError),
+ error: error instanceof Error ? error.message : 'Unknown error',
+ },
+ { status },
+ );
+ }
+ }
+ } catch (error) {
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Unknown error' },
+ { status: 500 },
+ );
+ }
+}
+
+export async function OPTIONS(request: NextRequest) {
+ return buildSameOriginOptionsResponse(request, 'POST, OPTIONS');
}
diff --git a/app/api/premium/category/route.ts b/app/api/premium/category/route.ts
index 1cd21d3..0f3fcde 100644
--- a/app/api/premium/category/route.ts
+++ b/app/api/premium/category/route.ts
@@ -1,14 +1,32 @@
import { NextResponse } from 'next/server';
+import type { VideoSource } from '@/lib/types';
+import { fetchWithPolicy } from '@/lib/server/outbound-policy';
+import { normalizeSourceConfigList } from '@/lib/server/source-validation';
-export const runtime = 'edge';
-// We still import this type but won't rely on the empty array
+export const runtime = 'nodejs';
import { PREMIUM_SOURCES } from '@/lib/api/premium-sources';
-/**
- * Shared handler for fetching content
- */
+interface PremiumCategoryVideo {
+ vod_id: string | number;
+ vod_name: string;
+ vod_pic?: string;
+ vod_remarks?: string;
+ type_name?: string;
+ source: string;
+}
+
+interface PremiumCategoryResponse {
+ list?: Array<{
+ vod_id: string | number;
+ vod_name: string;
+ vod_pic?: string;
+ vod_remarks?: string;
+ type_name?: string;
+ }>;
+}
+
async function handleCategoryRequest(
- sourceList: any[],
+ sourceList: VideoSource[],
categoryParam: string,
page: number,
limit: number
@@ -43,11 +61,12 @@ async function handleCategoryRequest(
return NextResponse.json({ videos: [], error: 'No enabled sources provided or found' }, { status: 500 });
}
- const fetchPromises = targetSources.map(async (source: any) => {
+ const fetchPromises = targetSources.map(async (source): Promise => {
try {
const url = new URL(source.baseUrl);
url.searchParams.set('ac', 'detail');
url.searchParams.set('pg', page.toString());
+ url.searchParams.set('limit', limit.toString());
if (sourceMap.has(source.id)) {
url.searchParams.set('t', sourceMap.get(source.id)!);
@@ -56,20 +75,19 @@ async function handleCategoryRequest(
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
- const response = await fetch(url.toString(), {
+ const response = await fetchWithPolicy(url, {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
- next: { revalidate: 1800 },
});
clearTimeout(timeoutId);
if (!response.ok) return [];
- const data = await response.json();
- return (data.list || []).map((item: any) => ({
+ const data = (await response.json()) as PremiumCategoryResponse;
+ return (data.list || []).map((item) => ({
vod_id: item.vod_id,
vod_name: item.vod_name,
vod_pic: item.vod_pic,
@@ -85,7 +103,7 @@ async function handleCategoryRequest(
const results = await Promise.all(fetchPromises);
- const interleavedVideos = [];
+ const interleavedVideos: PremiumCategoryVideo[] = [];
const maxLen = Math.max(...results.map(r => r.length));
for (let i = 0; i < maxLen; i++) {
@@ -111,26 +129,24 @@ export async function POST(request: Request) {
try {
const body = await request.json();
const { sources, category, page, limit } = body;
+ const normalizedSources = await normalizeSourceConfigList(sources);
- // Use provided sources
return await handleCategoryRequest(
- sources || [],
+ normalizedSources,
category || '',
parseInt(page || '1'),
parseInt(limit || '20')
);
- } catch (error) {
+ } catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
export async function GET(request: Request) {
- // Legacy GET support - currently BROKEN since ADULT_SOURCES is empty
- // But kept for structure. It will likely return 500 "No enabled sources"
const { searchParams } = new URL(request.url);
const categoryParam = searchParams.get('category') || '';
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
- return await handleCategoryRequest(PREMIUM_SOURCES, categoryParam, page, limit);
+ return await handleCategoryRequest(await normalizeSourceConfigList(PREMIUM_SOURCES), categoryParam, page, limit);
}
diff --git a/app/api/premium/types/route.ts b/app/api/premium/types/route.ts
index f01b70f..af98e9d 100644
--- a/app/api/premium/types/route.ts
+++ b/app/api/premium/types/route.ts
@@ -1,7 +1,10 @@
import { NextResponse } from 'next/server';
+import type { VideoSource } from '@/lib/types';
import { PREMIUM_SOURCES } from '@/lib/api/premium-sources';
+import { fetchWithPolicy } from '@/lib/server/outbound-policy';
+import { normalizeSourceConfigList } from '@/lib/server/source-validation';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
export const revalidate = 3600; // Cache for 1 hour
@@ -10,19 +13,17 @@ interface Category {
type_name: string;
}
-interface SourceCategories {
- sourceId: string;
- sourceName: string;
- categories: Category[];
+interface PremiumTypesResponse {
+ class?: Category[];
}
// Shared handler
-async function handleTypesRequest(sourceList: any[]) {
+async function handleTypesRequest(sourceList: VideoSource[]) {
try {
const enabledSources = sourceList.filter(s => s.enabled);
const results = await Promise.allSettled(
- enabledSources.map(async (source: any) => {
+ enabledSources.map(async (source) => {
try {
const url = new URL(source.baseUrl);
url.searchParams.set('ac', 'list');
@@ -30,12 +31,11 @@ async function handleTypesRequest(sourceList: any[]) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
- const response = await fetch(url.toString(), {
+ const response = await fetchWithPolicy(url, {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
- next: { revalidate: 3600 }
});
clearTimeout(timeoutId);
@@ -44,7 +44,7 @@ async function handleTypesRequest(sourceList: any[]) {
throw new Error(`HTTP ${response.status}`);
}
- const data = await response.json();
+ const data = (await response.json()) as PremiumTypesResponse;
return {
sourceId: source.id,
sourceName: source.name,
@@ -164,12 +164,12 @@ export async function POST(request: Request) {
try {
const body = await request.json();
const { sources } = body;
- return await handleTypesRequest(sources || []);
- } catch (error) {
+ return await handleTypesRequest(await normalizeSourceConfigList(sources));
+ } catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
export async function GET() {
- return await handleTypesRequest(PREMIUM_SOURCES);
+ return await handleTypesRequest(await normalizeSourceConfigList(PREMIUM_SOURCES));
}
diff --git a/app/api/probe-resolution/route.ts b/app/api/probe-resolution/route.ts
index 0a6440f..611fd8a 100644
--- a/app/api/probe-resolution/route.ts
+++ b/app/api/probe-resolution/route.ts
@@ -1,9 +1,3 @@
-/**
- * Probe Resolution API
- * Fetches actual video resolution by parsing m3u8 manifests.
- * Accepts a batch of videos and streams results back via SSE.
- */
-
import { NextRequest } from 'next/server';
import { getSourceById } from '@/lib/api/video-sources';
import { getVideoDetail } from '@/lib/api/detail-api';
@@ -14,9 +8,11 @@ import {
parseResolutionFromManifest,
type ResolutionProbeLabel,
} from '@/lib/player/resolution-probe-utils';
+import { requireAuthenticatedRequestIfConfigured } from '@/lib/server/api-access';
+import { buildSourceConfigMap, normalizeSourceConfig } from '@/lib/server/source-validation';
import type { VideoSource } from '@/lib/types';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
interface ProbeRequest {
id: string | number;
@@ -24,45 +20,39 @@ interface ProbeRequest {
episodeIndex?: number;
}
-function isValidSourceConfig(value: unknown): value is VideoSource {
+interface ProbeRequestBody {
+ videos?: unknown;
+ sourceConfigs?: unknown;
+}
+
+function isProbeRequest(value: unknown): value is ProbeRequest {
if (!value || typeof value !== 'object') {
return false;
}
- const source = value as Partial;
- return typeof source.id === 'string' &&
- typeof source.name === 'string' &&
- typeof source.baseUrl === 'string' &&
- typeof source.searchPath === 'string' &&
- typeof source.detailPath === 'string';
-}
-
-function buildSourceConfigMap(rawConfigs: unknown): Map {
- const configs = new Map();
- if (!Array.isArray(rawConfigs)) {
- return configs;
- }
-
- for (const config of rawConfigs) {
- if (isValidSourceConfig(config)) {
- configs.set(config.id, config);
- }
- }
-
- return configs;
+ const request = value as Partial;
+ return (
+ (typeof request.id === 'string' || typeof request.id === 'number') &&
+ typeof request.source === 'string' &&
+ (typeof request.episodeIndex === 'undefined' || typeof request.episodeIndex === 'number')
+ );
}
async function fetchManifestText(url: string, timeoutMs: number): Promise {
- const response = await fetchWithTimeout(url, {
- headers: { 'User-Agent': 'Mozilla/5.0' },
- }, timeoutMs);
+ const response = await fetchWithTimeout(
+ url,
+ {
+ headers: { 'User-Agent': 'Mozilla/5.0' },
+ },
+ timeoutMs,
+ );
return response.text();
}
async function probeManifestResolution(
targetUrl: string,
m3u8Content: string,
- detailHint: ResolutionProbeLabel | null
+ detailHint: ResolutionProbeLabel | null,
): Promise<{ resolution: ResolutionProbeLabel | null; origin: 'manifest' | 'hint' }> {
const directResolution = parseResolutionFromManifest(m3u8Content, targetUrl);
if (directResolution) {
@@ -94,23 +84,26 @@ async function probeManifestResolution(
};
}
-async function probeOne(video: ProbeRequest, providedConfigs: Map): Promise<{
- id: string | number;
- source: string;
- episodeIndex?: number;
- resolution: ResolutionProbeLabel | null;
- resolutionOrigin: 'manifest' | 'hint';
-}> {
+async function resolveSourceConfig(sourceId: string, providedConfigs: Map): Promise {
+ const providedSource = providedConfigs.get(sourceId);
+ if (providedSource) {
+ return providedSource;
+ }
+
+ const builtInSource = getSourceById(sourceId);
+ return builtInSource ? normalizeSourceConfig(builtInSource) : null;
+}
+
+async function probeOne(video: ProbeRequest, providedConfigs: Map) {
try {
- const sourceConfig = providedConfigs.get(video.source) || getSourceById(video.source);
+ const sourceConfig = await resolveSourceConfig(video.source, providedConfigs);
if (!sourceConfig) {
- return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null, resolutionOrigin: 'manifest' };
+ return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null, resolutionOrigin: 'manifest' as const };
}
- // 1. Get detail to find first episode URL
const detail = await getVideoDetail(video.id, sourceConfig);
if (!detail.episodes || detail.episodes.length === 0) {
- return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null, resolutionOrigin: 'manifest' };
+ return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null, resolutionOrigin: 'manifest' as const };
}
const episodeIndex = typeof video.episodeIndex === 'number'
@@ -118,45 +111,47 @@ async function probeOne(video: ProbeRequest, providedConfigs: Map {
@@ -177,17 +171,22 @@ export async function POST(request: NextRequest) {
const current = batch[index++];
try {
const result = await probeOne(current, sourceConfigs);
- const line = `data: ${JSON.stringify(result)}\n\n`;
- controller.enqueue(encoder.encode(line));
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(result)}\n\n`));
} catch {
- const fallback = { id: current.id, source: current.source, resolution: null, resolutionOrigin: 'manifest' };
+ const fallback = {
+ id: current.id,
+ source: current.source,
+ resolution: null,
+ resolutionOrigin: 'manifest',
+ };
controller.enqueue(encoder.encode(`data: ${JSON.stringify(fallback)}\n\n`));
}
}
}
- const workers = Array.from({ length: Math.min(CONCURRENCY, batch.length) }, () => processNext());
- await Promise.all(workers);
+ await Promise.all(
+ Array.from({ length: Math.min(concurrency, batch.length) }, () => processNext()),
+ );
controller.enqueue(encoder.encode('data: {"done":true}\n\n'));
controller.close();
},
@@ -197,7 +196,7 @@ export async function POST(request: NextRequest) {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
- 'Connection': 'keep-alive',
+ Connection: 'keep-alive',
},
});
} catch {
diff --git a/app/api/proxy/route.ts b/app/api/proxy/route.ts
index 4f7975c..cf43da4 100644
--- a/app/api/proxy/route.ts
+++ b/app/api/proxy/route.ts
@@ -1,142 +1,128 @@
import { NextRequest, NextResponse } from 'next/server';
import { processM3u8Content } from '@/lib/utils/proxy-utils';
import { fetchWithRetry } from '@/lib/utils/fetch-with-retry';
+import { requireRelayAccess, buildSameOriginOptionsResponse } from '@/lib/server/api-access';
+import { OutboundPolicyError, getRelayForwardHeaders } from '@/lib/server/outbound-policy';
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
-// Disable SSL verification for video sources with invalid certificates
-// Note: This is not supported in Cloudflare Workers/Edge Runtime.
-// process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
+function buildPassThroughHeaders(response: Response): Headers {
+ const headers = new Headers();
+
+ response.headers.forEach((value, key) => {
+ const lowerKey = key.toLowerCase();
+ if (
+ [
+ 'access-control-allow-origin',
+ 'access-control-allow-methods',
+ 'access-control-allow-headers',
+ 'content-encoding',
+ 'content-length',
+ 'set-cookie',
+ 'transfer-encoding',
+ ].includes(lowerKey)
+ ) {
+ return;
+ }
+
+ headers.set(key, value);
+ });
+
+ headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
+ return headers;
+}
export async function GET(request: NextRequest) {
- const runtimeFeatures = getRuntimeFeatures();
+ const runtimeFeatures = getRuntimeFeatures();
- if (!runtimeFeatures.mediaProxyEnabled) {
- return NextResponse.json(
- {
- error: 'External media proxy is disabled on this deployment',
- message: runtimeFeatures.restrictionSummary,
- },
- { status: 403 }
- );
+ if (!runtimeFeatures.mediaProxyEnabled) {
+ return NextResponse.json(
+ {
+ error: 'External media proxy is disabled on this deployment',
+ message: runtimeFeatures.restrictionSummary,
+ },
+ { status: 403 },
+ );
+ }
+
+ const access = await requireRelayAccess(request);
+ if (access.error) {
+ return access.error;
+ }
+
+ const url = request.nextUrl.searchParams.get('url');
+ if (!url) {
+ return new NextResponse('Missing URL parameter', { status: 400 });
+ }
+
+ try {
+ const response = await fetchWithRetry({
+ url,
+ headers: Object.fromEntries(getRelayForwardHeaders(request)),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ return new NextResponse(errorText || `Upstream error: ${response.status}`, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: {
+ 'Content-Type': response.headers.get('Content-Type') || 'text/plain',
+ },
+ });
}
- const url = request.nextUrl.searchParams.get('url');
+ const contentType = response.headers.get('Content-Type') || '';
+ const isM3u8ByHeader = contentType.includes('application/vnd.apple.mpegurl') ||
+ contentType.includes('application/x-mpegurl') ||
+ url.endsWith('.m3u8');
- if (!url) {
- return new NextResponse('Missing URL parameter', { status: 400 });
+ if (isM3u8ByHeader || url.includes('.m3u8')) {
+ const text = await response.text();
+
+ if (text.trim().startsWith('#EXTM3U') || text.trim().startsWith('#EXT-X-')) {
+ const modifiedText = await processM3u8Content(text, url, request.nextUrl.origin);
+ return new NextResponse(modifiedText, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: {
+ 'Content-Type': 'application/vnd.apple.mpegurl',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
+ },
+ });
+ }
+
+ return new NextResponse(text, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: {
+ 'Content-Type': contentType || 'text/plain',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
+ },
+ });
}
- try {
- // Extract headers to forward (only essential ones)
- const requestHeaders: Record = {};
- const forwardHeaders = ['cookie', 'range'];
+ return new NextResponse(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: buildPassThroughHeaders(response),
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ const status = error instanceof OutboundPolicyError ? error.status : 500;
- forwardHeaders.forEach(key => {
- const value = request.headers.get(key);
- if (value) requestHeaders[key] = value;
- });
-
- const response = await fetchWithRetry({ url, request, headers: requestHeaders });
-
- // If upstream returned an error, pass it through with CORS headers
- if (!response.ok) {
- const errorText = await response.text();
- return new NextResponse(errorText || `Upstream error: ${response.status}`, {
- status: response.status,
- statusText: response.statusText,
- headers: {
- 'Content-Type': response.headers.get('Content-Type') || 'text/plain',
- 'Access-Control-Allow-Origin': '*',
- },
- });
- }
-
- const contentType = response.headers.get('Content-Type');
-
- // Better M3U8 detection: check both content-type and actual content
- const isM3u8ByHeader = contentType &&
- (contentType.includes('application/vnd.apple.mpegurl') ||
- contentType.includes('application/x-mpegurl')) ||
- url.endsWith('.m3u8');
-
- // For potential M3U8 files, check content
- if (isM3u8ByHeader || url.includes('.m3u8')) {
- const text = await response.text();
-
- // Verify it's actually M3U8 content (starts with #EXTM3U or #EXT-X-)
- if (text.trim().startsWith('#EXTM3U') || text.trim().startsWith('#EXT-X-')) {
- const modifiedText = await processM3u8Content(text, url, request.nextUrl.origin);
-
- return new NextResponse(modifiedText, {
- status: response.status,
- statusText: response.statusText,
- headers: {
- 'Content-Type': 'application/vnd.apple.mpegurl',
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
- },
- });
- }
-
- // Not M3U8 content, return as-is
- return new NextResponse(text, {
- status: response.status,
- statusText: response.statusText,
- headers: {
- 'Content-Type': contentType || 'text/plain',
- 'Access-Control-Allow-Origin': '*',
- },
- });
- }
-
- // For non-m3u8 content
- const headers = new Headers();
- response.headers.forEach((value, key) => {
- const lowerKey = key.toLowerCase();
- if (!['content-encoding', 'content-length', 'transfer-encoding'].includes(lowerKey)) {
- headers.set(key, value);
- }
- });
-
- headers.set('Access-Control-Allow-Origin', '*');
- headers.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
- headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
- headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
-
- return new NextResponse(response.body, {
- status: response.status,
- statusText: response.statusText,
- headers: headers,
- });
- } catch (error) {
- console.error('Proxy error:', error);
- return new NextResponse(
- JSON.stringify({
- error: 'Proxy request failed',
- message: error instanceof Error ? error.message : 'Unknown error',
- url: url
- }),
- {
- status: 500,
- headers: {
- 'Content-Type': 'application/json',
- 'Access-Control-Allow-Origin': '*',
- }
- }
- );
- }
+ return NextResponse.json(
+ {
+ error: 'Proxy request failed',
+ message,
+ url,
+ },
+ { status },
+ );
+ }
}
export async function OPTIONS(request: NextRequest) {
- return new NextResponse(null, {
- status: 204,
- headers: {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
- },
- });
+ return buildSameOriginOptionsResponse(request, 'GET, OPTIONS');
}
diff --git a/app/api/search-parallel/route.ts b/app/api/search-parallel/route.ts
index 2e8a58d..df3227b 100644
--- a/app/api/search-parallel/route.ts
+++ b/app/api/search-parallel/route.ts
@@ -1,200 +1,185 @@
-/**
- * Parallel Streaming Search API Route
- * Searches all sources in parallel and streams results immediately as they arrive.
- * Supports abort via request.signal when clients disconnect.
- * Caps results per source and total to prevent OOM.
- */
-
import { NextRequest } from 'next/server';
import { searchVideos } from '@/lib/api/client';
import { getSourceName } from '@/lib/utils/source-names';
import { traditionalToSimplified } from '@/lib/utils/chinese-convert';
+import { requireAuthenticatedRequestIfConfigured } from '@/lib/server/api-access';
+import { normalizeSourceConfigList } from '@/lib/server/source-validation';
+import type { VideoItem, VideoSource } from '@/lib/types';
-export const runtime = 'edge';
+export const runtime = 'nodejs';
const MAX_TOTAL_VIDEOS = 2000;
const MAX_PAGES_PER_SOURCE = 3;
const PER_SOURCE_TIMEOUT_MS = 20000;
+interface SearchRequestBody {
+ query?: unknown;
+ sources?: unknown;
+}
+
+function withPresentationFields(videos: VideoItem[], source: VideoSource, latency: number) {
+ return videos.map((video) => ({
+ ...video,
+ sourceDisplayName: getSourceName(source.id),
+ latency,
+ }));
+}
+
export async function POST(request: NextRequest) {
+ const access = await requireAuthenticatedRequestIfConfigured(request);
+ if (access.error) {
+ return access.error;
+ }
+
+ const body = (await request.json()) as SearchRequestBody;
+ const query = typeof body.query === 'string' ? body.query.trim() : '';
+ const sources = await normalizeSourceConfigList(body.sources, 50);
+
+ if (!query) {
+ return Response.json({ error: 'Invalid query' }, { status: 400 });
+ }
+
+ if (sources.length === 0) {
+ return Response.json({ error: 'No valid sources provided' }, { status: 400 });
+ }
+
+ const normalizedQuery = traditionalToSimplified(query);
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
- // Use the request signal for abort detection
const signal = request.signal;
const safeSend = (data: object) => {
- if (signal.aborted) return;
+ if (signal.aborted) {
+ return;
+ }
+
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch {
- // Controller may be closed
+ // Ignore closed controller errors.
}
};
- try {
- const body = await request.json();
- const { query, sources: sourceConfigs } = body;
+ safeSend({ type: 'start', totalSources: sources.length });
- if (!query || typeof query !== 'string' || query.trim().length === 0) {
- safeSend({ type: 'error', message: 'Invalid query' });
- controller.close();
+ let completedSources = 0;
+ let totalVideosFound = 0;
+
+ const searchPromises = sources.map(async (source) => {
+ if (signal.aborted) {
return;
}
- const normalizedQuery = traditionalToSimplified(query.trim());
- const sources = Array.isArray(sourceConfigs) && sourceConfigs.length > 0
- ? sourceConfigs
- : [];
+ const startTime = performance.now();
+ const sourceController = new AbortController();
+ const sourceTimeout = setTimeout(() => sourceController.abort(), PER_SOURCE_TIMEOUT_MS);
+ const onRequestAbort = () => sourceController.abort();
+ signal.addEventListener('abort', onRequestAbort, { once: true });
- if (sources.length === 0) {
- safeSend({ type: 'error', message: 'No valid sources provided' });
- controller.close();
- return;
- }
-
- safeSend({ type: 'start', totalSources: sources.length });
-
- let completedSources = 0;
- let totalVideosFound = 0;
-
- const searchPromises = sources.map(async (source: any) => {
- if (signal.aborted) return;
-
- const startTime = performance.now();
-
- // Per-source timeout via AbortController
- const sourceController = new AbortController();
- const sourceTimeout = setTimeout(
- () => sourceController.abort(),
- PER_SOURCE_TIMEOUT_MS
+ try {
+ const [initialResult] = await searchVideos(
+ normalizedQuery,
+ [source],
+ 1,
+ sourceController.signal,
);
- // Cascade request abort to source controller
- const onRequestAbort = () => sourceController.abort();
- signal.addEventListener('abort', onRequestAbort, { once: true });
+ const latency = Math.round(performance.now() - startTime);
+ const videos = initialResult?.results || [];
+ const pagecount = initialResult?.pagecount ?? 1;
- try {
- const result = await searchVideos(
- normalizedQuery, [source], 1, sourceController.signal
- );
- const endTime = performance.now();
- const latency = Math.round(endTime - startTime);
- const videos = result[0]?.results || [];
- const pagecount = result[0]?.pagecount ?? 1;
-
- completedSources++;
- totalVideosFound += videos.length;
-
- if (videos.length > 0 && !signal.aborted) {
- safeSend({
- type: 'videos',
- videos: videos.map((video: any) => ({
- ...video,
- sourceDisplayName: getSourceName(source.id),
- latency,
- })),
- source: source.id,
- completedSources,
- totalSources: sources.length,
- latency,
- });
- }
+ completedSources += 1;
+ totalVideosFound += videos.length;
+ if (videos.length > 0 && !signal.aborted) {
safeSend({
- type: 'progress',
+ type: 'videos',
+ videos: withPresentationFields(videos, source, latency),
+ source: source.id,
completedSources,
totalSources: sources.length,
- totalVideosFound,
+ latency,
});
+ }
- // Auto-fetch remaining pages (capped)
- if (pagecount > 1 && totalVideosFound < MAX_TOTAL_VIDEOS && !signal.aborted) {
- const maxPages = Math.min(pagecount, MAX_PAGES_PER_SOURCE);
- const remainingPages = Array.from(
- { length: maxPages - 1 }, (_, i) => i + 2
- );
+ safeSend({
+ type: 'progress',
+ completedSources,
+ totalSources: sources.length,
+ totalVideosFound,
+ });
- for (const pg of remainingPages) {
- if (signal.aborted || totalVideosFound >= MAX_TOTAL_VIDEOS) break;
+ if (pagecount > 1 && totalVideosFound < MAX_TOTAL_VIDEOS && !signal.aborted) {
+ const maxPages = Math.min(pagecount, MAX_PAGES_PER_SOURCE);
- try {
- const pageResult = await searchVideos(
- normalizedQuery, [source], pg, sourceController.signal
- );
- const pageVideos = pageResult[0]?.results || [];
- totalVideosFound += pageVideos.length;
+ for (let page = 2; page <= maxPages; page += 1) {
+ if (signal.aborted || totalVideosFound >= MAX_TOTAL_VIDEOS) {
+ break;
+ }
- if (pageVideos.length > 0 && !signal.aborted) {
- safeSend({
- type: 'videos',
- videos: pageVideos.map((video: any) => ({
- ...video,
- sourceDisplayName: getSourceName(source.id),
- latency,
- })),
- source: source.id,
- completedSources,
- totalSources: sources.length,
- latency,
- });
- }
+ try {
+ const [pageResult] = await searchVideos(
+ normalizedQuery,
+ [source],
+ page,
+ sourceController.signal,
+ );
+ const pageVideos = pageResult?.results || [];
+ totalVideosFound += pageVideos.length;
+ if (pageVideos.length > 0 && !signal.aborted) {
safeSend({
- type: 'progress',
+ type: 'videos',
+ videos: withPresentationFields(pageVideos, source, latency),
+ source: source.id,
completedSources,
totalSources: sources.length,
- totalVideosFound,
+ latency,
});
- } catch {
- // Page fetch failed, continue
}
+
+ safeSend({
+ type: 'progress',
+ completedSources,
+ totalSources: sources.length,
+ totalVideosFound,
+ });
+ } catch {
+ // Ignore failed page fetches and continue.
}
}
- } catch (error) {
- const endTime = performance.now();
- const latency = Math.round(endTime - startTime);
- console.error(
- `[Search] Source ${source.id} failed after ${latency}ms:`,
- error
- );
- completedSources++;
-
- safeSend({
- type: 'progress',
- completedSources,
- totalSources: sources.length,
- totalVideosFound,
- });
- } finally {
- clearTimeout(sourceTimeout);
- signal.removeEventListener('abort', onRequestAbort);
}
- });
+ } catch (error) {
+ const latency = Math.round(performance.now() - startTime);
+ console.error(`[Search] Source ${source.id} failed after ${latency}ms:`, error);
+ completedSources += 1;
- await Promise.all(searchPromises);
-
- if (!signal.aborted) {
safeSend({
- type: 'complete',
- totalVideosFound,
+ type: 'progress',
+ completedSources,
totalSources: sources.length,
- maxPageCount: MAX_PAGES_PER_SOURCE,
+ totalVideosFound,
});
+ } finally {
+ clearTimeout(sourceTimeout);
+ signal.removeEventListener('abort', onRequestAbort);
}
+ });
- controller.close();
- } catch (error) {
- if (!signal.aborted) {
- console.error('Search error:', error);
- safeSend({
- type: 'error',
- message: error instanceof Error ? error.message : 'Unknown error',
- });
- }
- controller.close();
+ await Promise.all(searchPromises);
+
+ if (!signal.aborted) {
+ safeSend({
+ type: 'complete',
+ totalVideosFound,
+ totalSources: sources.length,
+ maxPageCount: MAX_PAGES_PER_SOURCE,
+ });
}
+
+ controller.close();
},
});
@@ -202,7 +187,7 @@ export async function POST(request: NextRequest) {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
- 'Connection': 'keep-alive',
+ Connection: 'keep-alive',
},
});
}
diff --git a/app/api/user/config/route.ts b/app/api/user/config/route.ts
index bc0f4b4..27bd510 100644
--- a/app/api/user/config/route.ts
+++ b/app/api/user/config/route.ts
@@ -5,13 +5,11 @@
* so they persist across browsers, devices, and PWA installs.
*/
-import { Redis } from '@upstash/redis';
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from '@/lib/server/auth';
+import { getRedisClient } from '@/lib/server/redis-client';
-export const runtime = 'edge';
-
-const redis = Redis.fromEnv();
+export const runtime = 'nodejs';
function redisKey(profileId: string): string {
const safe = profileId.replace(/[^a-zA-Z0-9_-]/g, '');
@@ -26,6 +24,11 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
}
+ const redis = getRedisClient();
+ if (!redis) {
+ return NextResponse.json({ success: true, data: null, synced: false });
+ }
+
try {
const data = await redis.get(redisKey(profileId));
return NextResponse.json({ success: true, data: data || null });
@@ -46,6 +49,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
}
+ const redis = getRedisClient();
+ if (!redis) {
+ return NextResponse.json({ success: true, synced: false });
+ }
+
try {
const body = await request.json();
const key = redisKey(profileId);
@@ -56,7 +64,7 @@ export async function POST(request: NextRequest) {
await redis.set(key, merged);
- return NextResponse.json({ success: true });
+ return NextResponse.json({ success: true, synced: true });
} catch (error) {
console.error('Config write error:', error);
return NextResponse.json(
diff --git a/app/api/user/sync/route.ts b/app/api/user/sync/route.ts
index 716ea1f..3d5e8b8 100644
--- a/app/api/user/sync/route.ts
+++ b/app/api/user/sync/route.ts
@@ -1,11 +1,9 @@
-import { Redis } from '@upstash/redis';
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from '@/lib/server/auth';
+import { getRedisClient } from '@/lib/server/redis-client';
// 确保这行代码在整个文件中只出现一次
-export const runtime = 'edge';
-
-const redis = Redis.fromEnv();
+export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const session = await getServerSession(request);
@@ -15,6 +13,15 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
}
+ const redis = getRedisClient();
+ if (!redis) {
+ return NextResponse.json({
+ success: true,
+ data: { history: [], favorites: [] },
+ synced: false,
+ });
+ }
+
try {
const data = await redis.get(`user:sync:${profileId}`);
return NextResponse.json({
@@ -35,13 +42,18 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
}
+ const redis = getRedisClient();
+ if (!redis) {
+ return NextResponse.json({ success: true, synced: false });
+ }
+
try {
const body = await request.json();
const { history, favorites } = body;
await redis.set(`user:sync:${profileId}`, { history, favorites });
- return NextResponse.json({ success: true });
+ return NextResponse.json({ success: true, synced: true });
} catch (error) {
console.error('Redis Set Error:', error);
return NextResponse.json({ error: 'Failed to save sync data' }, { status: 500 });
diff --git a/app/globals.css b/app/globals.css
index 1e19277..b808212 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -27,3 +27,18 @@
.no-spinner {
-moz-appearance: textfield;
}
+
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index f72b002..e1c5f46 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -21,9 +21,6 @@ import { resolveSiteIconSrc } from '@/lib/server/site-icon';
import fs from 'fs';
import path from 'path';
-const DEFAULT_VIDEOTOGETHER_SCRIPT_URL =
- 'https://fastly.jsdelivr.net/gh/VideoTogether/VideoTogether@latest/release/extension.website.user.js';
-
// Server Component specifically for reading env/file (async for best practices)
async function AdKeywordsWrapper() {
let keywords: string[] = [];
@@ -82,10 +79,9 @@ export default async function RootLayout({
}>) {
const siteIconSrc = await resolveSiteIconSrc();
const runtimeFeatures = getRuntimeFeatures();
- const videoTogetherScriptUrl =
- process.env.VIDEOTOGETHER_SCRIPT_URL?.trim() || DEFAULT_VIDEOTOGETHER_SCRIPT_URL;
+ const videoTogetherScriptUrl = process.env.VIDEOTOGETHER_SCRIPT_URL?.trim() || '';
const videoTogetherSettingUrl = process.env.VIDEOTOGETHER_SETTING_URL?.trim();
- const videoTogetherEnvEnabled = process.env.VIDEOTOGETHER_ENABLED !== 'false';
+ const videoTogetherEnvEnabled = process.env.VIDEOTOGETHER_ENABLED === 'true';
return (
diff --git a/app/page.tsx b/app/page.tsx
index 12bdca5..70fb8ff 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { Suspense, useMemo } from 'react';
+import { Suspense, useMemo, useSyncExternalStore } from 'react';
import { SearchForm } from '@/components/search/SearchForm';
import { NoResults } from '@/components/search/NoResults';
import { PopularFeatures } from '@/components/home/PopularFeatures';
@@ -9,6 +9,31 @@ import { Navbar } from '@/components/layout/Navbar';
import { SearchResults } from '@/components/home/SearchResults';
import { useHomePage } from '@/lib/hooks/useHomePage';
import { useLatencyPing } from '@/lib/hooks/useLatencyPing';
+import { settingsStore } from '@/lib/store/settings-store';
+import { userSourcesStore } from '@/lib/store/user-sources-store';
+import { buildLatencySourceUrls } from '@/lib/utils/latency-source-map';
+
+function subscribeToConfiguredSources(listener: () => void) {
+ const unsubscribeSettings = settingsStore.subscribe(listener);
+ const unsubscribeUserSources = userSourcesStore.subscribe(listener);
+
+ return () => {
+ unsubscribeSettings();
+ unsubscribeUserSources();
+ };
+}
+
+function getConfiguredSourcesSnapshot() {
+ const settings = settingsStore.getSettings();
+ const configuredSources = [...settings.sources, ...userSourcesStore.getSources()]
+ .filter((source) => source.enabled !== false)
+ .map((source) => ({
+ id: source.id,
+ baseUrl: source.baseUrl,
+ }));
+
+ return JSON.stringify(configuredSources);
+}
function HomePage() {
const {
@@ -24,12 +49,21 @@ function HomePage() {
handleCancelSearch,
} = useHomePage();
- // Real-time latency pinging
- const sourceUrls = useMemo(() =>
- availableSources.map(s => ({ id: s.id, baseUrl: s.id })), // Using id as baseUrl if not available elsewhere
- [availableSources]
+ const configuredSourcesSnapshot = useSyncExternalStore(
+ subscribeToConfiguredSources,
+ getConfiguredSourcesSnapshot,
+ () => '[]',
);
+ const sourceUrls = useMemo(() => {
+ const configuredSources = JSON.parse(configuredSourcesSnapshot) as Array<{
+ id: string;
+ baseUrl: string;
+ }>;
+
+ return buildLatencySourceUrls(availableSources, configuredSources);
+ }, [availableSources, configuredSourcesSnapshot]);
+
const { latencies } = useLatencyPing({
sourceUrls,
enabled: hasSearched && results.length > 0,
@@ -37,11 +71,9 @@ function HomePage() {
return (
- {/* Glass Navbar */}
- {/* Search Form - Separate from navbar */}
-
@@ -57,9 +89,7 @@ function HomePage() {
/>
- {/* Main Content */}
-
- {/* Results Section */}
+
{(results.length >= 1 || (!loading && results.length > 0)) && (
)}
- {/* Popular Features - Homepage */}
{!loading && !hasSearched && (
- <>
-
- >
+
)}
- {/* No Results */}
{!loading && hasSearched && results.length === 0 && (
)}
- {/* Favorites Sidebar - Left */}
);
diff --git a/app/player/page.tsx b/app/player/page.tsx
index 845688c..c423ca1 100644
--- a/app/player/page.tsx
+++ b/app/player/page.tsx
@@ -146,11 +146,11 @@ function PlayerContent() {
let sources: SourceInfo[] = [];
if (gsKey) {
- const cached = retrieveGroupedSources(gsKey);
+ const cached = retrieveGroupedSources(gsKey);
if (cached) sources = cached;
} else if (groupedSourcesParam) {
try {
- sources = JSON.parse(groupedSourcesParam);
+ sources = JSON.parse(groupedSourcesParam) as SourceInfo[];
} catch {
sources = [];
}
@@ -200,7 +200,7 @@ function PlayerContent() {
// Check if existing grouped sources already have full info (pic + latency)
let existingSources: SourceInfo[] = [];
if (gsKey) {
- const cached = retrieveGroupedSources(gsKey);
+ const cached = retrieveGroupedSources(gsKey);
if (cached) existingSources = cached;
} else if (groupedSourcesParam) {
try { existingSources = JSON.parse(groupedSourcesParam); } catch {}
diff --git a/app/settings/hooks/useSettingsPage.ts b/app/settings/hooks/useSettingsPage.ts
index 5368be9..771bb4d 100644
--- a/app/settings/hooks/useSettingsPage.ts
+++ b/app/settings/hooks/useSettingsPage.ts
@@ -16,6 +16,7 @@ import {
parseSourcesFromJson,
fetchSourcesFromUrl
} from '@/lib/utils/source-import-utils';
+import { clearSession } from '@/lib/store/auth-store';
export function useSettingsPage() {
const [sources, setSources] = useState([]);
@@ -360,7 +361,14 @@ export function useSettingsPage() {
setIsRestoreDefaultsDialogOpen(false);
};
- const handleResetAll = () => {
+ const handleResetAll = async () => {
+ try {
+ await fetch('/api/auth/session', { method: 'DELETE' });
+ } catch {
+ // Clear local state even if the server-side logout request fails.
+ }
+
+ clearSession();
settingsStore.resetToDefaults();
setIsResetDialogOpen(false);
window.location.reload();
diff --git a/app/settings/page.tsx b/app/settings/page.tsx
index d5aba8e..81af637 100644
--- a/app/settings/page.tsx
+++ b/app/settings/page.tsx
@@ -201,7 +201,7 @@ export default function SettingsPage() {
WKWebView {
- let config = WKWebViewConfiguration()
- config.allowsInlineMediaPlayback = true
- config.mediaTypesRequiringUserActionForPlayback = []
-
- let preferences = WKWebpagePreferences()
- preferences.allowsContentJavaScript = true
- config.defaultWebpagePreferences = preferences
-
- let webView = WKWebView(frame: .zero, configuration: config)
- webView.navigationDelegate = context.coordinator
- webView.isOpaque = false
- webView.backgroundColor = .black
- webView.scrollView.backgroundColor = .black
-
- // Allow back navigation via Menu button
- webView.allowsBackForwardNavigationGestures = true
-
- webView.load(URLRequest(url: url))
- return webView
- }
-
- func updateUIView(_ uiView: WKWebView, context: Context) {}
-
- func makeCoordinator() -> Coordinator {
- Coordinator()
- }
-
- class Coordinator: NSObject, WKNavigationDelegate {
- func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
- // Inject JS to signal TV mode
- webView.evaluateJavaScript("""
- document.body.classList.add('tv-mode');
- """)
- }
- }
-}
diff --git a/apple-tv/KVideoTV/KVideoTV/KVideoTVApp.swift b/apple-tv/KVideoTV/KVideoTV/KVideoTVApp.swift
deleted file mode 100644
index e3ac681..0000000
--- a/apple-tv/KVideoTV/KVideoTV/KVideoTVApp.swift
+++ /dev/null
@@ -1,11 +0,0 @@
-import SwiftUI
-
-@main
-struct KVideoTVApp: App {
- var body: some Scene {
- WindowGroup {
- ContentView()
- .ignoresSafeArea()
- }
- }
-}
diff --git a/apple-tv/README.md b/apple-tv/README.md
index ad939fd..cf5728c 100644
--- a/apple-tv/README.md
+++ b/apple-tv/README.md
@@ -1,40 +1,7 @@
-# KVideo Apple TV App
+# Apple TV
-A lightweight tvOS WebView wrapper for KVideo.
+Apple TV is not supported in this repository.
-## Requirements
+The previous tvOS sample WebView shell has been removed because it was not a complete or supported product path.
-- macOS with Xcode 15+
-- Apple Developer account (free is fine for personal device sideloading)
-
-## Setup
-
-1. Open Xcode → **File → New → Project**
-2. Select **tvOS → App**, click Next
-3. Set:
- - Product Name: `KVideoTV`
- - Interface: **SwiftUI**
- - Language: **Swift**
-4. Choose a save location, click Create
-5. **Replace** the generated `KVideoTVApp.swift` with the one in this directory
-6. **Replace** the generated `ContentView.swift` with the one in this directory
-7. In `ContentView.swift`, change `kvideoURL` to your deployed KVideo instance URL:
- ```swift
- let kvideoURL = "https://your-kvideo-instance.com"
- ```
-8. Set deployment target to **tvOS 16.0** or later
-9. Connect your Apple TV (or use the tvOS Simulator)
-10. Build and run (Cmd+R)
-
-## How it works
-
-- The app is a fullscreen `WKWebView` that loads your KVideo URL
-- On page load, it injects `tv-mode` CSS class to activate TV-optimized styles
-- The Apple TV remote's swipe gestures map to scroll, and click maps to tap/focus
-- Back navigation uses `allowsBackForwardNavigationGestures`
-
-## Notes
-
-- Apple TV apps **cannot** be published to the App Store if they're just web wrappers
-- This is intended for personal sideloading only
-- For AirPlay: you can also just AirPlay from iPhone/iPad/Mac without needing this app
+If you need TV playback on Apple hardware today, use AirPlay or another browser/device casting path from an already supported KVideo deployment.
diff --git a/components/AutoSync.tsx b/components/AutoSync.tsx
index e2bb6e0..fe8f41b 100644
--- a/components/AutoSync.tsx
+++ b/components/AutoSync.tsx
@@ -7,12 +7,17 @@ import { useCloudSync } from '@/lib/hooks/useCloudSync';
import { useConfigSync } from '@/lib/hooks/useConfigSync';
import { getSession } from '@/lib/store/auth-store';
-// 防抖函数,防止频繁请求
-function debounce(fn: Function, delay: number) {
- let timeoutId: NodeJS.Timeout;
- return (...args: any[]) => {
- clearTimeout(timeoutId);
- timeoutId = setTimeout(() => fn(...args), delay);
+type VoidCallback = () => void;
+
+function debounce(fn: VoidCallback, delay: number): VoidCallback {
+ let timeoutId: NodeJS.Timeout | null = null;
+
+ return () => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+
+ timeoutId = setTimeout(() => fn(), delay);
};
}
diff --git a/components/PasswordGate.tsx b/components/PasswordGate.tsx
index 0bfc4d9..924228c 100644
--- a/components/PasswordGate.tsx
+++ b/components/PasswordGate.tsx
@@ -117,6 +117,7 @@ export function PasswordGate({
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
+ const [authError, setAuthError] = useState('');
const [isClient, setIsClient] = useState(false);
const [persistSession, setPersistSession] = useState(true);
const [isValidating, setIsValidating] = useState(false);
@@ -145,6 +146,7 @@ export function PasswordGate({
setPersistSession(config.persistSession);
setLoginMode(config.loginMode || 'none');
+ setAuthError(typeof config.authError === 'string' ? config.authError : '');
applyRuntimeConfig(config);
if (sessionStatus.authenticated && sessionStatus.session) {
@@ -171,6 +173,13 @@ export function PasswordGate({
return;
}
+ if (config.authError) {
+ setError(config.authError);
+ setIsLocked(true);
+ setIsClient(true);
+ return;
+ }
+
setIsLocked(!!config.hasAuth);
setIsClient(true);
} catch {
@@ -189,8 +198,14 @@ export function PasswordGate({
const handleUnlock = async (event: React.FormEvent) => {
event.preventDefault();
+ if (authError) {
+ setError(authError);
+ return;
+ }
+
setIsValidating(true);
setError('');
+ let nextError = '';
try {
const response = await fetch('/api/auth', {
@@ -208,11 +223,15 @@ export function PasswordGate({
window.location.reload();
return;
}
+
+ if (typeof data.message === 'string' && data.message) {
+ nextError = data.message;
+ }
} catch {
// Ignore network errors and show the same message as invalid credentials.
}
- setError(loginMode === 'managed' ? '用户名或密码错误' : '密码错误');
+ setError(nextError || (loginMode === 'managed' ? '用户名或密码错误' : '密码错误'));
setIsValidating(false);
const form = document.getElementById('password-form');
form?.classList.add('animate-shake');
@@ -256,7 +275,9 @@ export function PasswordGate({
value={username}
onChange={(event) => {
setUsername(event.target.value);
- setError('');
+ if (!authError) {
+ setError('');
+ }
}}
placeholder="输入用户名..."
className="w-full pl-11 pr-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-[var(--text-color)] placeholder-[var(--text-color-secondary)]"
@@ -273,7 +294,9 @@ export function PasswordGate({
value={password}
onChange={(event) => {
setPassword(event.target.value);
- setError('');
+ if (!authError) {
+ setError('');
+ }
}}
placeholder={showManagedFields ? '输入密码...' : '输入密码...'}
className={`w-full px-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border ${error ? 'border-red-500' : 'border-[var(--glass-border)]'} focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-[var(--text-color)] placeholder-[var(--text-color-secondary)]`}
@@ -289,10 +312,10 @@ export function PasswordGate({
diff --git a/components/ThemeProvider.tsx b/components/ThemeProvider.tsx
index 3899718..4b1e8aa 100644
--- a/components/ThemeProvider.tsx
+++ b/components/ThemeProvider.tsx
@@ -1,6 +1,6 @@
'use client';
-import React, { createContext, useContext, useEffect, useState } from 'react';
+import React, { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
type Theme = 'light' | 'dark' | 'system';
@@ -12,73 +12,97 @@ interface ThemeContextType {
const ThemeContext = createContext