Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 222126e50f | |||
| 3783fbdd00 | |||
| c4458ae23a | |||
| ac29b75457 | |||
| 1ca36f7454 | |||
| 2294f1b066 | |||
| b4ebe89292 | |||
| 54b4388685 | |||
| 66a6fd0392 | |||
| d563ca165d | |||
| cdd60356eb | |||
| d8e8510e5e | |||
| 1e3467fff2 | |||
| c69e9a380f | |||
| 53ef9281ba | |||
| fa958d0987 | |||
| f545058bf8 | |||
| aa03a0b932 | |||
| 5dacbc027d | |||
| 0b60840097 | |||
| dd01a91383 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
### 🎉 新增功能
|
||||
|
||||
@@ -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) — 由此启发,站在巨人的肩膀上。
|
||||
|
||||
@@ -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 部署的关键问题,建议所有用户升级。
|
||||
@@ -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
@@ -1 +1 @@
|
||||
20250902153459
|
||||
20250903203337
|
||||
+1
-19
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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} didn’t 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} didn’t 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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
})()}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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':
|
||||
|
||||
@@ -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
@@ -2,7 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
const CURRENT_VERSION = '20250902153459';
|
||||
const CURRENT_VERSION = '20250903203337';
|
||||
|
||||
// 版本检查结果枚举
|
||||
export enum UpdateStatus {
|
||||
|
||||
+1
-1
@@ -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).*)',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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": "另一个测试源"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user