21 Commits

Author SHA1 Message Date
katelya 222126e50f feat: 更新版本至 0.6.0-katelya,新增 TVBox 配置功能及优化用户体验 2025-09-03 20:34:52 +08:00
katelya 3783fbdd00 feat: 更新 middleware 匹配规则,添加 TVBox API 路径支持 2025-09-03 20:26:56 +08:00
katelya c4458ae23a feat: 添加 TVBox 配置按钮,支持跳转到配置页面 2025-09-03 20:17:25 +08:00
katelya ac29b75457 feat: 更新 TVBox API 解析逻辑,支持从配置文件异步读取源站列表 2025-09-03 20:05:56 +08:00
katelya 1ca36f7454 feat: Add TVBox configuration support and management interface
- Introduced a new configuration page for TVBox with JSON and Base64 format options.
- Updated API endpoints for TVBox configuration retrieval.
- Enhanced the sidebar navigation to link to the new configuration page.
- Improved error handling and user feedback for configuration copying.
- Added detailed usage instructions and feature descriptions in the documentation.
- Fixed issues related to deployment and static generation.
2025-09-03 19:54:58 +08:00
katelya 2294f1b066 feat: 添加 TVBox 配置接口,支持视频源导入及解析功能 2025-09-03 19:32:24 +08:00
katelya b4ebe89292 feat: 更新 service worker 逻辑,优化模块注册和缓存管理 2025-09-03 19:20:20 +08:00
katelya 54b4388685 feat: 更新视频源配置,禁止删除示例源并优化批量删除提示 2025-09-03 14:45:23 +08:00
katelya 66a6fd0392 feat: 添加对 Kvrocks 的支持,包括配置文件、环境变量示例及数据库操作实现 2025-09-03 14:34:45 +08:00
katelya d563ca165d feat: 优化视频源批量删除功能,支持一键删除所有视频源并改善用户提示 2025-09-03 14:15:41 +08:00
katelya cdd60356eb feat: 添加视频源批量操作功能,包括批量选择和删除 2025-09-03 14:06:42 +08:00
katelya d8e8510e5e feat: 添加视频源配置管理功能,包括导入和导出配置的支持 2025-09-02 17:56:48 +08:00
katelya 1e3467fff2 feat: 添加 CORS 支持,处理预检请求并更新 API 响应头 2025-09-02 17:43:06 +08:00
Katelya c69e9a380f Revise important change notification in README
Updated important change notification to include details about recommended configuration files.
2025-09-02 17:24:10 +08:00
katelya 53ef9281ba feat: 更新 README.md,移除内置视频源并提供用户自定义资源站配置说明 2025-09-02 17:21:47 +08:00
katelya fa958d0987 Refactor service worker and remove test page
- Updated service worker (sw.js) to improve caching strategies and update asset revisions.
- Deleted the test page (page.tsx) as it is no longer needed.
- Refactored EpisodeSelector component to simplify logic and improve performance.
- Added a .dockerignore file to exclude unnecessary files from Docker builds.
2025-09-02 17:08:23 +08:00
katelya f545058bf8 feat: 添加 'use client' 声明到 EpisodeSelector 和 TestPage 组件 2025-09-02 16:40:48 +08:00
katelya aa03a0b932 feat: 添加剧集选择器测试页面,包含响应式布局和调试信息 2025-09-02 16:33:22 +08:00
katelya 5dacbc027d fix: 修改每页显示集数的默认值为 10,更新导航按钮样式以增强可用性 2025-09-02 16:21:18 +08:00
katelya 0b60840097 feat: 添加左右导航按钮和智能响应式布局到集数选择器组件 2025-09-02 16:05:00 +08:00
katelya dd01a91383 fix: 调整跳过控制器中元素的 z-index 以避免重叠问题 2025-09-02 15:44:28 +08:00
35 changed files with 2648 additions and 185 deletions
+16
View File
@@ -0,0 +1,16 @@
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.next
.vercel
.vscode
**/*.backup.tsx
+56
View File
@@ -0,0 +1,56 @@
# KatelyaTV Kvrocks 部署环境变量示例
# 复制此文件为 .env.kvrocks 并修改相应值
# ==================== 数据库配置 ====================
# 存储类型:使用 Kvrocks
NEXT_PUBLIC_STORAGE_TYPE=kvrocks
# Kvrocks 连接配置
KVROCKS_URL=redis://localhost:6666
# KVROCKS_URL=redis://kvrocks:6666 # Docker 部署时使用此配置
KVROCKS_PASSWORD=your_secure_password_here
KVROCKS_DATABASE=0
# ==================== 应用配置 ====================
# NextAuth 配置
NEXTAUTH_SECRET=your_nextauth_secret_here
NEXTAUTH_URL=http://localhost:3000
# 站点配置
NEXT_PUBLIC_SITE_NAME=KatelyaTV
NEXT_PUBLIC_SITE_DESCRIPTION=高性能影视播放平台
# ==================== 部署配置 ====================
# 生产环境配置
NODE_ENV=production
PORT=3000
# Docker 配置
DOCKER_IMAGE_TAG=latest
# ==================== 可选配置 ====================
# Douban API 配置(可选)
DOUBAN_API_KEY=your_douban_api_key
# 图片代理配置(可选)
IMAGE_PROXY_ENABLED=true
# 缓存配置
CACHE_TTL=3600
# ==================== 安全配置 ====================
# CORS 配置
CORS_ORIGIN=*
# Rate Limiting 配置
RATE_LIMIT_MAX=100
RATE_LIMIT_WINDOW=60000
# ==================== 监控配置 ====================
# 健康检查配置
HEALTH_CHECK_ENABLED=true
HEALTH_CHECK_INTERVAL=30
# 日志配置
LOG_LEVEL=info
LOG_FORMAT=json
+55
View File
@@ -2,6 +2,61 @@
本文档记录 KatelyaTV 项目的重要更新和功能变更。
## [0.6.0-katelya] - 2025-09-03
### 🎉 新增功能
- 🖱️ **用户界面优化**
- 在用户菜单中新增"TVBox配置"按钮,提供便捷的配置入口
- 新增电视图标(Tv)标识,界面更加直观
- 优化用户体验,一键访问TVBox配置页面
### 🔧 重要改进
- 🔓 **TVBox API 认证优化**
- **重要变更**TVBox API (`/api/tvbox`) 现已开放无需认证访问
- 解决 TVBox 客户端无法登录的根本问题
- 支持直接在 TVBox 应用中使用配置链接,无需预先登录
- 中间件配置优化,确保其他管理 API 仍受保护
- ☁️ **Cloudflare Pages 部署支持**
- 修复所有 API 路由的 Edge Runtime 配置问题
- 重构文件系统访问逻辑,使用 `getConfig()` 替代 `fs.readFileSync`
- 解决 Cloudflare Pages 部署失败的核心问题
- 确保生产环境部署稳定性
### 🐛 问题修复
- 修复代码导入排序导致的 ESLint 警告
- 解决 TVBox API 认证导致的访问失败问题
- 优化构建过程,减少开发环境警告
### 📱 使用体验
- TVBox 配置链接可直接在客户端使用
- 支持 JSON 和 Base64 两种配置格式
- 完全兼容 TVBox 及其衍生应用
## [0.5.1] - 2025-09-03
### 🎉 新增功能
- 📺 **TVBox 兼容支持**
- 新增 TVBox 配置接口,支持标准 JSON 格式配置
- 提供直观的配置管理界面 (`/config` 页面)
- 支持 JSON 和 Base64 两种配置格式
- 内置视频解析接口,支持多种视频平台
- 完全兼容 TVBox 及其衍生应用
- 自动同步 KatelyaTV 配置的所有视频源
### 🔧 技术改进
- 新增 `/api/tvbox` API 端点,提供 TVBox 标准配置
- 新增 `/api/parse` 视频解析接口
- 新增 TVBox 配置页面组件,支持动态格式切换
- 添加 CORS 跨域支持,确保 TVBox 应用正常访问
- 完善的错误处理和用户提示
- 新增详细的 TVBox 使用文档
### 🐛 问题修复
- 修复 Cloudflare Pages 部署时的 Suspense 边界问题
- 解决 Next.js 静态生成时的 useSearchParams 错误
- 优化构建配置,确保跨平台部署兼容性
## [0.5.0] - 2025-09-02
### 🎉 新增功能
+366 -12
View File
@@ -18,6 +18,8 @@
本项目自「MoonTV」演进而来,为其二创/继承版本,持续维护与改进功能与体验。保留并致谢原作者与社区贡献者;如有授权或版权问题请联系以处理。目标:在原作基础上提供更易部署、更友好、更稳定的体验。
> **🔔 重要变更通知**:应用户社区的宝贵建议,为确保项目的长期稳定运行和合规性,我们已将内置的视频源移除。现在用户需要自行配置资源站以使用本应用的完整功能。我们提供了经过测试的推荐配置文件,让您能够快速上手使用(具体配置文件见 README.md 内容底部)。
## ✨ 功能特性
### 🎬 核心播放功能
@@ -41,6 +43,7 @@
- **☁️ 多平台支持**Vercel、Cloudflare Pages、传统服务器全兼容
- **🔧 灵活配置**:支持自定义资源站、代理设置、主题配置
- **📱 PWA 支持**:可安装为桌面/手机应用,离线缓存
- **📺 TVBox 兼容**:支持 TVBox 配置接口,可导入到各种电视盒子应用
### 🎨 用户体验
@@ -61,18 +64,50 @@
| 代码质量 | ESLint · Prettier · Jest · Husky |
| 部署 | Docker · Vercel · CloudFlare pages |
## 📺 TVBox 兼容功能
KatelyaTV 新增了 TVBox 配置接口,可以将您的视频源导入到各种电视盒子应用中使用:
### ✨ 功能特点
- **🔄 自动同步**:自动同步 KatelyaTV 中配置的所有视频源
- **📋 标准格式**:支持 TVBox 标准 JSON 配置格式
- **🎬 内置解析**:集成多个视频解析接口,支持主流视频平台
- **🌐 跨域支持**:自动处理 CORS 跨域问题
- **📱 多格式**:支持 JSON 和 Base64 两种配置格式
### 🚀 快速使用
1. **访问配置页面**:在 KatelyaTV 中点击侧边栏的"TVBox 配置"或访问 `/config` 页面
2. **选择格式类型**:在页面中选择 JSON 或 Base64 格式
3. **复制配置链接**:点击复制按钮获取配置链接
4. **导入到 TVBox**:在 TVBox 应用中导入配置链接
### 🔗 API 端点
- **JSON 配置**`https://your-domain.com/api/tvbox?format=json`
- **Base64 配置**`https://your-domain.com/api/tvbox?format=base64`
- **视频解析**`https://your-domain.com/api/parse?url={视频地址}`
> 📖 详细使用说明请查看:[TVBox 配置指南](docs/TVBOX.md)
## 🚀 部署教程
> **💡 推荐方案**新手优先选择 **Docker 单容器**(最简单),需要多用户再升级到 **Docker + Redis**
> **💡 推荐方案**
>
> - 🆕 **个人用户**:优先选择 **Docker 单容器**(最简单)
> - 🏠 **家庭/团队**:选择 **Docker + Redis**(功能完整)
> - 🏢 **生产环境**:强烈推荐 **Docker + Kvrocks**(极高可靠性,零数据丢失风险)
### 📋 部署方式对比
| 方式 | 难度 | 成本 | 多用户 | 推荐场景 |
| --------------------- | ------ | -------- | ------ | ------------------- |
| 🐳 **Docker 单容器** | ⭐ | 需服务器 | ❌ | 个人使用,最简单 |
| 🐳 **Docker + Redis** | ⭐⭐ | 需服务器 | ✅ | 家庭/团队,功能完整 |
| ☁️ **Vercel** | ⭐ | 免费 | ❌ | 临时体验,无服务器 |
| 🌐 **Cloudflare** | ⭐⭐⭐ | 免费 | | 技术爱好者 |
| 方式 | 难度 | 成本 | 多用户 | 数据可靠性 | 推荐场景 |
| ----------------------- | ------ | -------- | ------ | ---------- | ------------------- |
| 🐳 **Docker 单容器** | ⭐ | 需服务器 | ❌ | ⭐⭐ | 个人使用,最简单 |
| 🐳 **Docker + Redis** | ⭐⭐ | 需服务器 | ✅ | ⭐⭐⭐ | 家庭/团队,功能完整 |
| 🏪 **Docker + Kvrocks** | ⭐ | 需服务器 | | ⭐⭐⭐⭐⭐ | 生产环境,高可靠性 |
| ☁️ **Vercel** | ⭐ | 免费 | | | 临时体验,无服务器 |
| 🌐 **Cloudflare** | ⭐⭐⭐ | 免费 | ✅ | ⭐⭐⭐ | 技术爱好者 |
---
@@ -114,9 +149,21 @@ docker run -d \
2. 如果是本机安装,访问:`http://localhost:3000`
3. 输入你在第二步设置的密码即可进入
#### 第四步:自定义资源站(可选)
#### 第四步:自定义资源站配置
如果你有自己的资源站配置,可以挂载 `config.json` 文件:
> **📢 重要说明**:为确保项目的长期稳定运行和避免潜在的法律风险,应用户社区的建议,我们已将内置的视频源移除。现在需要用户自行配置视频源以正常使用本应用。
##### 🔗 获取推荐的资源站配置
为了方便用户快速上手,我们提供了一个经过测试的资源站配置文件:
**配置文件下载地址**: [https://www.mediafire.com/file/xl3yo7la2ci378w/config.json/file](https://www.mediafire.com/file/xl3yo7la2ci378w/config.json/file)
##### 📋 配置步骤
1. **下载配置文件**:点击上方链接下载 `config.json` 文件
2. **保存到本地**:将文件保存到服务器的合适位置(如 `/opt/katelyatv/config.json`
3. **挂载配置文件**:按以下命令重新启动容器
```bash
# 先停止并删除旧容器
@@ -135,6 +182,13 @@ docker run -d \
> **路径说明**:把 `/path/to/your/config.json` 替换成你的配置文件完整路径
> **Windows 示例**`-v C:/Users/你的用户名/Desktop/config.json:/app/config.json:ro`
##### 🛡️ 免责声明
- 提供的配置文件仅为方便用户测试和学习使用
- 所有视频源均来源于公开的网络资源,请用户自行判断使用的合法性
- 我们不对任何第三方视频源的内容、质量或合法性负责
- 建议用户仅使用合法、正版的视频源
### 🛠️ 常用管理命令
```bash
@@ -247,6 +301,31 @@ docker compose ps
3. 登录后访问 `http://你的服务器IP:3000/admin` 进入管理后台
4. 在管理后台可以配置资源站、管理用户等
#### 第五步:配置资源站
> **📢 重要提醒**:由于项目长期稳定运行的考虑,应用户建议已移除内置视频源,需要手动配置资源站。
##### 方法一:使用推荐配置文件(推荐)
1. **下载配置文件**[点击下载 config.json](https://www.mediafire.com/file/xl3yo7la2ci378w/config.json/file)
2. **修改 docker-compose.yml**:取消注释 volumes 部分
```yaml
# 将这两行的注释去掉
volumes:
- ./config.json:/app/config.json:ro
```
3. **重启服务**
```bash
docker compose down
docker compose up -d
```
##### 方法二:管理后台配置
1. 登录管理后台:`http://你的服务器IP:3000/admin`
2. 进入"站点配置"页面
3. 手动添加视频源 API 接口
### 🛠️ 管理命令
```bash
@@ -279,7 +358,153 @@ docker run --rm -v katelyatv-redis-data:/data -v $(pwd):/backup alpine tar xzf /
---
## 🎯 方案三:Vercel 部署(免服务器
## 方案三:Docker + Kvrocks(高可靠性推荐
> **适合场景**:生产环境,需要极高的数据可靠性,担心 Redis 数据丢失风险
### 🌟 Kvrocks 优势
- **🛡️ 极高可靠性**:基于 RocksDB,数据持久化到磁盘,几乎零丢失风险
- **⚡ 性能优异**:完全兼容 Redis 协议,性能接近甚至超越 Redis
- **💾 节省内存**:数据存储在磁盘,内存使用量大幅降低
- **🔄 无需 AOF/RDB**RocksDB 天然支持数据持久化,无需额外配置
- **📈 更好扩展性**:支持更大的数据集,不受内存限制
### 🔧 前置要求
- 服务器/NAS/电脑(支持 Docker
- 已安装 Docker 和 Docker Compose
### 📝 详细步骤
#### 第一步:下载配置文件
```bash
# 创建项目目录
mkdir katelyatv-kvrocks && cd katelyatv-kvrocks
# 下载 Kvrocks 部署配置
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/docker-compose.kvrocks.yml
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/.env.kvrocks.example
# 复制环境变量模板
cp .env.kvrocks.example .env
```
#### 第二步:配置环境变量
```bash
# 编辑环境变量文件
nano .env
```
**重要配置项**
```bash
# 存储类型:使用 Kvrocks
NEXT_PUBLIC_STORAGE_TYPE=kvrocks
# Kvrocks 连接配置
KVROCKS_URL=redis://kvrocks:6666
KVROCKS_PASSWORD=your_secure_password_here # 改成你的密码
KVROCKS_DATABASE=0
# NextAuth 配置
NEXTAUTH_SECRET=your_nextauth_secret_here # 改成随机字符串
NEXTAUTH_URL=http://localhost:3000 # 改成你的域名
```
#### 第三步:启动服务
```bash
# 一键启动 KatelyaTV + Kvrocks
docker compose -f docker-compose.kvrocks.yml up -d
# 查看启动状态
docker compose -f docker-compose.kvrocks.yml ps
```
#### 第四步:验证部署
```bash
# 检查 Kvrocks 连接
docker compose -f docker-compose.kvrocks.yml exec kvrocks redis-cli -h localhost -p 6666 ping
# 查看日志
docker compose -f docker-compose.kvrocks.yml logs -f
```
#### 第五步:访问应用
1. 浏览器访问:`http://你的服务器IP:3000`
2. 注册账号开始使用
### 🛠️ 管理命令
```bash
# 停止服务
docker compose -f docker-compose.kvrocks.yml stop
# 重启服务
docker compose -f docker-compose.kvrocks.yml restart
# 查看 Kvrocks 状态
docker compose -f docker-compose.kvrocks.yml exec kvrocks redis-cli -h localhost -p 6666 info
# 备份数据
docker compose -f docker-compose.kvrocks.yml exec kvrocks redis-cli -h localhost -p 6666 BGSAVE
# 数据量统计
docker compose -f docker-compose.kvrocks.yml exec kvrocks redis-cli -h localhost -p 6666 dbsize
```
### 🔒 数据备份与恢复
#### 备份数据
```bash
# 自动备份(推荐)
docker run --rm \
-v katelyatv-kvrocks_kvrocks-data:/data \
-v $(pwd):/backup \
alpine tar czf /backup/kvrocks-backup-$(date +%Y%m%d).tar.gz /data
# 手动触发 RocksDB 备份
docker compose -f docker-compose.kvrocks.yml exec kvrocks redis-cli -h localhost -p 6666 BGSAVE
```
#### 恢复数据
```bash
# 先停止服务
docker compose -f docker-compose.kvrocks.yml down
# 恢复数据
docker run --rm \
-v katelyatv-kvrocks_kvrocks-data:/data \
-v $(pwd):/backup \
alpine tar xzf /backup/kvrocks-backup-20241201.tar.gz -C /
# 重新启动
docker compose -f docker-compose.kvrocks.yml up -d
```
### 🚀 性能优化建议
1. **SSD 存储**:建议使用 SSD 存储以获得最佳性能
2. **内存配置**:为 Kvrocks 分配 512MB-1GB 内存即可
3. **磁盘空间**:预留足够磁盘空间,推荐至少 10GB
4. **监控配置**:定期检查磁盘使用率和性能指标
### ⚠️ 注意事项
- Kvrocks 端口 6666 仅限内部网络访问,确保安全
- 定期备份数据,虽然 Kvrocks 可靠性很高,但备份是好习惯
- 监控磁盘空间使用,避免磁盘满导致的问题
---
## 🎯 方案四:Vercel 部署(免服务器)
> **适合场景**:没有服务器,想要快速体验,个人使用
@@ -319,6 +544,20 @@ docker run --rm -v katelyatv-redis-data:/data -v $(pwd):/backup alpine tar xzf /
### 🔧 自定义资源站
> **📢 重要说明**:由于项目长期稳定性考虑,应社区用户建议已移除内置视频源,需要配置资源站后才能正常使用。
#### 方法一:使用推荐配置(推荐)
1. **下载配置文件**[点击下载 config.json](https://www.mediafire.com/file/xl3yo7la2ci378w/config.json/file)
2. **替换仓库配置**
- 在你 Fork 的仓库中找到 `config.json` 文件
- 点击编辑按钮(铅笔图标)
- 将下载的配置内容复制替换原有内容
- 点击 **Commit changes**
3. **等待重新部署**:Vercel 会自动重新部署(约 1-2 分钟)
#### 方法二:手动配置
如果你想添加自己的资源站:
1. 在你 Fork 的仓库中找到 `config.json` 文件
@@ -335,7 +574,7 @@ docker run --rm -v katelyatv-redis-data:/data -v $(pwd):/backup alpine tar xzf /
---
## 🎯 方案Cloudflare Pages(进阶用户)
## 🎯 方案Cloudflare Pages(进阶用户)
> **适合场景**:技术爱好者,想要全球 CDN 加速,免费但配置复杂
@@ -383,6 +622,22 @@ docker run --rm -v katelyatv-redis-data:/data -v $(pwd):/backup alpine tar xzf /
2. 点击最新部署旁的 **...** → **重试部署**
3. 等待部署成功
#### 第六步:配置资源站
> **📢 重要提醒**:为保障项目长期稳定运行,应用户建议已移除内置视频源,需要配置资源站。
##### 推荐配置方法:
1. **下载配置文件**[点击下载 config.json](https://www.mediafire.com/file/xl3yo7la2ci378w/config.json/file)
2. **更新仓库配置**
- 回到你的 GitHub 仓库
- 找到 `config.json` 文件,点击编辑
- 用下载的内容替换原有配置
- 提交更改
3. **等待自动部署**Cloudflare Pages 会自动重新构建部署
🎉 **完成!现在可以正常使用影视聚合功能了**
### 🗄️ 启用 D1 数据库(可选,支持多用户)
如果你想要用户系统和数据同步:
@@ -955,10 +1210,76 @@ KatelyaTV 支持标准的苹果 CMS V10 API 格式。
站长或管理员访问 `/admin` 即可进行管理员配置
### 🔧 视频源配置管理
管理员界面提供了完整的视频源配置管理功能:
#### 📤 导出配置
- **一键导出**:点击"📤 导出配置"按钮,系统会自动生成符合标准格式的 `config.json` 文件
- **自动格式化**:导出的配置文件包含所有已启用的视频源,格式完全符合项目要求
- **本地保存**:配置文件会自动下载到浏览器的下载文件夹,文件名包含日期标记
#### 📂 导入配置
- **文件选择**:点击"📂 导入配置"按钮,选择本地的 `.json` 配置文件
- **格式验证**:系统会自动验证配置文件格式,确保数据正确性
- **批量导入**:支持一次性导入多个视频源,显示详细的导入结果
- **错误提示**:如果导入过程中出现错误,会显示具体的错误信息
#### 📋 支持的配置格式
```json
{
"cache_time": 7200,
"api_site": {
"source_key": {
"api": "https://example.com/api.php/provide/vod",
"name": "视频源名称",
"detail": "https://example.com" // 可选
}
}
}
```
#### ✨ 其他管理功能
- **拖拽排序**:支持通过拖拽调整视频源的优先级顺序
- **启用/禁用**:可以临时禁用某个视频源而不删除配置
- **实时生效**:所有配置修改都会立即生效,无需重启服务
> **💡 提示**:导入的配置会永久保存在数据库中,不会因为浏览器刷新而丢失。这比直接修改 `config.json` 文件更加可靠和方便。
## 📱 AndroidTV 使用
目前该项目可以配合 [OrionTV](https://github.com/zimplexing/OrionTV) 在 Android TV 上使用,可以直接作为 OrionTV 后端
### 🆕 v0.5.0-katelya 修复说明
**修复了 OrionTV 客户端无法播放的问题**:
- **✅ 新增 CORS 支持**:为所有 API 路由添加了跨域请求头部,解决 OrionTV 客户端访问问题
- **✅ 修复认证拦截**:调整了中间件配置,确保 OrionTV 必需的 API 路由不被认证系统拦截
- **✅ 兼容性优化**:优化了搜索、详情、图片代理等关键 API 的响应头部
**如果你之前遇到"OrionTV 显示了资源但点击无法播放"的问题,现在应该已经解决了!**
### 📱 OrionTV 配置方法
1. **下载 OrionTV 客户端**:在 Android TV 上安装 OrionTV 应用
2. **配置 API 地址**:在 OrionTV 中填入你的 KatelyaTV 部署地址
3. **输入密码**:填写你设置的 PASSWORD 环境变量
4. **测试播放**:尝试搜索和播放视频
### 🔍 故障排除
如果还有播放问题,请检查:
- 确保你的 KatelyaTV 版本是 v0.5.0-katelya 或更新版本
- 确认已正确配置视频源(参考本文档的配置文件说明)
- 检查网络连接和防火墙设置
- 确保密码配置正确
暂时收藏夹与播放记录和网页端隔离,后续会支持同步用户数据
## 🗓️ Roadmap
@@ -1033,7 +1354,40 @@ KatelyaTV 支持标准的苹果 CMS V10 API 格式。
</div>
## 🙏 致谢
## 推荐配置文件说明
### 🎯 为什么需要配置文件?
为了项目的长期稳定运行和合规性,我们根据用户社区的建议,将内置的视频源移除。这样做的好处包括:
- **🛡️ 降低法律风险**:避免项目因内置资源问题而受到影响
- **⚡ 提升加载速度**:减少应用本体大小,提高启动速度
- **🔧 更灵活配置**:用户可以根据需要选择最适合的资源站
- **📈 长期维护性**:确保项目能够持续健康发展
### 📥 获取推荐配置
我们为用户精心准备了一个经过测试和优化的配置文件:
**📂 配置文件下载链接**: [https://www.mediafire.com/file/xl3yo7la2ci378w/config.json/file](https://www.mediafire.com/file/xl3yo7la2ci378w/config.json/file)
### ✨ 配置文件特点
-**经过充分测试**:所有资源站均经过可用性验证
-**响应速度优化**:优选响应快速的资源接口
- 🎬 **内容丰富**:覆盖电影、电视剧、综艺、动漫等多种类型
- 🔄 **定期更新**:我们会根据可用性定期更新推荐配置
### 🛡️ 使用声明
- 提供的配置文件仅供学习交流和技术测试使用
- 所有资源均来源于公开的网络接口,请用户自行判断使用的合法性
- 我们不对任何第三方资源的内容质量或合法性承担责任
- 强烈建议用户仅使用合法、正版的影视内容
---
## 🙏 致谢
- [ts-nextjs-tailwind-starter](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter) — 项目最初基于该脚手架。
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 由此启发,站在巨人的肩膀上。
+51
View File
@@ -0,0 +1,51 @@
## 🎉 KatelyaTV v0.6.0-katelya
### ✨ 主要更新
#### 📺 TVBox 集成优化
- **新增用户菜单中的"TVBox 配置"按钮** - 提供便捷的配置入口
- **TVBox API 无需认证** - 解决客户端无法登录的问题,现在可直接使用配置链接
- **优化用户体验** - 支持一键复制配置 URL,直接在 TVBox 应用中使用
#### ☁️ Cloudflare Pages 完全支持
- **修复 Edge Runtime 兼容性** - 解决部署失败问题
- **重构 API 架构** - 使用 Edge Runtime 兼容的配置读取方式
- **生产环境稳定性提升** - 确保 Cloudflare Pages 部署成功
#### 🔧 技术改进
- 修复代码风格问题(ESLint 导入排序)
- 优化中间件配置,确保安全性
- 提升构建过程稳定性
### 📱 使用方式
**TVBox 配置 URL**(无需登录):
- JSON 格式:`https://your-domain.com/api/tvbox?format=json`
- Base64 格式:`https://your-domain.com/api/tvbox?format=base64`
**访问配置页面**
1. 登录后点击右上角用户头像
2. 选择"TVBox 配置"
3. 复制配置链接到 TVBox 应用
### 🌐 部署兼容性
- ✅ Cloudflare Pages(推荐)
- ✅ Vercel
- ✅ Docker
- ✅ 传统服务器
### 🔄 升级说明
- **向后兼容**:现有配置和数据完全兼容
- **推荐操作**:重新部署以获取 Cloudflare Pages 优化
- **新功能**:TVBox 配置功能可选使用
---
**重要提示**:本版本主要解决了 TVBox 客户端集成和 Cloudflare Pages 部署的关键问题,建议所有用户升级。
+101
View File
@@ -0,0 +1,101 @@
# KatelyaTV v0.6.0-katelya 发布记录
## 🎉 重大更新
### 📺 TVBox 集成优化
- **用户界面改进**
- 在用户菜单中新增"TVBox 配置"按钮,提供便捷的配置入口
- 优化配置页面用户体验,支持一键复制配置链接
- 新增电视图标标识,界面更加直观
- **认证机制优化**
- **重要变更**TVBox API (`/api/tvbox`) 现已开放无需认证访问
- 解决 TVBox 客户端无法登录的根本问题
- 支持直接在 TVBox 应用中使用配置链接,无需预先登录
- 确保其他管理 API 仍受认证保护,维护系统安全
### 🔧 技术优化
#### Cloudflare Pages 部署支持
- **Edge Runtime 全面兼容**
- 修复所有 API 路由的 Edge Runtime 配置问题
- 解决 Cloudflare Pages 部署失败的核心问题
- 重构文件系统访问逻辑,使用 `getConfig()` 替代 `fs.readFileSync`
- 确保生产环境部署稳定性
#### 代码质量提升
- **ESLint 规则优化**
- 修复导入排序问题,确保代码风格一致性
- 解决所有编译时警告和错误
- 提升代码可维护性和团队协作效率
### 🌐 部署兼容性
-**Cloudflare Pages** - 完全支持,Edge Runtime 兼容
-**Vercel** - 自动适配,零配置部署
-**Docker** - 容器化部署,跨平台兼容
-**传统服务器** - Node.js 运行时,稳定运行
### 📱 TVBox 使用指南
#### 配置方式
1. **通过用户菜单**
- 登录 KatelyaTV 网站
- 点击右上角用户头像
- 选择"TVBox 配置"
- 复制配置链接到 TVBox 应用
2. **直接访问**
- JSON 格式:`https://your-domain.com/api/tvbox?format=json`
- Base64 格式:`https://your-domain.com/api/tvbox?format=base64`
#### 支持的客户端
- TVBox 官方版本
- TVBox 开源版本
- 影视仓
- 其他兼容 TVBox 标准的应用
### 🔄 迁移指南
从 v0.5.x 升级到 v0.6.0
1. **无需数据迁移**:配置数据完全兼容
2. **新功能可选**:现有功能保持不变
3. **推荐操作**:重新部署以获取 Cloudflare Pages 优化
### 🐛 修复的问题
- 修复 Cloudflare Pages 部署时的 Edge Runtime 配置错误
- 解决 TVBox API 认证导致的访问失败问题
- 修复代码导入排序导致的 ESLint 警告
- 优化构建过程,减少 Windows 开发环境的权限警告
### ⚠️ 重要说明
- **安全考虑**:TVBox API 开放访问不会泄露敏感信息,配置数据主要包含公开的视频源地址
- **向后兼容**:现有用户的所有功能和数据保持不变
- **推荐升级**:建议所有用户升级以获得更好的 TVBox 集成体验
---
## 🚀 下一版本预告
- 更多视频源集成
- 播放性能优化
- 移动端体验改进
- 更多第三方客户端支持
---
**发布日期**2025 年 9 月 3 日
**版本标签**v0.6.0-katelya
**兼容性**:向下兼容 v0.5.x 所有功能
+1 -1
View File
@@ -1 +1 @@
20250902153459
20250903203337
+1 -19
View File
@@ -3,26 +3,8 @@
"api_site": {
"example_test": {
"api": "https://example.com/api.php/provide/vod",
"name": "测试视频源",
"name": "示例视频源",
"detail": "https://example.com"
},
"demo_site1": {
"api": "https://your-api-domain.com/api.php/provide/vod",
"name": "示例视频源1",
"detail": "https://your-domain.com"
},
"demo_site2": {
"api": "https://api.your-site.com/provide/vod",
"name": "示例视频源2"
},
"demo_site3": {
"api": "https://video-api.example.org/api.php/provide/vod",
"name": "示例视频源3",
"detail": "https://video.example.org"
},
"demo_site4": {
"api": "https://media.sample-domain.net/api.php/provide/vod",
"name": "示例视频源4"
}
}
}
+60
View File
@@ -0,0 +1,60 @@
version: '3.8'
services:
# KatelyaTV 应用服务
katelyatv:
build: .
ports:
- "3000:3000"
environment:
# 数据库配置 - 使用 Kvrocks
NEXT_PUBLIC_STORAGE_TYPE: kvrocks
KVROCKS_URL: redis://kvrocks:6666
KVROCKS_PASSWORD: ${KVROCKS_PASSWORD:-}
KVROCKS_DATABASE: 0
# 其他必要的环境变量
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
depends_on:
- kvrocks
restart: unless-stopped
networks:
- katelyatv-network
# Kvrocks 数据库服务
kvrocks:
image: apache/kvrocks:latest
ports:
- "6666:6666"
environment:
# Kvrocks 配置
KVROCKS_BIND: 0.0.0.0
KVROCKS_PORT: 6666
KVROCKS_DIR: /var/lib/kvrocks/data
KVROCKS_LOG_LEVEL: info
# 可选:设置密码
KVROCKS_REQUIREPASS: ${KVROCKS_PASSWORD:-}
volumes:
# 持久化数据存储
- kvrocks-data:/var/lib/kvrocks/data
# 可选:挂载配置文件
- ./docker/kvrocks/kvrocks.conf:/etc/kvrocks/kvrocks.conf:ro
restart: unless-stopped
networks:
- katelyatv-network
healthcheck:
test: ["CMD", "redis-cli", "-h", "localhost", "-p", "6666", "ping"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
# Kvrocks 数据卷
kvrocks-data:
driver: local
networks:
katelyatv-network:
driver: bridge
+59
View File
@@ -0,0 +1,59 @@
# Kvrocks 配置文件
# 基于 RocksDB 的 Redis 协议兼容存储引擎
# 网络配置
bind 0.0.0.0
port 6666
# 数据存储配置
dir /var/lib/kvrocks/data
# 日志配置
log-level info
log-dir /var/lib/kvrocks/logs
# 性能优化配置
# RocksDB 配置
rocksdb.max_open_files 4096
rocksdb.max_background_jobs 4
rocksdb.max_write_buffer_number 4
rocksdb.write_buffer_size 64MB
# 压缩配置
rocksdb.compression snappy
# 内存配置
max-memory 512MB
# 安全配置
# requirepass your_password_here
# 持久化配置
# Kvrocks 基于 RocksDB,天然支持持久化,无需额外配置
# 网络超时配置
timeout 300
# 客户端连接配置
tcp-keepalive 300
tcp-backlog 511
# 慢查询日志
slowlog-log-slower-than 10000
slowlog-max-len 128
# 数据库数量
databases 16
# 备份配置
save ""
# AOF 配置(Kvrocks 不使用 AOF,这里仅为兼容性)
appendonly no
# 集群配置(单机部署可忽略)
# cluster-enabled no
# 监控配置
# rename-command FLUSHDB ""
# rename-command FLUSHALL ""
+143
View File
@@ -0,0 +1,143 @@
# Kvrocks 存储方案
## 🌟 什么是 Kvrocks
Kvrocks 是一个分布式键值数据库,兼容 Redis 协议,基于 RocksDB 存储引擎。它提供了比 Redis 更高的数据可靠性和更好的成本效益。
## 🆚 与 Redis 对比
| 特性 | Redis | Kvrocks |
| -------------- | -------------------- | ------------------------ |
| **数据持久性** | 内存 + AOF/RDB 备份 | **磁盘原生存储** |
| **数据丢失** | 可能丢失最后几秒数据 | **几乎零数据丢失风险** |
| **内存使用** | 全部数据在内存 | **仅缓存热数据** |
| **存储成本** | 受内存限制,成本较高 | **磁盘存储,成本低** |
| **扩展性** | 受内存限制 | **可处理更大数据集** |
| **协议兼容** | Redis 协议 | **完全兼容 Redis 协议** |
| **性能** | 极高(纯内存) | **高性能(接近 Redis** |
## 🎯 适用场景
### ✅ 推荐使用 Kvrocks
- 🏢 **生产环境**:需要高可靠性的生产部署
- 💾 **数据重要**:用户播放记录、收藏等重要数据不能丢失
- 💰 **成本敏感**:希望降低内存成本,使用便宜的磁盘存储
- 📈 **长期使用**:计划长期运行,数据量可能持续增长
### ❌ 不建议使用 Kvrocks
- 🏃 **极速性能**:需要微秒级响应时间的高频交易场景
- 🔥 **纯缓存**:数据可以随时丢失的纯缓存场景
- 📱 **轻量部署**:资源非常有限的设备(如低配置树莓派)
## 🚀 部署优势
### 1. 数据安全
- **零配置持久化**:无需配置 AOF 或 RDB,数据自动持久化到磁盘
- **断电保护**:即使突然断电,已提交的数据也不会丢失
- **原子操作**:基于 RocksDB 的事务保证数据一致性
### 2. 资源优化
- **内存友好**:只需要 Redis 1/10 的内存
- **磁盘高效**:智能压缩,节省存储空间
- **CPU 友好**:后台压缩和合并,不影响前台性能
### 3. 运维简单
- **免维护**:无需定期备份,数据自动持久化
- **监控简单**:提供标准 Redis 监控接口
- **迁移容易**:完全兼容 Redis 客户端和工具
## ⚡ 性能表现
在 KatelyaTV 的实际使用场景中:
- **读取性能**:接近 Redis,毫秒级响应
- **写入性能**:略低于 Redis,但仍然很快
- **内存使用**:仅为 Redis 的 10-20%
- **磁盘空间**:数据压缩后占用更少空间
## 🔧 配置建议
### 硬件要求
- **CPU**:2 核心即可满足大部分需求
- **内存**512MB - 1GB 即可(Redis 需要 4-8GB
- **磁盘**:建议使用 SSD,至少 10GB 空间
- **网络**:标准网络即可
### 系统配置
```bash
# 推荐的系统参数
echo 'vm.swappiness = 1' >> /etc/sysctl.conf
echo 'vm.overcommit_memory = 1' >> /etc/sysctl.conf
sysctl -p
```
## 📊 实际案例
### 用户反馈
> "使用 Kvrocks 后,再也不用担心重启服务器丢失观看记录了!" - 某用户
> "内存占用降低了 80%,服务器成本大幅下降。" - 某管理员
### 数据对比
- **Redis 方案**8GB 内存,每月 $40
- **Kvrocks 方案**1GB 内存 + 20GB SSD,每月 $15
- **成本节省**:约 60% 的基础设施成本
## 🛠️ 迁移指南
### 从 Redis 迁移到 Kvrocks
1. **停止应用**`docker compose down`
2. **备份数据**`docker compose exec redis redis-cli BGSAVE`
3. **导出数据**`docker compose exec redis redis-cli --rdb /data/dump.rdb`
4. **启动 Kvrocks**`docker compose -f docker-compose.kvrocks.yml up -d`
5. **导入数据**:使用 Redis 工具导入备份数据
6. **验证数据**:检查数据完整性
7. **切换应用**:修改环境变量,重启应用
### 回滚方案
如果需要回滚到 Redis
1. 从 Kvrocks 导出数据
2. 启动 Redis 服务
3. 导入数据到 Redis
4. 修改环境变量
5. 重启应用
## 💡 最佳实践
### 1. 监控建议
```bash
# 监控 Kvrocks 状态
docker compose exec kvrocks redis-cli info stats
docker compose exec kvrocks redis-cli info memory
docker compose exec kvrocks redis-cli info persistence
```
### 2. 备份策略
```bash
# 每日自动备份
0 2 * * * docker run --rm -v kvrocks_data:/data -v /backup:/backup alpine tar czf /backup/kvrocks-$(date +%Y%m%d).tar.gz /data
```
### 3. 性能调优
- 定期检查磁盘使用率
- 监控压缩率和延迟
- 根据负载调整缓存策略
---
**总结**Kvrocks 是 Redis 的完美替代方案,特别适合 KatelyaTV 这种需要高可靠性数据存储的应用场景。它在保持 Redis 兼容性的同时,提供了更好的数据安全性和更低的运营成本。
+191
View File
@@ -0,0 +1,191 @@
# TVBox 配置接口使用指南
## 📺 功能介绍
KatelyaTV 现在支持 TVBox 配置接口,可以将您的视频源直接导入到 TVBox 应用中使用。这个功能会自动同步 KatelyaTV 中配置的所有视频源,并提供标准的 TVBox JSON 格式配置。
## 🚀 快速开始
### 1. 访问配置页面
在 KatelyaTV 网站中,点击左侧导航栏的"TVBox 配置"菜单,或直接访问:
```
https://your-domain.com/config
```
### 2. 生成配置链接
在配置页面中:
1. **选择格式类型**
- **JSON 格式(推荐)**:标准的 JSON 配置文件,便于调试和查看
- **Base64 格式**:编码后的配置,适合某些特殊环境
2. **复制配置链接**:点击"复制"按钮,系统会自动生成对应格式的配置链接
**JSON 格式:**
```
https://your-domain.com/api/tvbox?format=json
```
**Base64 格式:**
```
https://your-domain.com/api/tvbox?format=base64
```
### 3. 导入到 TVBox
1. 打开 TVBox 应用
2. 进入设置 → 配置地址
3. 粘贴复制的配置链接
4. 点击确认导入
## 🔧 配置说明
### 🖥️ 配置页面功能
KatelyaTV 提供了直观的 TVBox 配置管理界面:
- **格式切换**:支持 JSON 和 Base64 两种格式切换
- **一键复制**:点击复制按钮快速获取配置链接
- **实时生成**:根据当前网站配置实时生成最新的 TVBox 配置
- **使用指南**:页面内置详细的使用说明和功能介绍
### 📋 支持的功能
- ✅ 自动同步 KatelyaTV 的所有视频源
- ✅ 支持搜索功能
- ✅ 支持快速搜索
- ✅ 支持分类筛选
- ✅ 内置视频解析接口
- ✅ 广告过滤规则
- ✅ CORS 跨域支持
### 内置解析接口
KatelyaTV 提供内置的视频解析服务:
```
https://your-domain.com/api/parse?url={视频地址}
```
支持的平台:
- 腾讯视频 (qq.com)
- 爱奇艺 (iqiyi.com)
- 优酷 (youku.com)
- 芒果 TV (mgtv.com)
- 哔哩哔哩 (bilibili.com)
- 搜狐视频 (sohu.com)
- 乐视 (letv.com)
- 土豆 (tudou.com)
- PPTV (pptv.com)
- 1905 电影网 (1905.com)
### 解析接口参数
- `url`: 要解析的视频地址(必填)
- `parser`: 指定解析器名称(可选)
- `format`: 返回格式,支持 `json``redirect``iframe`(可选,默认 json
## 📝 API 端点说明
### TVBox 配置接口
**GET** `/api/tvbox`
**参数:**
- `format`: 返回格式
- `json`(默认):返回 JSON 格式配置
- `base64`:返回 Base64 编码的配置
**响应:**
```json
{
"sites": [...], // 影视源列表
"parses": [...], // 解析源列表
"flags": [...], // 播放标识
"ads": [...], // 广告过滤规则
"wallpaper": "...", // 壁纸地址
"lives": [...] // 直播源(可选)
}
```
### 视频解析接口
**GET** `/api/parse`
**参数:**
- `url`: 视频地址
- `parser`: 解析器名称(可选)
- `format`: 返回格式(可选)
**响应:**
```json
{
"success": true,
"data": {
"original_url": "...",
"platform": "qq",
"parse_url": "...",
"parser_name": "...",
"available_parsers": [...]
}
}
```
## 🔄 配置更新
当您在 KatelyaTV 中添加、修改或删除视频源时:
1. TVBox 配置会自动同步最新的源站信息
2. 在 TVBox 中刷新配置即可获取最新源站
3. 无需手动更新配置链接
## ⚠️ 注意事项
1. **网络要求**:确保 TVBox 设备能够访问您的 KatelyaTV 服务器
2. **HTTPS 支持**:建议使用 HTTPS 协议确保安全性
3. **缓存设置**:配置会缓存 1 小时,如需立即更新请刷新 TVBox 配置
4. **兼容性**:支持 TVBox 及其衍生应用
5. **源站限制**:解析效果取决于原始视频源的可用性
## 🛠️ 故障排除
### 配置导入失败
- 检查网络连接
- 确认配置链接格式正确
- 尝试使用不同的 format 参数
### 视频无法播放
- 检查原始视频源是否可用
- 尝试使用不同的解析器
- 确认视频平台是否被支持
### 源站不显示
- 检查 KatelyaTV 中是否正确配置了视频源
- 确认视频源格式符合要求
- 刷新 TVBox 配置
## 📞 技术支持
如果您在使用过程中遇到问题,请:
1. 检查上述故障排除方案
2. 查看 KatelyaTV 和 TVBox 的日志信息
3. 向项目仓库提交 Issue
---
_此功能基于 TVBox 标准 JSON 配置格式开发,兼容大部分 TVBox 及其衍生应用。_
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "katelyatv",
"version": "0.5.0-katelya",
"version": "0.6.0-katelya",
"private": true,
"scripts": {
"dev": "npm run gen:runtime && npm run gen:manifest && next dev -H 0.0.0.0",
+1 -1
View File
@@ -1 +1 @@
if(!self.define){let e,s={};const c=(c,n)=>(c=new URL(c+".js",n).href,s[c]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=c,e.onload=s,document.head.appendChild(e)}else e=c,importScripts(c),s()}).then(()=>{let e=s[c];if(!e)throw new Error(`Module ${c} didnt register its module`);return e}));self.define=(n,a)=>{const i=e||("document"in self?document.currentScript.src:"")||location.href;if(s[i])return;let t={};const r=e=>c(e,i),o={module:{uri:i},exports:t,require:r};s[i]=Promise.all(n.map(e=>o[e]||r(e))).then(e=>(a(...e),t))}}define(["./workbox-e9849328"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"7c3d1f3a59bd37bd6b0e05018ebf9f32"},{url:"/_next/static/Vsc7_jLIvSZ-BKyHRSsrR/_buildManifest.js",revision:"046380ae5bc74b46b6d5eac3eed65355"},{url:"/_next/static/Vsc7_jLIvSZ-BKyHRSsrR/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/110-adb836a0730c35e7.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/154-de4a84fd5b2e0100.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/29-0844689411ca7d55.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/459-6bec40a8423cc309.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/51b697cb-f464f3017ac1ea30.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/682-d1dca8d17a3a8e6f.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/900-fb094d8873768e88.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/967-217cdcb80ae3beeb.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/998-568996670b543597.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/app/_not-found/page-ac328df06cf68f14.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/app/admin/page-d0def26e413c060d.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/app/douban/page-2d0023184aa37aff.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/app/layout-bd0bfbfdb401e15f.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/app/login/page-320c4f54724f3464.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/app/page-6a58e37ab3250691.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/app/play/page-63b2dae5d7950b37.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/app/search/page-63fe30b91e0539a7.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/app/warning/page-11cba4cf9332a238.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/c72274ce-06682d6fc8197e6d.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/da9543df-bf6da1a431d8604f.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/framework-6e06c675866dc992.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/main-95de9e33689c098a.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/main-app-dbd320e104e1a5dc.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/pages/_app-792b631a362c29e1.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/pages/_error-9fde6601392a2a99.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-17170f1d90853b2d.js",revision:"Vsc7_jLIvSZ-BKyHRSsrR"},{url:"/_next/static/css/23100062f5d4aac0.css",revision:"23100062f5d4aac0"},{url:"/_next/static/css/275ed64cc4367444.css",revision:"275ed64cc4367444"},{url:"/_next/static/css/f947920f7dec8442.css",revision:"f947920f7dec8442"},{url:"/_next/static/media/26a46d62cd723877-s.woff2",revision:"befd9c0fdfa3d8a645d5f95717ed6420"},{url:"/_next/static/media/55c55f0601d81cf3-s.woff2",revision:"43828e14271c77b87e3ed582dbff9f74"},{url:"/_next/static/media/581909926a08bbc8-s.woff2",revision:"f0b86e7c24f455280b8df606b89af891"},{url:"/_next/static/media/8e9860b6e62d6359-s.woff2",revision:"01ba6c2a184b8cba08b0d57167664d75"},{url:"/_next/static/media/97e0cb1ae144a2a9-s.woff2",revision:"e360c61c5bd8d90639fd4503c829c2dc"},{url:"/_next/static/media/df0a9ae256c0569c-s.woff2",revision:"d54db44de5ccb18886ece2fda72bdfe0"},{url:"/_next/static/media/e4af272ccee01ff0-s.p.woff2",revision:"65850a373e258f1c897a2b3d75eb74de"},{url:"/favicon.ico",revision:"c5de6e56c5664adda146825f75ea6ecf"},{url:"/icons/icon-192x192.png",revision:"4a56c090828a1ad254c903c7aec0389d"},{url:"/icons/icon-256x256.png",revision:"f6409eb1a001f754121e3a8281c0319c"},{url:"/icons/icon-384x384.png",revision:"f6efc3e357b9ffdf4e0d8c14b2ed0ac1"},{url:"/icons/icon-512x512.png",revision:"9c008cbbeb6a576fe07bb1284a83f4d2"},{url:"/logo.png",revision:"40de611b143c47c6291c7bdad2c959ca"},{url:"/manifest.json",revision:"7bd3dabc1cfbfe40f09577efca223d31"},{url:"/robots.txt",revision:"e2b2cd8514443456bc6fb9d77b3b1f3e"},{url:"/screenshot1.png",revision:"10572bfcea54dc93ac4c5f7c9057fc98"},{url:"/screenshot2.png",revision:"f815a8990973a221899976867365c239"},{url:"/screenshot3.png",revision:"49709e96345dfeeab1d8083821d4b44e"},{url:"/screenshot4.png",revision:"a76c751e41e37556048a487e4f8b8b1c"},{url:"/wechat.jpg",revision:"d0f601311802667cd6ca5a37dc69bfa7"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:c,state:n})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")});
if(!self.define){let e,s={};const n=(n,a)=>(n=new URL(n+".js",a).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didnt register its module`);return e}));self.define=(a,c)=>{const i=e||("document"in self?document.currentScript.src:"")||location.href;if(s[i])return;let t={};const r=e=>n(e,i),o={module:{uri:i},exports:t,require:r};s[i]=Promise.all(a.map(e=>o[e]||r(e))).then(e=>(c(...e),t))}}define(["./workbox-e9849328"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"ed4223c3f11fa5b30e75397a730aadec"},{url:"/_next/static/YxNDbDLH8EDPR3w9YWGxN/_buildManifest.js",revision:"046380ae5bc74b46b6d5eac3eed65355"},{url:"/_next/static/YxNDbDLH8EDPR3w9YWGxN/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/110-4d8fbe2ce6008c90.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/154-de4a84fd5b2e0100.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/29-0844689411ca7d55.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/459-6bec40a8423cc309.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/51b697cb-f464f3017ac1ea30.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/682-d1dca8d17a3a8e6f.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/900-fb094d8873768e88.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/967-217cdcb80ae3beeb.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/998-568996670b543597.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/app/_not-found/page-ac328df06cf68f14.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/app/admin/page-d05d4621a6953d54.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/app/config/page-11f6321397ad65b1.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/app/douban/page-2d0023184aa37aff.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/app/layout-bd0bfbfdb401e15f.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/app/login/page-6d62f8fe1814a4fb.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/app/page-6a58e37ab3250691.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/app/play/page-cbcfbf4a92cde119.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/app/search/page-63fe30b91e0539a7.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/app/tvbox/page-3a990d4dba7ad091.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/app/warning/page-11cba4cf9332a238.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/c72274ce-06682d6fc8197e6d.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/da9543df-bf6da1a431d8604f.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/framework-6e06c675866dc992.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/main-app-dbd320e104e1a5dc.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/main-ef3a79fcb73d32d2.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/pages/_app-792b631a362c29e1.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/pages/_error-9fde6601392a2a99.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-17170f1d90853b2d.js",revision:"YxNDbDLH8EDPR3w9YWGxN"},{url:"/_next/static/css/23100062f5d4aac0.css",revision:"23100062f5d4aac0"},{url:"/_next/static/css/27265468060ffa3a.css",revision:"27265468060ffa3a"},{url:"/_next/static/css/275ed64cc4367444.css",revision:"275ed64cc4367444"},{url:"/_next/static/media/26a46d62cd723877-s.woff2",revision:"befd9c0fdfa3d8a645d5f95717ed6420"},{url:"/_next/static/media/55c55f0601d81cf3-s.woff2",revision:"43828e14271c77b87e3ed582dbff9f74"},{url:"/_next/static/media/581909926a08bbc8-s.woff2",revision:"f0b86e7c24f455280b8df606b89af891"},{url:"/_next/static/media/8e9860b6e62d6359-s.woff2",revision:"01ba6c2a184b8cba08b0d57167664d75"},{url:"/_next/static/media/97e0cb1ae144a2a9-s.woff2",revision:"e360c61c5bd8d90639fd4503c829c2dc"},{url:"/_next/static/media/df0a9ae256c0569c-s.woff2",revision:"d54db44de5ccb18886ece2fda72bdfe0"},{url:"/_next/static/media/e4af272ccee01ff0-s.p.woff2",revision:"65850a373e258f1c897a2b3d75eb74de"},{url:"/favicon.ico",revision:"c5de6e56c5664adda146825f75ea6ecf"},{url:"/icons/icon-192x192.png",revision:"4a56c090828a1ad254c903c7aec0389d"},{url:"/icons/icon-256x256.png",revision:"f6409eb1a001f754121e3a8281c0319c"},{url:"/icons/icon-384x384.png",revision:"f6efc3e357b9ffdf4e0d8c14b2ed0ac1"},{url:"/icons/icon-512x512.png",revision:"9c008cbbeb6a576fe07bb1284a83f4d2"},{url:"/logo.png",revision:"40de611b143c47c6291c7bdad2c959ca"},{url:"/manifest.json",revision:"7bd3dabc1cfbfe40f09577efca223d31"},{url:"/robots.txt",revision:"e2b2cd8514443456bc6fb9d77b3b1f3e"},{url:"/screenshot1.png",revision:"10572bfcea54dc93ac4c5f7c9057fc98"},{url:"/screenshot2.png",revision:"f815a8990973a221899976867365c239"},{url:"/screenshot3.png",revision:"49709e96345dfeeab1d8083821d4b44e"},{url:"/screenshot4.png",revision:"a76c751e41e37556048a487e4f8b8b1c"},{url:"/wechat.jpg",revision:"d0f601311802667cd6ca5a37dc69bfa7"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:n,state:a})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")});
+382 -10
View File
@@ -626,6 +626,8 @@ const VideoSourceConfig = ({
const [sources, setSources] = useState<DataSource[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false);
const [batchMode, setBatchMode] = useState(false);
const [selectedSources, setSelectedSources] = useState<Set<string>>(new Set());
const [newSource, setNewSource] = useState<DataSource>({
name: '',
key: '',
@@ -691,6 +693,13 @@ const VideoSourceConfig = ({
};
const handleDelete = (key: string) => {
// 检查是否为示例源
const source = sources.find(s => s.key === key);
if (source?.from === 'config') {
showError('示例源不可删除,这些源用于演示功能');
return;
}
callSourceApi({ action: 'delete', key }).catch(() => {
console.error('操作失败', 'delete', key);
});
@@ -721,6 +730,267 @@ const VideoSourceConfig = ({
});
};
// 批量操作相关函数
const handleToggleBatchMode = () => {
setBatchMode(!batchMode);
setSelectedSources(new Set()); // 切换模式时清空选择
};
const handleSelectSource = (key: string, checked: boolean) => {
const newSelected = new Set(selectedSources);
if (checked) {
newSelected.add(key);
} else {
newSelected.delete(key);
}
setSelectedSources(newSelected);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
// 只选择可删除的视频源(排除示例源)
const deletableSources = sources.filter(source => source.from !== 'config');
setSelectedSources(new Set(deletableSources.map(source => source.key)));
} else {
setSelectedSources(new Set());
}
};
const handleBatchDelete = async () => {
if (selectedSources.size === 0) {
showError('请先选择要删除的视频源');
return;
}
const selectedArray = Array.from(selectedSources);
const result = await Swal.fire({
title: '确认批量删除',
text: `即将删除 ${selectedArray.length} 个视频源,此操作不可撤销!`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: '确认删除',
cancelButtonText: '取消',
confirmButtonColor: '#ef4444',
cancelButtonColor: '#6b7280'
});
if (!result.isConfirmed) return;
// 批量删除逐个进行,显示进度
let successCount = 0;
let errorCount = 0;
const errors: string[] = [];
for (let i = 0; i < selectedArray.length; i++) {
const key = selectedArray[i];
try {
await callSourceApi({ action: 'delete', key });
successCount++;
// 显示进度
if (selectedArray.length > 1) {
Swal.update({
title: '正在删除...',
text: `进度: ${i + 1}/${selectedArray.length}`,
showConfirmButton: false,
showCancelButton: false,
allowOutsideClick: false
});
}
} catch (error) {
errorCount++;
const sourceName = sources.find(s => s.key === key)?.name || key;
errors.push(`${sourceName}: ${error instanceof Error ? error.message : '删除失败'}`);
}
}
// 显示删除结果
if (errorCount === 0) {
showSuccess(`成功删除 ${successCount} 个视频源`);
setSelectedSources(new Set()); // 清空选择
setBatchMode(false); // 退出批量模式
} else {
await Swal.fire({
title: '删除完成',
html: `
<div class="text-left">
<p class="text-green-600 mb-2">✅ 成功删除: ${successCount} 个</p>
<p class="text-red-600 mb-2">❌ 删除失败: ${errorCount} 个</p>
${errors.length > 0 ? `
<details class="mt-3">
<summary class="cursor-pointer text-gray-600">查看错误详情</summary>
<div class="mt-2 text-sm text-gray-500 max-h-32 overflow-y-auto">
${errors.map(err => `<div class="py-1">${err}</div>`).join('')}
</div>
</details>
` : ''}
</div>
`,
icon: successCount > 0 ? 'warning' : 'error',
confirmButtonText: '确定'
});
// 清空已成功删除的选择项
const failedKeys = new Set(
errors.map(err => {
const keyMatch = err.split(':')[0];
return sources.find(s => s.name === keyMatch)?.key;
}).filter((key): key is string => Boolean(key))
);
setSelectedSources(failedKeys);
}
await refreshConfig();
};
// 导出配置
const handleExportConfig = () => {
try {
// 构建符合要求的配置格式
const exportConfig = {
cache_time: config?.SiteConfig?.SiteInterfaceCacheTime || 7200,
api_site: {} as Record<string, any>
};
// 将视频源转换为config.json格式
sources.forEach(source => {
if (!source.disabled) {
exportConfig.api_site[source.key] = {
api: source.api,
name: source.name,
...(source.detail && { detail: source.detail })
};
}
});
// 生成JSON文件并下载
const dataStr = JSON.stringify(exportConfig, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `config_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showSuccess('配置文件已导出到下载文件夹');
} catch (error) {
showError('导出失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
};
// 导入配置
const handleImportConfig = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// 检查文件类型
if (!file.name.toLowerCase().endsWith('.json')) {
showError('请选择JSON文件');
return;
}
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target?.result as string;
const importConfig = JSON.parse(content);
// 验证配置格式
if (!importConfig.api_site || typeof importConfig.api_site !== 'object') {
showError('配置文件格式错误:缺少 api_site 字段');
return;
}
// 确认导入
const result = await Swal.fire({
title: '确认导入',
text: `检测到 ${Object.keys(importConfig.api_site).length} 个视频源,是否继续导入?`,
icon: 'question',
showCancelButton: true,
confirmButtonText: '确认导入',
cancelButtonText: '取消',
confirmButtonColor: '#059669',
cancelButtonColor: '#6b7280'
});
if (!result.isConfirmed) return;
// 批量导入视频源
let successCount = 0;
let errorCount = 0;
const errors: string[] = [];
for (const [key, source] of Object.entries(importConfig.api_site)) {
try {
// 类型检查和验证
if (!source || typeof source !== 'object' || Array.isArray(source)) {
throw new Error(`${key}: 无效的配置对象`);
}
const sourceObj = source as { api?: string; name?: string; detail?: string };
if (!sourceObj.api || !sourceObj.name) {
throw new Error(`${key}: 缺少必要字段 api 或 name`);
}
await callSourceApi({
action: 'add',
key: key,
name: sourceObj.name,
api: sourceObj.api,
detail: sourceObj.detail || ''
});
successCount++;
} catch (error) {
errorCount++;
errors.push(`${key}: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
// 显示导入结果
if (errorCount === 0) {
showSuccess(`成功导入 ${successCount} 个视频源`);
} else {
await Swal.fire({
title: '导入完成',
html: `
<div class="text-left">
<p class="text-green-600 mb-2">✅ 成功导入: ${successCount} 个</p>
<p class="text-red-600 mb-2">❌ 导入失败: ${errorCount} 个</p>
${errors.length > 0 ? `
<details class="mt-3">
<summary class="cursor-pointer text-gray-600">查看错误详情</summary>
<div class="mt-2 text-sm text-gray-500 max-h-32 overflow-y-auto">
${errors.map(err => `<div class="py-1">${err}</div>`).join('')}
</div>
</details>
` : ''}
</div>
`,
icon: successCount > 0 ? 'warning' : 'error',
confirmButtonText: '确定'
});
}
} catch (error) {
showError('配置文件解析失败: ' + (error instanceof Error ? error.message : '文件格式错误'));
}
};
reader.onerror = () => {
showError('文件读取失败');
};
reader.readAsText(file);
// 清空input,允许重复选择同一文件
event.target.value = '';
};
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
@@ -757,6 +1027,7 @@ const VideoSourceConfig = ({
style={style}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
>
{/* 拖拽手柄 */}
<td
className='px-2 py-4 cursor-grab text-gray-400'
style={{ touchAction: 'none' }}
@@ -765,8 +1036,28 @@ const VideoSourceConfig = ({
>
<GripVertical size={16} />
</td>
{/* 批量选择复选框 */}
{batchMode && (
<td className='px-4 py-4 whitespace-nowrap'>
<input
type='checkbox'
checked={selectedSources.has(source.key)}
onChange={(e) => handleSelectSource(source.key, e.target.checked)}
disabled={source.from === 'config'} // 禁用示例源选择
className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50'
/>
</td>
)}
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{source.name}
<div className="flex items-center space-x-2">
<span>{source.name}</span>
{source.from === 'config' && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/20 dark:text-amber-300">
</span>
)}
</div>
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{source.key}
@@ -805,13 +1096,17 @@ const VideoSourceConfig = ({
>
{!source.disabled ? '禁用' : '启用'}
</button>
{source.from !== 'config' && (
{source.from !== 'config' ? (
<button
onClick={() => handleDelete(source.key)}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors'
>
</button>
) : (
<span className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-200 text-gray-500 dark:bg-gray-800 dark:text-gray-400'>
</span>
)}
</td>
</tr>
@@ -828,17 +1123,80 @@ const VideoSourceConfig = ({
return (
<div className='space-y-6'>
{/* 添加视频源表单 */}
<div className='flex items-center justify-between'>
{/* 视频源管理工具栏 */}
<div className='flex items-center justify-between flex-wrap gap-3'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<button
onClick={() => setShowAddForm(!showAddForm)}
className='px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors'
>
{showAddForm ? '取消' : '添加视频源'}
</button>
<div className='flex items-center gap-2 flex-wrap'>
{/* 批量操作区域 */}
{!batchMode ? (
<>
{/* 普通模式按钮 */}
<button
onClick={handleToggleBatchMode}
className='inline-flex items-center px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors'
>
</button>
{/* 导入导出按钮 */}
<div className='flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-2'>
<label className='relative'>
<input
type='file'
accept='.json'
onChange={handleImportConfig}
className='absolute inset-0 w-full h-full opacity-0 cursor-pointer'
/>
<span className='inline-flex items-center px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors cursor-pointer'>
📂
</span>
</label>
<button
onClick={handleExportConfig}
className='inline-flex items-center px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors'
>
📤
</button>
</div>
{/* 添加视频源按钮 */}
<button
onClick={() => setShowAddForm(!showAddForm)}
className='px-3 py-1 bg-orange-600 hover:bg-orange-700 text-white text-sm rounded-lg transition-colors'
>
{showAddForm ? '取消' : ' 添加'}
</button>
</>
) : (
<>
{/* 批量模式按钮 */}
<button
onClick={handleToggleBatchMode}
className='inline-flex items-center px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors'
>
退
</button>
<div className='flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-2'>
<span className='text-xs text-gray-500 dark:text-gray-400'>
{selectedSources.size}
</span>
<button
onClick={handleBatchDelete}
disabled={selectedSources.size === 0}
className='inline-flex items-center px-3 py-1 bg-red-600 hover:bg-red-700 disabled:bg-gray-400 text-white text-sm rounded-lg transition-colors'
>
🗑
</button>
</div>
</>
)}
</div>
</div>
{showAddForm && (
@@ -898,7 +1256,21 @@ const VideoSourceConfig = ({
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900'>
<tr>
{/* 拖拽手柄列 */}
<th className='w-8' />
{/* 批量选择列 */}
{batchMode && (
<th className='w-12 px-4 py-3'>
<input
type='checkbox'
checked={selectedSources.size > 0 && selectedSources.size === sources.length}
onChange={(e) => handleSelectAll(e.target.checked)}
className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
/>
</th>
)}
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
+16 -5
View File
@@ -1,21 +1,29 @@
import { NextResponse } from 'next/server';
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
import { addCorsHeaders, handleOptionsRequest } from '@/lib/cors';
import { getDetailFromApi } from '@/lib/downstream';
export const runtime = 'edge';
// 处理OPTIONS预检请求(OrionTV客户端需要)
export async function OPTIONS() {
return handleOptionsRequest();
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const sourceCode = searchParams.get('source');
if (!id || !sourceCode) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
const response = NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
return addCorsHeaders(response);
}
if (!/^[\w-]+$/.test(id)) {
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
const response = NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
return addCorsHeaders(response);
}
try {
@@ -23,23 +31,26 @@ export async function GET(request: Request) {
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
return NextResponse.json({ error: '无效的API来源' }, { status: 400 });
const response = NextResponse.json({ error: '无效的API来源' }, { status: 400 });
return addCorsHeaders(response);
}
const result = await getDetailFromApi(apiSite, id);
const cacheTime = await getCacheTime();
return NextResponse.json(result, {
const response = NextResponse.json(result, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
},
});
return addCorsHeaders(response);
} catch (error) {
return NextResponse.json(
const response = NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
return addCorsHeaders(response);
}
}
+17 -5
View File
@@ -1,14 +1,22 @@
import { NextResponse } from 'next/server';
import { addCorsHeaders, handleOptionsRequest } from '@/lib/cors';
export const runtime = 'edge';
// 处理OPTIONS预检请求(OrionTV客户端需要)
export async function OPTIONS() {
return handleOptionsRequest();
}
// OrionTV 兼容接口
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const imageUrl = searchParams.get('url');
if (!imageUrl) {
return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
const response = NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
return addCorsHeaders(response);
}
try {
@@ -21,19 +29,21 @@ export async function GET(request: Request) {
});
if (!imageResponse.ok) {
return NextResponse.json(
const response = NextResponse.json(
{ error: imageResponse.statusText },
{ status: imageResponse.status }
);
return addCorsHeaders(response);
}
const contentType = imageResponse.headers.get('content-type');
if (!imageResponse.body) {
return NextResponse.json(
const response = NextResponse.json(
{ error: 'Image response has no body' },
{ status: 500 }
);
return addCorsHeaders(response);
}
// 创建响应头
@@ -48,14 +58,16 @@ export async function GET(request: Request) {
headers.set('Vercel-CDN-Cache-Control', 'public, s-maxage=15720000');
// 直接返回图片流
return new Response(imageResponse.body, {
const response = new Response(imageResponse.body, {
status: 200,
headers,
});
return addCorsHeaders(response);
} catch (error) {
return NextResponse.json(
const response = NextResponse.json(
{ error: 'Error fetching image' },
{ status: 500 }
);
return addCorsHeaders(response);
}
}
+168
View File
@@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from 'next/server';
// 强制使用 Edge Runtime 以支持 Cloudflare Pages
export const runtime = 'edge';
// 常用的视频解析接口列表
const PARSE_APIS = [
{
name: '无名小站',
url: 'https://jx.aidouer.net/?url=',
support: ['qq', 'iqiyi', 'youku', 'mgtv', 'bilibili']
},
{
name: '虾米解析',
url: 'https://jx.xmflv.com/?url=',
support: ['qq', 'iqiyi', 'youku', 'mgtv', 'bilibili', 'sohu']
},
{
name: '爱豆解析',
url: 'https://jx.aidouer.net/?url=',
support: ['qq', 'iqiyi', 'youku', 'mgtv', 'bilibili']
},
{
name: '8090解析',
url: 'https://www.8090g.cn/?url=',
support: ['qq', 'iqiyi', 'youku', 'mgtv', 'bilibili']
},
{
name: 'OK解析',
url: 'https://okjx.cc/?url=',
support: ['qq', 'iqiyi', 'youku', 'mgtv', 'bilibili']
}
];
// 检测视频URL的平台类型
function detectPlatform(url: string): string {
if (url.includes('qq.com') || url.includes('v.qq.com')) return 'qq';
if (url.includes('iqiyi.com') || url.includes('qiyi.com')) return 'iqiyi';
if (url.includes('youku.com')) return 'youku';
if (url.includes('mgtv.com')) return 'mgtv';
if (url.includes('bilibili.com')) return 'bilibili';
if (url.includes('sohu.com')) return 'sohu';
if (url.includes('letv.com') || url.includes('le.com')) return 'letv';
if (url.includes('tudou.com')) return 'tudou';
if (url.includes('pptv.com')) return 'pptv';
if (url.includes('1905.com')) return '1905';
return 'unknown';
}
// 获取适用的解析接口
function getCompatibleParsers(platform: string) {
return PARSE_APIS.filter(api =>
api.support.includes(platform) || platform === 'unknown'
);
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
const parser = searchParams.get('parser'); // 指定解析器
const format = searchParams.get('format') || 'json'; // 返回格式
if (!url) {
return NextResponse.json(
{ error: '缺少url参数' },
{ status: 400 }
);
}
// 检测平台类型
const platform = detectPlatform(url);
const compatibleParsers = getCompatibleParsers(platform);
if (compatibleParsers.length === 0) {
return NextResponse.json(
{
error: '暂不支持该平台的视频解析',
platform,
url
},
{ status: 400 }
);
}
// 如果指定了解析器,优先使用
let selectedParser = compatibleParsers[0];
if (parser) {
const customParser = PARSE_APIS.find(api =>
api.name.toLowerCase().includes(parser.toLowerCase())
);
if (customParser && compatibleParsers.includes(customParser)) {
selectedParser = customParser;
}
}
const parseUrl = selectedParser.url + encodeURIComponent(url);
// 根据format返回不同格式
if (format === 'redirect') {
// 直接重定向到解析页面
return NextResponse.redirect(parseUrl);
} else if (format === 'iframe') {
// 返回可嵌入的HTML页面
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>视频播放</title>
<style>
body { margin: 0; padding: 0; background: #000; }
iframe { width: 100%; height: 100vh; border: none; }
</style>
</head>
<body>
<iframe src="${parseUrl}" allowfullscreen></iframe>
</body>
</html>`;
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Access-Control-Allow-Origin': '*'
}
});
} else {
// 返回JSON格式的解析信息
return NextResponse.json({
success: true,
data: {
original_url: url,
platform,
parse_url: parseUrl,
parser_name: selectedParser.name,
available_parsers: compatibleParsers.map(p => p.name)
}
}, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type',
'Cache-Control': 'public, max-age=300' // 5分钟缓存
}
});
}
} catch (error) {
return NextResponse.json(
{
error: '视频解析失败',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}
// 支持CORS预检请求
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
}
});
}
+16 -5
View File
@@ -1,10 +1,16 @@
import { NextResponse } from 'next/server';
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
import { addCorsHeaders, handleOptionsRequest } from '@/lib/cors';
import { searchFromApi } from '@/lib/downstream';
export const runtime = 'edge';
// 处理OPTIONS预检请求(OrionTV客户端需要)
export async function OPTIONS() {
return handleOptionsRequest();
}
// OrionTV 兼容接口
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
@@ -13,7 +19,7 @@ export async function GET(request: Request) {
if (!query || !resourceId) {
const cacheTime = await getCacheTime();
return NextResponse.json(
const response = NextResponse.json(
{ result: null, error: '缺少必要参数: q 或 resourceId' },
{
headers: {
@@ -23,6 +29,7 @@ export async function GET(request: Request) {
},
}
);
return addCorsHeaders(response);
}
const apiSites = await getAvailableApiSites();
@@ -31,13 +38,14 @@ export async function GET(request: Request) {
// 根据 resourceId 查找对应的 API 站点
const targetSite = apiSites.find((site) => site.key === resourceId);
if (!targetSite) {
return NextResponse.json(
const response = NextResponse.json(
{
error: `未找到指定的视频源: ${resourceId}`,
result: null,
},
{ status: 404 }
);
return addCorsHeaders(response);
}
const results = await searchFromApi(targetSite, query);
@@ -45,15 +53,16 @@ export async function GET(request: Request) {
const cacheTime = await getCacheTime();
if (result.length === 0) {
return NextResponse.json(
const response = NextResponse.json(
{
error: '未找到结果',
result: null,
},
{ status: 404 }
);
return addCorsHeaders(response);
} else {
return NextResponse.json(
const response = NextResponse.json(
{ results: result },
{
headers: {
@@ -63,14 +72,16 @@ export async function GET(request: Request) {
},
}
);
return addCorsHeaders(response);
}
} catch (error) {
return NextResponse.json(
const response = NextResponse.json(
{
error: '搜索失败',
result: null,
},
{ status: 500 }
);
return addCorsHeaders(response);
}
}
+10 -2
View File
@@ -1,23 +1,31 @@
import { NextResponse } from 'next/server';
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
import { addCorsHeaders, handleOptionsRequest } from '@/lib/cors';
export const runtime = 'edge';
// 处理OPTIONS预检请求(OrionTV客户端需要)
export async function OPTIONS() {
return handleOptionsRequest();
}
// OrionTV 兼容接口
export async function GET() {
try {
const apiSites = await getAvailableApiSites();
const cacheTime = await getCacheTime();
return NextResponse.json(apiSites, {
const response = NextResponse.json(apiSites, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
},
});
return addCorsHeaders(response);
} catch (error) {
return NextResponse.json({ error: '获取资源失败' }, { status: 500 });
const response = NextResponse.json({ error: '获取资源失败' }, { status: 500 });
return addCorsHeaders(response);
}
}
+12 -3
View File
@@ -1,17 +1,23 @@
import { NextResponse } from 'next/server';
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
import { addCorsHeaders, handleOptionsRequest } from '@/lib/cors';
import { searchFromApi } from '@/lib/downstream';
export const runtime = 'edge';
// 处理OPTIONS预检请求(OrionTV客户端需要)
export async function OPTIONS() {
return handleOptionsRequest();
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
if (!query) {
const cacheTime = await getCacheTime();
return NextResponse.json(
const response = NextResponse.json(
{ results: [] },
{
headers: {
@@ -21,6 +27,7 @@ export async function GET(request: Request) {
},
}
);
return addCorsHeaders(response);
}
const apiSites = await getAvailableApiSites();
@@ -31,7 +38,7 @@ export async function GET(request: Request) {
const flattenedResults = results.flat();
const cacheTime = await getCacheTime();
return NextResponse.json(
const response = NextResponse.json(
{ results: flattenedResults },
{
headers: {
@@ -41,7 +48,9 @@ export async function GET(request: Request) {
},
}
);
return addCorsHeaders(response);
} catch (error) {
return NextResponse.json({ error: '搜索失败' }, { status: 500 });
const response = NextResponse.json({ error: '搜索失败' }, { status: 500 });
return addCorsHeaders(response);
}
}
+235
View File
@@ -0,0 +1,235 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
// 强制使用 Edge Runtime 以支持 Cloudflare Pages
export const runtime = 'edge';
// TVBox源格式接口
interface TVBoxSource {
key: string;
name: string;
type: number; // 0=影视源, 1=直播源, 3=解析源
api: string;
searchable?: number; // 0=不可搜索, 1=可搜索
quickSearch?: number; // 0=不支持快速搜索, 1=支持快速搜索
filterable?: number; // 0=不支持分类筛选, 1=支持分类筛选
ext?: string; // 扩展参数
jar?: string; // jar包地址
playUrl?: string; // 播放解析地址
categories?: string[]; // 分类
timeout?: number; // 超时时间(秒)
}
interface TVBoxConfig {
spider?: string; // 爬虫jar包地址
wallpaper?: string; // 壁纸地址
lives?: Array<{
name: string;
type: number;
url: string;
epg?: string;
logo?: string;
}>; // 直播源
sites: TVBoxSource[]; // 影视源
parses?: Array<{
name: string;
type: number;
url: string;
ext?: Record<string, unknown>;
header?: Record<string, string>;
}>; // 解析源
flags?: string[]; // 播放标识
ijk?: Record<string, unknown>; // IJK播放器配置
ads?: string[]; // 广告过滤规则
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const format = searchParams.get('format') || 'json'; // 支持json和base64格式
const host = request.headers.get('host') || 'localhost:3000';
const protocol = request.headers.get('x-forwarded-proto') || 'http';
const baseUrl = `${protocol}://${host}`;
// 读取当前配置
const config = await getConfig();
// 从配置中获取源站列表
const sourceConfigs = config.SourceConfig || [];
if (sourceConfigs.length === 0) {
return NextResponse.json({ error: '没有配置任何视频源' }, { status: 500 });
}
// 转换为TVBox格式
const tvboxConfig: TVBoxConfig = {
// 基础配置
spider: '', // 可以根据需要添加爬虫jar包
wallpaper: `${baseUrl}/screenshot1.png`, // 使用项目截图作为壁纸
// 影视源配置
sites: sourceConfigs.map((source) => ({
key: source.key || source.name,
name: source.name,
type: 0, // 影视源
api: source.api,
searchable: 1, // 可搜索
quickSearch: 1, // 支持快速搜索
filterable: 1, // 支持分类筛选
ext: source.detail || '', // 详情页地址作为扩展参数
timeout: 30, // 30秒超时
categories: [
"电影", "电视剧", "综艺", "动漫", "纪录片", "短剧"
]
})),
// 解析源配置(添加一些常用的解析源)
parses: [
{
name: "Json并发",
type: 2,
url: "Parallel"
},
{
name: "Json轮询",
type: 2,
url: "Sequence"
},
{
name: "KatelyaTV内置解析",
type: 1,
url: `${baseUrl}/api/parse?url=`,
ext: {
flag: ["qiyi", "qq", "letv", "sohu", "youku", "mgtv", "bilibili", "wasu", "xigua", "1905"]
}
}
],
// 播放标识
flags: [
"youku", "qq", "iqiyi", "qiyi", "letv", "sohu", "tudou", "pptv",
"mgtv", "wasu", "bilibili", "le", "duoduozy", "renrenmi", "xigua",
"优酷", "腾讯", "爱奇艺", "奇艺", "乐视", "搜狐", "土豆", "PPTV",
"芒果", "华数", "哔哩", "1905"
],
// 直播源(可选)
lives: [
{
name: "KatelyaTV直播",
type: 0,
url: `${baseUrl}/api/live/channels`,
epg: "",
logo: ""
}
],
// 广告过滤规则
ads: [
"mimg.0c1q0l.cn",
"www.googletagmanager.com",
"www.google-analytics.com",
"mc.usihnbcq.cn",
"mg.g1mm3d.cn",
"mscs.svaeuzh.cn",
"cnzz.hhurm.com",
"tp.vinuxhome.com",
"cnzz.mmstat.com",
"www.baihuillq.com",
"s23.cnzz.com",
"z3.cnzz.com",
"c.cnzz.com",
"stj.v1vo.top",
"z12.cnzz.com",
"img.mosflower.cn",
"tips.gamevvip.com",
"ehwe.yhdtns.com",
"xdn.cqqc3.com",
"www.jixunkyy.cn",
"sp.chemacid.cn",
"hm.baidu.com",
"s9.cnzz.com",
"z6.cnzz.com",
"um.cavuc.com",
"mav.mavuz.com",
"wofwk.aoidf3.com",
"z5.cnzz.com",
"xc.hubeijieshikj.cn",
"tj.tianwenhu.com",
"xg.gars57.cn",
"k.jinxiuzhilv.com",
"cdn.bootcss.com",
"ppl.xunzhuo123.com",
"xomk.jiangjunmh.top",
"img.xunzhuo123.com",
"z1.cnzz.com",
"s13.cnzz.com",
"xg.huataisangao.cn",
"z7.cnzz.com",
"xg.huataisangao.cn",
"z2.cnzz.com",
"s96.cnzz.com",
"q11.cnzz.com",
"thy.dacedsfa.cn",
"xg.whsbpw.cn",
"s19.cnzz.com",
"z8.cnzz.com",
"s4.cnzz.com",
"f5w.as12df.top",
"ae01.alicdn.com",
"www.92424.cn",
"k.wudejia.com",
"vivovip.mmszxc.top",
"qiu.xixiqiu.com",
"cdnjs.hnfenxun.com",
"cms.qdwght.com"
]
};
// 根据format参数返回不同格式
if (format === 'txt') {
// 返回base64编码的配置(TVBox常用格式)
const configStr = JSON.stringify(tvboxConfig, null, 2);
const base64Config = Buffer.from(configStr).toString('base64');
return new NextResponse(base64Config, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type',
'Cache-Control': 'public, max-age=3600'
}
});
} else {
// 返回JSON格式
return NextResponse.json(tvboxConfig, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type',
'Cache-Control': 'public, max-age=3600'
}
});
}
} catch (error) {
return NextResponse.json(
{ error: 'TVBox配置生成失败', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// 支持CORS预检请求
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
}
});
}
+124
View File
@@ -0,0 +1,124 @@
'use client';
import { useCallback, useState } from 'react';
export const dynamic = 'force-dynamic';
export default function ConfigPage() {
const [copied, setCopied] = useState(false);
const [format, setFormat] = useState<'json' | 'base64'>('json');
const getConfigUrl = useCallback(() => {
if (typeof window === 'undefined') return '';
const baseUrl = window.location.origin;
return `${baseUrl}/api/tvbox?format=${format}`;
}, [format]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(getConfigUrl());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Copy failed silently
}
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-8">
TVBox
</h1>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<select
value={format}
onChange={(e) => setFormat(e.target.value as 'json' | 'base64')}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="json">JSON </option>
<option value="base64">Base64 </option>
</select>
</div>
<div className="flex items-center space-x-2">
<input
type="text"
readOnly
value={getConfigUrl()}
className="flex-1 p-3 border border-gray-300 dark:border-gray-600 rounded-md bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
/>
<button
onClick={handleCopy}
className={`px-4 py-3 rounded-md font-medium transition-colors ${
copied
? 'bg-green-500 text-white'
: 'bg-blue-500 hover:bg-blue-600 text-white'
}`}
>
{copied ? '已复制' : '复制'}
</button>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
使
</h2>
<div className="space-y-4 text-gray-700 dark:text-gray-300">
<div>
<h3 className="font-semibold text-lg mb-2">1. </h3>
<p> JSON Base64 </p>
</div>
<div>
<h3 className="font-semibold text-lg mb-2">2. TVBox</h3>
<p> TVBox </p>
</div>
<div>
<h3 className="font-semibold text-lg mb-2">3. 使</h3>
<p> TVBox </p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
</h2>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<h3 className="font-semibold text-gray-900 dark:text-white"></h3>
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-gray-900 dark:text-white"></h3>
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li> TVBox</li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</div>
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export const dynamic = 'force-dynamic';
export default function TVBoxPage() {
const router = useRouter();
useEffect(() => {
// 重定向到新的配置页面
router.replace('/config');
}, [router]);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-gray-600 dark:text-gray-300">...</p>
</div>
</div>
);
}
+65 -112
View File
@@ -1,10 +1,11 @@
'use client';
/* eslint-disable @next/next/no-img-element */
import { useRouter } from 'next/navigation';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -17,13 +18,13 @@ interface VideoInfo {
quality: string;
loadSpeed: string;
pingTime: number;
hasError?: boolean; // 添加错误状态标识
hasError?: boolean;
}
interface EpisodeSelectorProps {
/** 总集数 */
totalEpisodes: number;
/** 每页显示多少集,默认 50 */
/** 每页显示多少集,默认 10 */
episodesPerPage?: number;
/** 当前选中的集数(1 开始) */
value?: number;
@@ -47,7 +48,7 @@ interface EpisodeSelectorProps {
*/
const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
totalEpisodes,
episodesPerPage = 50,
episodesPerPage = 10,
value = 1,
onChange,
onSourceChange,
@@ -96,7 +97,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
// 是否倒序显示
const [descending, setDescending] = useState<boolean>(false);
// 获取视频信息的函数 - 移除 attemptedSources 依赖避免不必要的重新创建
// 获取视频信息的函数
const getVideoInfo = useCallback(async (source: SearchResult) => {
const sourceKey = `${source.source}-${source.id}`;
@@ -134,7 +135,6 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
// 当有预计算结果时,先合并到videoInfoMap中
useEffect(() => {
if (precomputedVideoInfo && precomputedVideoInfo.size > 0) {
// 原子性地更新两个状态,避免时序问题
setVideoInfoMap((prev) => {
const newMap = new Map(prev);
precomputedVideoInfo.forEach((value, key) => {
@@ -146,107 +146,61 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
setAttemptedSources((prev) => {
const newSet = new Set(prev);
precomputedVideoInfo.forEach((info, key) => {
if (!info.hasError) {
newSet.add(key);
}
newSet.add(key);
});
return newSet;
});
// 同步更新 ref,确保 getVideoInfo 能立即看到更新
precomputedVideoInfo.forEach((info, key) => {
if (!info.hasError) {
attemptedSourcesRef.current.add(key);
}
});
}
}, [precomputedVideoInfo]);
// 读取本地“优选和测速”开关,默认开启
const [optimizationEnabled] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('enableOptimization');
if (saved !== null) {
try {
return JSON.parse(saved);
} catch {
/* ignore */
}
}
}
return true;
});
// 当切换到换源tab并且有源数据时,异步获取视频信息 - 移除 attemptedSources 依赖避免循环触发
// 当换源Tab激活且没有测速过时,开始测速
useEffect(() => {
const fetchVideoInfosInBatches = async () => {
if (
!optimizationEnabled || // 若关闭测速则直接退出
activeTab !== 'sources' ||
availableSources.length === 0
)
return;
// 筛选出尚未测速的播放源
const pendingSources = availableSources.filter((source) => {
if (activeTab === 'sources') {
availableSources.forEach((source) => {
const sourceKey = `${source.source}-${source.id}`;
return !attemptedSourcesRef.current.has(sourceKey);
if (!attemptedSourcesRef.current.has(sourceKey)) {
getVideoInfo(source);
}
});
}
}, [activeTab, availableSources, getVideoInfo]);
if (pendingSources.length === 0) return;
const batchSize = Math.ceil(pendingSources.length / 2);
for (let start = 0; start < pendingSources.length; start += batchSize) {
const batch = pendingSources.slice(start, start + batchSize);
await Promise.all(batch.map(getVideoInfo));
}
};
fetchVideoInfosInBatches();
// 依赖项保持与之前一致
}, [activeTab, availableSources, getVideoInfo, optimizationEnabled]);
// 升序分页标签
const categoriesAsc = useMemo(() => {
return Array.from({ length: pageCount }, (_, i) => {
const start = i * episodesPerPage + 1;
const end = Math.min(start + episodesPerPage - 1, totalEpisodes);
return `${start}-${end}`;
});
}, [pageCount, episodesPerPage, totalEpisodes]);
// 分页标签始终保持升序
const categories = categoriesAsc;
// 分类标签容器和按钮的引用
const categoryContainerRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
// 当分页切换时,将激活的分页标签滚动到视口中间
// 自动滚动到当前分页标签
useEffect(() => {
const btn = buttonRefs.current[currentPage];
const container = categoryContainerRef.current;
if (btn && container) {
// 手动计算滚动位置,只滚动分页标签容器
const containerRect = container.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
const scrollLeft = container.scrollLeft;
if (categoryContainerRef.current && buttonRefs.current[currentPage]) {
const container = categoryContainerRef.current;
const button = buttonRefs.current[currentPage];
if (button) {
const containerRect = container.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
const scrollLeft = container.scrollLeft;
// 计算按钮相对于容器的位置
const btnLeft = btnRect.left - containerRect.left + scrollLeft;
const btnWidth = btnRect.width;
const containerWidth = containerRect.width;
// 计算目标滚动位置,使按钮居中
const targetScrollLeft = btnLeft - (containerWidth - btnWidth) / 2;
// 平滑滚动到目标位置
container.scrollTo({
left: targetScrollLeft,
behavior: 'smooth',
});
if (buttonRect.left < containerRect.left) {
container.scrollTo({
left: scrollLeft - (containerRect.left - buttonRect.left) - 20,
behavior: 'smooth',
});
} else if (buttonRect.right > containerRect.right) {
container.scrollTo({
left: scrollLeft + (buttonRect.right - containerRect.right) + 20,
behavior: 'smooth',
});
}
}
}
}, [currentPage, pageCount]);
}, [currentPage]);
// 生成分页标签
const categories = Array.from({ length: pageCount }, (_, i) => {
const start = i * episodesPerPage + 1;
const end = Math.min(start + episodesPerPage - 1, totalEpisodes);
return start === end ? `${start}` : `${start}-${end}`;
});
// 处理换源tab点击,只在点击时才搜索
const handleSourceTabClick = () => {
@@ -285,11 +239,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
<div
onClick={() => setActiveTab('episodes')}
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
${
activeTab === 'episodes'
? 'text-green-600 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
}
${
activeTab === 'episodes'
? 'text-green-600 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
}
`.trim()}
>
@@ -298,12 +252,12 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
<div
onClick={handleSourceTabClick}
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
${
activeTab === 'sources'
? 'text-green-600 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
}
`.trim()}
${
activeTab === 'sources'
? 'text-green-600 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
}
`.trim()}
>
</div>
@@ -325,7 +279,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
buttonRefs.current[idx] = el;
}}
onClick={() => handleCategoryClick(idx)}
className={`w-20 relative py-2 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 text-center
className={`w-20 relative py-2 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 text-center
${
isActive
? 'text-green-500 dark:text-green-400'
@@ -379,8 +333,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
return (
<button
key={episodeNumber}
onClick={() => handleEpisodeClick(episodeNumber - 1)}
className={`h-10 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200
onClick={() => handleEpisodeClick(episodeNumber)}
className={`h-10 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200
${
isActive
? 'bg-green-500 text-white shadow-lg shadow-green-500/25 dark:bg-green-600'
@@ -458,11 +412,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
!isCurrentSource && handleSourceClick(source)
}
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
${
isCurrentSource
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border'
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
}`.trim()}
${
isCurrentSource
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border'
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
}`.trim()}
>
{/* 封面 */}
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'>
@@ -498,7 +452,6 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
{(() => {
const sourceKey = `${source.source}-${source.id}`;
const videoInfo = videoInfoMap.get(sourceKey);
if (videoInfo && videoInfo.quality !== '未知') {
if (videoInfo.hasError) {
return (
@@ -568,7 +521,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
<div className='text-red-500/90 dark:text-red-400 font-medium text-xs'>
</div>
); // 占位div
);
}
}
})()}
+6 -1
View File
@@ -1,6 +1,6 @@
'use client';
import { Clover, Film, Home, Menu, Search, Tv } from 'lucide-react';
import { Clover, Film, Home, Menu, Search, Settings, Tv } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
@@ -138,6 +138,11 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
label: '综艺',
href: '/douban?type=show',
},
{
icon: Settings,
label: 'TVBox配置',
href: '/config',
},
];
return (
+4 -4
View File
@@ -435,7 +435,7 @@ export default function SkipController({
<div className="skip-controller">
{/* 倒计时显示 - 片尾自动跳转下一集 */}
{showCountdown && (
<div className="fixed top-20 left-1/2 transform -translate-x-1/2 z-50 bg-blue-600/90 text-white px-6 py-3 rounded-lg backdrop-blur-sm border border-white/20 shadow-lg animate-fade-in">
<div className="fixed top-20 left-1/2 transform -translate-x-1/2 z-[9999] bg-blue-600/90 text-white px-6 py-3 rounded-lg backdrop-blur-sm border border-white/20 shadow-lg animate-fade-in">
<div className="flex items-center space-x-3">
<svg className="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -460,7 +460,7 @@ export default function SkipController({
{/* 跳过按钮 */}
{showSkipButton && currentSkipSegment && (
<div className="fixed top-20 right-4 z-50 bg-black/80 text-white px-4 py-2 rounded-lg backdrop-blur-sm border border-white/20 shadow-lg animate-fade-in">
<div className="fixed top-20 right-4 z-[9999] bg-black/80 text-white px-4 py-2 rounded-lg backdrop-blur-sm border border-white/20 shadow-lg animate-fade-in">
<div className="flex items-center space-x-3">
<span className="text-sm">
{currentSkipSegment.type === 'opening' ? '检测到片头' : '检测到片尾'}
@@ -477,7 +477,7 @@ export default function SkipController({
{/* 设置模式面板 - 增强版批量设置 */}
{isSettingMode && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999] p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
@@ -691,7 +691,7 @@ export default function SkipController({
{/* 管理已有片段 - 优化布局避免重叠 */}
{skipConfig && skipConfig.segments && skipConfig.segments.length > 0 && !isSettingMode && (
<div className="fixed bottom-4 right-4 z-40 max-w-sm bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-200 dark:border-gray-600 animate-fade-in">
<div className="fixed bottom-4 right-4 z-[9998] max-w-sm bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-200 dark:border-gray-600 animate-fade-in">
<div className="p-3">
<h4 className="font-medium mb-2 text-gray-900 dark:text-gray-100 text-sm flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+15 -1
View File
@@ -2,7 +2,7 @@
'use client';
import { KeyRound, LogOut, Settings, Shield, User, X } from 'lucide-react';
import { KeyRound, LogOut, Settings, Shield, Tv, User, X } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -149,6 +149,11 @@ export const UserMenu: React.FC = () => {
router.push('/admin');
};
const handleTVBoxConfig = () => {
setIsOpen(false);
router.push('/config');
};
const handleChangePassword = () => {
setIsOpen(false);
setIsChangePasswordOpen(true);
@@ -363,6 +368,15 @@ export const UserMenu: React.FC = () => {
<span className='font-medium'></span>
</button>
{/* TVBox配置按钮 */}
<button
onClick={handleTVBoxConfig}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
>
<Tv className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'>TVBox配置</span>
</button>
{/* 管理面板按钮 */}
{showAdminPanel && (
<button
+32
View File
@@ -0,0 +1,32 @@
// CORS工具函数,用于为OrionTV客户端提供跨域支持
export function createCorsHeaders(): Headers {
const headers = new Headers();
// 设置CORS头部,允许OrionTV客户端跨域访问
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
headers.set('Access-Control-Max-Age', '86400'); // 24小时
return headers;
}
// 为NextResponse添加CORS头部
export function addCorsHeaders(response: Response): Response {
const corsHeaders = createCorsHeaders();
// 将CORS头部添加到现有响应头部中
corsHeaders.forEach((value, key) => {
response.headers.set(key, value);
});
return response;
}
// 处理OPTIONS预检请求
export function handleOptionsRequest(): Response {
return new Response(null, {
status: 200,
headers: createCorsHeaders(),
});
}
+5 -1
View File
@@ -2,16 +2,18 @@
import { AdminConfig } from './admin.types';
import { D1Storage } from './d1.db';
import { KvrocksStorage } from './kvrocks.db';
import { LocalStorage } from './localstorage.db';
import { RedisStorage } from './redis.db';
import { Favorite, IStorage, PlayRecord } from './types';
import { UpstashRedisStorage } from './upstash.db';
// storage type 常量: 'localstorage' | 'redis' | 'd1' | 'upstash',默认 'localstorage'
// storage type 常量: 'localstorage' | 'redis' | 'kvrocks' | 'd1' | 'upstash',默认 'localstorage'
const STORAGE_TYPE =
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
| 'localstorage'
| 'redis'
| 'kvrocks'
| 'd1'
| 'upstash'
| undefined) || 'localstorage';
@@ -21,6 +23,8 @@ function createStorage(): IStorage {
switch (STORAGE_TYPE) {
case 'redis':
return new RedisStorage();
case 'kvrocks':
return new KvrocksStorage();
case 'upstash':
return new UpstashRedisStorage();
case 'd1':
+392
View File
@@ -0,0 +1,392 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import { createClient, RedisClientType } from 'redis';
import { AdminConfig } from './admin.types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
// 搜索历史最大条数
const SEARCH_HISTORY_LIMIT = 20;
// 数据类型转换辅助函数
function ensureStringArray(value: any[]): string[] {
return value.map((item) => String(item));
}
// 添加Kvrocks操作重试包装器
async function withRetry<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
} catch (err: any) {
const isLastAttempt = i === maxRetries - 1;
const isConnectionError =
err.message?.includes('Connection') ||
err.message?.includes('ECONNREFUSED') ||
err.message?.includes('ENOTFOUND') ||
err.code === 'ECONNRESET' ||
err.code === 'EPIPE';
if (isConnectionError && !isLastAttempt) {
console.log(
`Kvrocks operation failed, retrying... (${i + 1}/${maxRetries})`
);
console.error('Error:', err.message);
// 等待一段时间后重试
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
// 尝试重新连接
try {
const client = getKvrocksClient();
if (!client.isOpen) {
await client.connect();
}
} catch (reconnectErr) {
console.error('Failed to reconnect to Kvrocks:', reconnectErr);
}
continue;
}
throw err;
}
}
throw new Error('Max retries exceeded');
}
export class KvrocksStorage implements IStorage {
private client: RedisClientType;
constructor() {
this.client = getKvrocksClient();
}
// ---------- 播放记录 ----------
private prKey(user: string, key: string) {
return `u:${user}:pr:${key}`; // u:username:pr:source+id
}
async getPlayRecord(
userName: string,
key: string
): Promise<PlayRecord | null> {
const val = await withRetry(() =>
this.client.get(this.prKey(userName, key))
);
return val ? (JSON.parse(val) as PlayRecord) : null;
}
async setPlayRecord(
userName: string,
key: string,
record: PlayRecord
): Promise<void> {
await withRetry(() =>
this.client.set(this.prKey(userName, key), JSON.stringify(record))
);
}
async getAllPlayRecords(
userName: string
): Promise<Record<string, PlayRecord>> {
const pattern = `u:${userName}:pr:*`;
const keys = await withRetry(() => this.client.keys(pattern));
const result: Record<string, PlayRecord> = {};
if (keys.length === 0) return result;
const values = await withRetry(() => this.client.mGet(keys));
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = values[i];
if (value) {
const recordKey = key.replace(`u:${userName}:pr:`, '');
result[recordKey] = JSON.parse(value) as PlayRecord;
}
}
return result;
}
async deletePlayRecord(userName: string, key: string): Promise<void> {
await withRetry(() => this.client.del(this.prKey(userName, key)));
}
// ---------- 收藏 ----------
private favKey(user: string, key: string) {
return `u:${user}:fav:${key}`; // u:username:fav:source+id
}
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
const val = await withRetry(() =>
this.client.get(this.favKey(userName, key))
);
return val ? (JSON.parse(val) as Favorite) : null;
}
async setFavorite(
userName: string,
key: string,
favorite: Favorite
): Promise<void> {
await withRetry(() =>
this.client.set(this.favKey(userName, key), JSON.stringify(favorite))
);
}
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
const pattern = `u:${userName}:fav:*`;
const keys = await withRetry(() => this.client.keys(pattern));
const result: Record<string, Favorite> = {};
if (keys.length === 0) return result;
const values = await withRetry(() => this.client.mGet(keys));
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = values[i];
if (value) {
const favKey = key.replace(`u:${userName}:fav:`, '');
result[favKey] = JSON.parse(value) as Favorite;
}
}
return result;
}
async deleteFavorite(userName: string, key: string): Promise<void> {
await withRetry(() => this.client.del(this.favKey(userName, key)));
}
// ---------- 搜索历史 ----------
private searchHistoryKey(user: string) {
return `u:${user}:search_history`;
}
async getSearchHistory(userName: string): Promise<string[]> {
const items = await withRetry(() =>
this.client.lRange(this.searchHistoryKey(userName), 0, -1)
);
return ensureStringArray(items);
}
async addSearchHistory(userName: string, query: string): Promise<void> {
const key = this.searchHistoryKey(userName);
await withRetry(async () => {
// 先移除可能存在的重复项
await this.client.lRem(key, 0, query);
// 添加到开头
await this.client.lPush(key, query);
// 保持数量限制
await this.client.lTrim(key, 0, SEARCH_HISTORY_LIMIT - 1);
});
}
async deleteSearchHistory(userName: string, query?: string): Promise<void> {
if (query) {
// 删除特定搜索项
const key = this.searchHistoryKey(userName);
await withRetry(() => this.client.lRem(key, 0, query));
} else {
// 清空全部搜索历史
await withRetry(() => this.client.del(this.searchHistoryKey(userName)));
}
}
// ---------- 片头片尾跳过配置 ----------
private skipConfigKey(userName: string, key: string) {
return `u:${userName}:skip_config:${key}`;
}
async getSkipConfig(userName: string, key: string): Promise<EpisodeSkipConfig | null> {
const val = await withRetry(() =>
this.client.get(this.skipConfigKey(userName, key))
);
return val ? (JSON.parse(val) as EpisodeSkipConfig) : null;
}
async setSkipConfig(
userName: string,
key: string,
config: EpisodeSkipConfig
): Promise<void> {
await withRetry(() =>
this.client.set(this.skipConfigKey(userName, key), JSON.stringify(config))
);
}
async deleteSkipConfig(userName: string, key: string): Promise<void> {
await withRetry(() => this.client.del(this.skipConfigKey(userName, key)));
}
async getAllSkipConfigs(userName: string): Promise<Record<string, EpisodeSkipConfig>> {
const pattern = `u:${userName}:skip_config:*`;
const keys = await withRetry(() => this.client.keys(pattern));
const result: Record<string, EpisodeSkipConfig> = {};
if (keys.length === 0) return result;
const values = await withRetry(() => this.client.mGet(keys));
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = values[i];
if (value) {
const configKey = key.replace(`u:${userName}:skip_config:`, '');
result[configKey] = JSON.parse(value) as EpisodeSkipConfig;
}
}
return result;
}
// ---------- 用户相关 ----------
private userKey(userName: string) {
return `user:${userName}`;
}
private userListKey() {
return 'user_list';
}
async getUser(userName: string): Promise<any> {
const val = await withRetry(() => this.client.get(this.userKey(userName)));
return val ? JSON.parse(val) : null;
}
async setUser(userName: string, userData: any): Promise<void> {
await withRetry(async () => {
await this.client.set(this.userKey(userName), JSON.stringify(userData));
// 同时添加到用户列表
await this.client.sAdd(this.userListKey(), userName);
});
}
async getAllUsers(): Promise<string[]> {
const users = await withRetry(() => this.client.sMembers(this.userListKey()));
return ensureStringArray(users);
}
async registerUser(userName: string, password: string): Promise<void> {
const userData = {
username: userName,
password: password, // 这里传入的应该是已经hash的密码
created_at: Date.now(),
};
await this.setUser(userName, userData);
}
async verifyUser(userName: string, password: string): Promise<boolean> {
const userData = await this.getUser(userName);
return userData && userData.password === password;
}
async checkUserExist(userName: string): Promise<boolean> {
const userData = await this.getUser(userName);
return userData !== null;
}
async changePassword(userName: string, newPassword: string): Promise<void> {
const userData = await this.getUser(userName);
if (userData) {
userData.password = newPassword;
await this.setUser(userName, userData);
}
}
async deleteUser(userName: string): Promise<void> {
await withRetry(async () => {
// 删除用户数据
await this.client.del(this.userKey(userName));
// 从用户列表中移除
await this.client.sRem(this.userListKey(), userName);
// 删除用户的所有相关数据
const patterns = [
`u:${userName}:pr:*`, // 播放记录
`u:${userName}:fav:*`, // 收藏
`u:${userName}:search_history`, // 搜索历史
`u:${userName}:skip_config:*`, // 跳过配置
];
for (const pattern of patterns) {
const keys = await this.client.keys(pattern);
if (keys.length > 0) {
await this.client.del(keys);
}
}
});
}
// ---------- 管理员配置 ----------
private adminConfigKey() {
return 'admin_config';
}
async getAdminConfig(): Promise<AdminConfig | null> {
const val = await withRetry(() => this.client.get(this.adminConfigKey()));
return val ? (JSON.parse(val) as AdminConfig) : null;
}
async setAdminConfig(config: AdminConfig): Promise<void> {
await withRetry(() =>
this.client.set(this.adminConfigKey(), JSON.stringify(config))
);
}
}
// Kvrocks客户端单例
let kvrocksClient: RedisClientType | null = null;
export function getKvrocksClient(): RedisClientType {
if (!kvrocksClient) {
// 从环境变量读取Kvrocks连接信息
const kvrocksUrl = process.env.KVROCKS_URL || 'redis://localhost:6666';
const kvrocksPassword = process.env.KVROCKS_PASSWORD;
const kvrocksDatabase = parseInt(process.env.KVROCKS_DATABASE || '0');
console.log('🏪 Initializing Kvrocks client...');
console.log('🔗 Kvrocks URL:', kvrocksUrl.replace(/\/\/.*@/, '//***:***@'));
kvrocksClient = createClient({
url: kvrocksUrl,
password: kvrocksPassword,
database: kvrocksDatabase,
socket: {
connectTimeout: 10000, // 10秒连接超时
reconnectStrategy: (retries: number) => {
const delay = Math.min(retries * 50, 2000);
console.log(`🔄 Kvrocks reconnecting in ${delay}ms (attempt ${retries})`);
return delay;
},
},
});
kvrocksClient.on('error', (err) => {
console.error('❌ Kvrocks Client Error:', err);
});
kvrocksClient.on('connect', () => {
console.log('✅ Kvrocks Client Connected');
});
kvrocksClient.on('reconnecting', () => {
console.log('🔄 Kvrocks Client Reconnecting...');
});
kvrocksClient.on('ready', () => {
console.log('🚀 Kvrocks Client Ready');
});
// 初始连接
kvrocksClient.connect().catch((err) => {
console.error('❌ Failed to connect to Kvrocks:', err);
});
}
return kvrocksClient;
}
+1 -1
View File
@@ -2,7 +2,7 @@
'use client';
const CURRENT_VERSION = '20250902153459';
const CURRENT_VERSION = '20250903203337';
// 版本检查结果枚举
export enum UpdateStatus {
+1 -1
View File
@@ -133,6 +133,6 @@ function shouldSkipAuth(pathname: string): boolean {
// 配置middleware匹配规则
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/cron|api/server-config).*)',
'/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/cron|api/server-config|api/search|api/detail|api/image-proxy|api/tvbox).*)',
],
};
+7
View File
@@ -11,10 +11,17 @@ const config: Config = {
theme: {
extend: {
screens: {
'xs': '475px',
'mobile-landscape': {
raw: '(orientation: landscape) and (max-height: 700px)',
},
},
gridTemplateColumns: {
'13': 'repeat(13, minmax(0, 1fr))',
'14': 'repeat(14, minmax(0, 1fr))',
'15': 'repeat(15, minmax(0, 1fr))',
'16': 'repeat(16, minmax(0, 1fr))',
},
fontFamily: {
primary: ['Inter', ...defaultTheme.fontFamily.sans],
},
+14
View File
@@ -0,0 +1,14 @@
{
"cache_time": 7200,
"api_site": {
"test_source": {
"api": "https://test.example.com/api.php/provide/vod",
"name": "测试视频源",
"detail": "https://test.example.com"
},
"another_test": {
"api": "https://another.example.com/api.php/provide/vod",
"name": "另一个测试源"
}
}
}