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 项目的重要更新和功能变更。
|
本文档记录 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
|
## [0.5.0] - 2025-09-02
|
||||||
|
|
||||||
### 🎉 新增功能
|
### 🎉 新增功能
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
|
|
||||||
本项目自「MoonTV」演进而来,为其二创/继承版本,持续维护与改进功能与体验。保留并致谢原作者与社区贡献者;如有授权或版权问题请联系以处理。目标:在原作基础上提供更易部署、更友好、更稳定的体验。
|
本项目自「MoonTV」演进而来,为其二创/继承版本,持续维护与改进功能与体验。保留并致谢原作者与社区贡献者;如有授权或版权问题请联系以处理。目标:在原作基础上提供更易部署、更友好、更稳定的体验。
|
||||||
|
|
||||||
|
> **🔔 重要变更通知**:应用户社区的宝贵建议,为确保项目的长期稳定运行和合规性,我们已将内置的视频源移除。现在用户需要自行配置资源站以使用本应用的完整功能。我们提供了经过测试的推荐配置文件,让您能够快速上手使用(具体配置文件见 README.md 内容底部)。
|
||||||
|
|
||||||
## ✨ 功能特性
|
## ✨ 功能特性
|
||||||
|
|
||||||
### 🎬 核心播放功能
|
### 🎬 核心播放功能
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
- **☁️ 多平台支持**:Vercel、Cloudflare Pages、传统服务器全兼容
|
- **☁️ 多平台支持**:Vercel、Cloudflare Pages、传统服务器全兼容
|
||||||
- **🔧 灵活配置**:支持自定义资源站、代理设置、主题配置
|
- **🔧 灵活配置**:支持自定义资源站、代理设置、主题配置
|
||||||
- **📱 PWA 支持**:可安装为桌面/手机应用,离线缓存
|
- **📱 PWA 支持**:可安装为桌面/手机应用,离线缓存
|
||||||
|
- **📺 TVBox 兼容**:支持 TVBox 配置接口,可导入到各种电视盒子应用
|
||||||
|
|
||||||
### 🎨 用户体验
|
### 🎨 用户体验
|
||||||
|
|
||||||
@@ -61,18 +64,50 @@
|
|||||||
| 代码质量 | ESLint · Prettier · Jest · Husky |
|
| 代码质量 | ESLint · Prettier · Jest · Husky |
|
||||||
| 部署 | Docker · Vercel · CloudFlare pages |
|
| 部署 | 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 单容器** | ⭐ | 需服务器 | ❌ | ⭐⭐ | 个人使用,最简单 |
|
||||||
| 🐳 **Docker + Redis** | ⭐⭐ | 需服务器 | ✅ | 家庭/团队,功能完整 |
|
| 🐳 **Docker + Redis** | ⭐⭐ | 需服务器 | ✅ | ⭐⭐⭐ | 家庭/团队,功能完整 |
|
||||||
| ☁️ **Vercel** | ⭐ | 免费 | ❌ | 临时体验,无服务器 |
|
| 🏪 **Docker + Kvrocks** | ⭐⭐ | 需服务器 | ✅ | ⭐⭐⭐⭐⭐ | 生产环境,高可靠性 |
|
||||||
| 🌐 **Cloudflare** | ⭐⭐⭐ | 免费 | ✅ | 技术爱好者 |
|
| ☁️ **Vercel** | ⭐ | 免费 | ❌ | ⭐ | 临时体验,无服务器 |
|
||||||
|
| 🌐 **Cloudflare** | ⭐⭐⭐ | 免费 | ✅ | ⭐⭐⭐ | 技术爱好者 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -114,9 +149,21 @@ docker run -d \
|
|||||||
2. 如果是本机安装,访问:`http://localhost:3000`
|
2. 如果是本机安装,访问:`http://localhost:3000`
|
||||||
3. 输入你在第二步设置的密码即可进入
|
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
|
```bash
|
||||||
# 先停止并删除旧容器
|
# 先停止并删除旧容器
|
||||||
@@ -135,6 +182,13 @@ docker run -d \
|
|||||||
> **路径说明**:把 `/path/to/your/config.json` 替换成你的配置文件完整路径
|
> **路径说明**:把 `/path/to/your/config.json` 替换成你的配置文件完整路径
|
||||||
> **Windows 示例**:`-v C:/Users/你的用户名/Desktop/config.json:/app/config.json:ro`
|
> **Windows 示例**:`-v C:/Users/你的用户名/Desktop/config.json:/app/config.json:ro`
|
||||||
|
|
||||||
|
##### 🛡️ 免责声明
|
||||||
|
|
||||||
|
- 提供的配置文件仅为方便用户测试和学习使用
|
||||||
|
- 所有视频源均来源于公开的网络资源,请用户自行判断使用的合法性
|
||||||
|
- 我们不对任何第三方视频源的内容、质量或合法性负责
|
||||||
|
- 建议用户仅使用合法、正版的视频源
|
||||||
|
|
||||||
### 🛠️ 常用管理命令
|
### 🛠️ 常用管理命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -247,6 +301,31 @@ docker compose ps
|
|||||||
3. 登录后访问 `http://你的服务器IP:3000/admin` 进入管理后台
|
3. 登录后访问 `http://你的服务器IP:3000/admin` 进入管理后台
|
||||||
4. 在管理后台可以配置资源站、管理用户等
|
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
|
```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` 文件
|
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 加速,免费但配置复杂
|
> **适合场景**:技术爱好者,想要全球 CDN 加速,免费但配置复杂
|
||||||
|
|
||||||
@@ -383,6 +622,22 @@ docker run --rm -v katelyatv-redis-data:/data -v $(pwd):/backup alpine tar xzf /
|
|||||||
2. 点击最新部署旁的 **...** → **重试部署**
|
2. 点击最新部署旁的 **...** → **重试部署**
|
||||||
3. 等待部署成功
|
3. 等待部署成功
|
||||||
|
|
||||||
|
#### 第六步:配置资源站
|
||||||
|
|
||||||
|
> **📢 重要提醒**:为保障项目长期稳定运行,应用户建议已移除内置视频源,需要配置资源站。
|
||||||
|
|
||||||
|
##### 推荐配置方法:
|
||||||
|
|
||||||
|
1. **下载配置文件**:[点击下载 config.json](https://www.mediafire.com/file/xl3yo7la2ci378w/config.json/file)
|
||||||
|
2. **更新仓库配置**:
|
||||||
|
- 回到你的 GitHub 仓库
|
||||||
|
- 找到 `config.json` 文件,点击编辑
|
||||||
|
- 用下载的内容替换原有配置
|
||||||
|
- 提交更改
|
||||||
|
3. **等待自动部署**:Cloudflare Pages 会自动重新构建部署
|
||||||
|
|
||||||
|
🎉 **完成!现在可以正常使用影视聚合功能了**
|
||||||
|
|
||||||
### 🗄️ 启用 D1 数据库(可选,支持多用户)
|
### 🗄️ 启用 D1 数据库(可选,支持多用户)
|
||||||
|
|
||||||
如果你想要用户系统和数据同步:
|
如果你想要用户系统和数据同步:
|
||||||
@@ -955,10 +1210,76 @@ KatelyaTV 支持标准的苹果 CMS V10 API 格式。
|
|||||||
|
|
||||||
站长或管理员访问 `/admin` 即可进行管理员配置
|
站长或管理员访问 `/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 使用
|
## 📱 AndroidTV 使用
|
||||||
|
|
||||||
目前该项目可以配合 [OrionTV](https://github.com/zimplexing/OrionTV) 在 Android TV 上使用,可以直接作为 OrionTV 后端
|
目前该项目可以配合 [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
|
## 🗓️ Roadmap
|
||||||
@@ -1033,7 +1354,40 @@ KatelyaTV 支持标准的苹果 CMS V10 API 格式。
|
|||||||
|
|
||||||
</div>
|
</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) — 项目最初基于该脚手架。
|
- [ts-nextjs-tailwind-starter](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter) — 项目最初基于该脚手架。
|
||||||
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 由此启发,站在巨人的肩膀上。
|
- [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": {
|
"api_site": {
|
||||||
"example_test": {
|
"example_test": {
|
||||||
"api": "https://example.com/api.php/provide/vod",
|
"api": "https://example.com/api.php/provide/vod",
|
||||||
"name": "测试视频源",
|
"name": "示例视频源",
|
||||||
"detail": "https://example.com"
|
"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",
|
"name": "katelyatv",
|
||||||
"version": "0.5.0-katelya",
|
"version": "0.6.0-katelya",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run gen:runtime && npm run gen:manifest && next dev -H 0.0.0.0",
|
"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 [sources, setSources] = useState<DataSource[]>([]);
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [orderChanged, setOrderChanged] = 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>({
|
const [newSource, setNewSource] = useState<DataSource>({
|
||||||
name: '',
|
name: '',
|
||||||
key: '',
|
key: '',
|
||||||
@@ -691,6 +693,13 @@ const VideoSourceConfig = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (key: string) => {
|
const handleDelete = (key: string) => {
|
||||||
|
// 检查是否为示例源
|
||||||
|
const source = sources.find(s => s.key === key);
|
||||||
|
if (source?.from === 'config') {
|
||||||
|
showError('示例源不可删除,这些源用于演示功能');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
callSourceApi({ action: 'delete', key }).catch(() => {
|
callSourceApi({ action: 'delete', key }).catch(() => {
|
||||||
console.error('操作失败', 'delete', key);
|
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 handleDragEnd = (event: any) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
@@ -757,6 +1027,7 @@ const VideoSourceConfig = ({
|
|||||||
style={style}
|
style={style}
|
||||||
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
|
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
|
||||||
>
|
>
|
||||||
|
{/* 拖拽手柄 */}
|
||||||
<td
|
<td
|
||||||
className='px-2 py-4 cursor-grab text-gray-400'
|
className='px-2 py-4 cursor-grab text-gray-400'
|
||||||
style={{ touchAction: 'none' }}
|
style={{ touchAction: 'none' }}
|
||||||
@@ -765,8 +1036,28 @@ const VideoSourceConfig = ({
|
|||||||
>
|
>
|
||||||
<GripVertical size={16} />
|
<GripVertical size={16} />
|
||||||
</td>
|
</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'>
|
<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>
|
||||||
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
|
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
|
||||||
{source.key}
|
{source.key}
|
||||||
@@ -805,13 +1096,17 @@ const VideoSourceConfig = ({
|
|||||||
>
|
>
|
||||||
{!source.disabled ? '禁用' : '启用'}
|
{!source.disabled ? '禁用' : '启用'}
|
||||||
</button>
|
</button>
|
||||||
{source.from !== 'config' && (
|
{source.from !== 'config' ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(source.key)}
|
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'
|
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>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -828,17 +1123,80 @@ const VideoSourceConfig = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<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 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
视频源列表
|
视频源列表
|
||||||
</h4>
|
</h4>
|
||||||
<button
|
|
||||||
onClick={() => setShowAddForm(!showAddForm)}
|
<div className='flex items-center gap-2 flex-wrap'>
|
||||||
className='px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors'
|
{/* 批量操作区域 */}
|
||||||
>
|
{!batchMode ? (
|
||||||
{showAddForm ? '取消' : '添加视频源'}
|
<>
|
||||||
</button>
|
{/* 普通模式按钮 */}
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
@@ -898,7 +1256,21 @@ const VideoSourceConfig = ({
|
|||||||
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
||||||
<thead className='bg-gray-50 dark:bg-gray-900'>
|
<thead className='bg-gray-50 dark:bg-gray-900'>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* 拖拽手柄列 */}
|
||||||
<th className='w-8' />
|
<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 className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||||
名称
|
名称
|
||||||
</th>
|
</th>
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||||
|
import { addCorsHeaders, handleOptionsRequest } from '@/lib/cors';
|
||||||
import { getDetailFromApi } from '@/lib/downstream';
|
import { getDetailFromApi } from '@/lib/downstream';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
// 处理OPTIONS预检请求(OrionTV客户端需要)
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return handleOptionsRequest();
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const id = searchParams.get('id');
|
const id = searchParams.get('id');
|
||||||
const sourceCode = searchParams.get('source');
|
const sourceCode = searchParams.get('source');
|
||||||
|
|
||||||
if (!id || !sourceCode) {
|
if (!id || !sourceCode) {
|
||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
const response = NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
|
return addCorsHeaders(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^[\w-]+$/.test(id)) {
|
if (!/^[\w-]+$/.test(id)) {
|
||||||
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
|
const response = NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
|
||||||
|
return addCorsHeaders(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -23,23 +31,26 @@ export async function GET(request: Request) {
|
|||||||
const apiSite = apiSites.find((site) => site.key === sourceCode);
|
const apiSite = apiSites.find((site) => site.key === sourceCode);
|
||||||
|
|
||||||
if (!apiSite) {
|
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 result = await getDetailFromApi(apiSite, id);
|
||||||
const cacheTime = await getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
|
|
||||||
return NextResponse.json(result, {
|
const response = NextResponse.json(result, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return addCorsHeaders(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
const response = NextResponse.json(
|
||||||
{ error: (error as Error).message },
|
{ error: (error as Error).message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
return addCorsHeaders(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { addCorsHeaders, handleOptionsRequest } from '@/lib/cors';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
// 处理OPTIONS预检请求(OrionTV客户端需要)
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return handleOptionsRequest();
|
||||||
|
}
|
||||||
|
|
||||||
// OrionTV 兼容接口
|
// OrionTV 兼容接口
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const imageUrl = searchParams.get('url');
|
const imageUrl = searchParams.get('url');
|
||||||
|
|
||||||
if (!imageUrl) {
|
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 {
|
try {
|
||||||
@@ -21,19 +29,21 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!imageResponse.ok) {
|
if (!imageResponse.ok) {
|
||||||
return NextResponse.json(
|
const response = NextResponse.json(
|
||||||
{ error: imageResponse.statusText },
|
{ error: imageResponse.statusText },
|
||||||
{ status: imageResponse.status }
|
{ status: imageResponse.status }
|
||||||
);
|
);
|
||||||
|
return addCorsHeaders(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = imageResponse.headers.get('content-type');
|
const contentType = imageResponse.headers.get('content-type');
|
||||||
|
|
||||||
if (!imageResponse.body) {
|
if (!imageResponse.body) {
|
||||||
return NextResponse.json(
|
const response = NextResponse.json(
|
||||||
{ error: 'Image response has no body' },
|
{ error: 'Image response has no body' },
|
||||||
{ status: 500 }
|
{ 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');
|
headers.set('Vercel-CDN-Cache-Control', 'public, s-maxage=15720000');
|
||||||
|
|
||||||
// 直接返回图片流
|
// 直接返回图片流
|
||||||
return new Response(imageResponse.body, {
|
const response = new Response(imageResponse.body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
return addCorsHeaders(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
const response = NextResponse.json(
|
||||||
{ error: 'Error fetching image' },
|
{ error: 'Error fetching image' },
|
||||||
{ status: 500 }
|
{ 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 { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||||
|
import { addCorsHeaders, handleOptionsRequest } from '@/lib/cors';
|
||||||
import { searchFromApi } from '@/lib/downstream';
|
import { searchFromApi } from '@/lib/downstream';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
// 处理OPTIONS预检请求(OrionTV客户端需要)
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return handleOptionsRequest();
|
||||||
|
}
|
||||||
|
|
||||||
// OrionTV 兼容接口
|
// OrionTV 兼容接口
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
@@ -13,7 +19,7 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
if (!query || !resourceId) {
|
if (!query || !resourceId) {
|
||||||
const cacheTime = await getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
return NextResponse.json(
|
const response = NextResponse.json(
|
||||||
{ result: null, error: '缺少必要参数: q 或 resourceId' },
|
{ result: null, error: '缺少必要参数: q 或 resourceId' },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -23,6 +29,7 @@ export async function GET(request: Request) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
return addCorsHeaders(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiSites = await getAvailableApiSites();
|
const apiSites = await getAvailableApiSites();
|
||||||
@@ -31,13 +38,14 @@ export async function GET(request: Request) {
|
|||||||
// 根据 resourceId 查找对应的 API 站点
|
// 根据 resourceId 查找对应的 API 站点
|
||||||
const targetSite = apiSites.find((site) => site.key === resourceId);
|
const targetSite = apiSites.find((site) => site.key === resourceId);
|
||||||
if (!targetSite) {
|
if (!targetSite) {
|
||||||
return NextResponse.json(
|
const response = NextResponse.json(
|
||||||
{
|
{
|
||||||
error: `未找到指定的视频源: ${resourceId}`,
|
error: `未找到指定的视频源: ${resourceId}`,
|
||||||
result: null,
|
result: null,
|
||||||
},
|
},
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
|
return addCorsHeaders(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await searchFromApi(targetSite, query);
|
const results = await searchFromApi(targetSite, query);
|
||||||
@@ -45,15 +53,16 @@ export async function GET(request: Request) {
|
|||||||
const cacheTime = await getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
return NextResponse.json(
|
const response = NextResponse.json(
|
||||||
{
|
{
|
||||||
error: '未找到结果',
|
error: '未找到结果',
|
||||||
result: null,
|
result: null,
|
||||||
},
|
},
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
|
return addCorsHeaders(response);
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json(
|
const response = NextResponse.json(
|
||||||
{ results: result },
|
{ results: result },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -63,14 +72,16 @@ export async function GET(request: Request) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
return addCorsHeaders(response);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
const response = NextResponse.json(
|
||||||
{
|
{
|
||||||
error: '搜索失败',
|
error: '搜索失败',
|
||||||
result: null,
|
result: null,
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
return addCorsHeaders(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||||
|
import { addCorsHeaders, handleOptionsRequest } from '@/lib/cors';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
// 处理OPTIONS预检请求(OrionTV客户端需要)
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return handleOptionsRequest();
|
||||||
|
}
|
||||||
|
|
||||||
// OrionTV 兼容接口
|
// OrionTV 兼容接口
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const apiSites = await getAvailableApiSites();
|
const apiSites = await getAvailableApiSites();
|
||||||
const cacheTime = await getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
|
|
||||||
return NextResponse.json(apiSites, {
|
const response = NextResponse.json(apiSites, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return addCorsHeaders(response);
|
||||||
} catch (error) {
|
} 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 { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||||
|
import { addCorsHeaders, handleOptionsRequest } from '@/lib/cors';
|
||||||
import { searchFromApi } from '@/lib/downstream';
|
import { searchFromApi } from '@/lib/downstream';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
// 处理OPTIONS预检请求(OrionTV客户端需要)
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return handleOptionsRequest();
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const query = searchParams.get('q');
|
const query = searchParams.get('q');
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
const cacheTime = await getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
return NextResponse.json(
|
const response = NextResponse.json(
|
||||||
{ results: [] },
|
{ results: [] },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -21,6 +27,7 @@ export async function GET(request: Request) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
return addCorsHeaders(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiSites = await getAvailableApiSites();
|
const apiSites = await getAvailableApiSites();
|
||||||
@@ -31,7 +38,7 @@ export async function GET(request: Request) {
|
|||||||
const flattenedResults = results.flat();
|
const flattenedResults = results.flat();
|
||||||
const cacheTime = await getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
|
|
||||||
return NextResponse.json(
|
const response = NextResponse.json(
|
||||||
{ results: flattenedResults },
|
{ results: flattenedResults },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -41,7 +48,9 @@ export async function GET(request: Request) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
return addCorsHeaders(response);
|
||||||
} catch (error) {
|
} 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 */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@@ -17,13 +18,13 @@ interface VideoInfo {
|
|||||||
quality: string;
|
quality: string;
|
||||||
loadSpeed: string;
|
loadSpeed: string;
|
||||||
pingTime: number;
|
pingTime: number;
|
||||||
hasError?: boolean; // 添加错误状态标识
|
hasError?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EpisodeSelectorProps {
|
interface EpisodeSelectorProps {
|
||||||
/** 总集数 */
|
/** 总集数 */
|
||||||
totalEpisodes: number;
|
totalEpisodes: number;
|
||||||
/** 每页显示多少集,默认 50 */
|
/** 每页显示多少集,默认 10 */
|
||||||
episodesPerPage?: number;
|
episodesPerPage?: number;
|
||||||
/** 当前选中的集数(1 开始) */
|
/** 当前选中的集数(1 开始) */
|
||||||
value?: number;
|
value?: number;
|
||||||
@@ -47,7 +48,7 @@ interface EpisodeSelectorProps {
|
|||||||
*/
|
*/
|
||||||
const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||||
totalEpisodes,
|
totalEpisodes,
|
||||||
episodesPerPage = 50,
|
episodesPerPage = 10,
|
||||||
value = 1,
|
value = 1,
|
||||||
onChange,
|
onChange,
|
||||||
onSourceChange,
|
onSourceChange,
|
||||||
@@ -96,7 +97,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
// 是否倒序显示
|
// 是否倒序显示
|
||||||
const [descending, setDescending] = useState<boolean>(false);
|
const [descending, setDescending] = useState<boolean>(false);
|
||||||
|
|
||||||
// 获取视频信息的函数 - 移除 attemptedSources 依赖避免不必要的重新创建
|
// 获取视频信息的函数
|
||||||
const getVideoInfo = useCallback(async (source: SearchResult) => {
|
const getVideoInfo = useCallback(async (source: SearchResult) => {
|
||||||
const sourceKey = `${source.source}-${source.id}`;
|
const sourceKey = `${source.source}-${source.id}`;
|
||||||
|
|
||||||
@@ -134,7 +135,6 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
// 当有预计算结果时,先合并到videoInfoMap中
|
// 当有预计算结果时,先合并到videoInfoMap中
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (precomputedVideoInfo && precomputedVideoInfo.size > 0) {
|
if (precomputedVideoInfo && precomputedVideoInfo.size > 0) {
|
||||||
// 原子性地更新两个状态,避免时序问题
|
|
||||||
setVideoInfoMap((prev) => {
|
setVideoInfoMap((prev) => {
|
||||||
const newMap = new Map(prev);
|
const newMap = new Map(prev);
|
||||||
precomputedVideoInfo.forEach((value, key) => {
|
precomputedVideoInfo.forEach((value, key) => {
|
||||||
@@ -146,107 +146,61 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
setAttemptedSources((prev) => {
|
setAttemptedSources((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
precomputedVideoInfo.forEach((info, key) => {
|
precomputedVideoInfo.forEach((info, key) => {
|
||||||
if (!info.hasError) {
|
newSet.add(key);
|
||||||
newSet.add(key);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 同步更新 ref,确保 getVideoInfo 能立即看到更新
|
|
||||||
precomputedVideoInfo.forEach((info, key) => {
|
|
||||||
if (!info.hasError) {
|
|
||||||
attemptedSourcesRef.current.add(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [precomputedVideoInfo]);
|
}, [precomputedVideoInfo]);
|
||||||
|
|
||||||
// 读取本地“优选和测速”开关,默认开启
|
// 当换源Tab激活且没有测速过时,开始测速
|
||||||
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 依赖避免循环触发
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchVideoInfosInBatches = async () => {
|
if (activeTab === 'sources') {
|
||||||
if (
|
availableSources.forEach((source) => {
|
||||||
!optimizationEnabled || // 若关闭测速则直接退出
|
|
||||||
activeTab !== 'sources' ||
|
|
||||||
availableSources.length === 0
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// 筛选出尚未测速的播放源
|
|
||||||
const pendingSources = availableSources.filter((source) => {
|
|
||||||
const sourceKey = `${source.source}-${source.id}`;
|
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 categoryContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
|
|
||||||
// 当分页切换时,将激活的分页标签滚动到视口中间
|
// 自动滚动到当前分页标签
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const btn = buttonRefs.current[currentPage];
|
if (categoryContainerRef.current && buttonRefs.current[currentPage]) {
|
||||||
const container = categoryContainerRef.current;
|
const container = categoryContainerRef.current;
|
||||||
if (btn && container) {
|
const button = buttonRefs.current[currentPage];
|
||||||
// 手动计算滚动位置,只滚动分页标签容器
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
if (button) {
|
||||||
const btnRect = btn.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const scrollLeft = container.scrollLeft;
|
const buttonRect = button.getBoundingClientRect();
|
||||||
|
const scrollLeft = container.scrollLeft;
|
||||||
|
|
||||||
// 计算按钮相对于容器的位置
|
if (buttonRect.left < containerRect.left) {
|
||||||
const btnLeft = btnRect.left - containerRect.left + scrollLeft;
|
container.scrollTo({
|
||||||
const btnWidth = btnRect.width;
|
left: scrollLeft - (containerRect.left - buttonRect.left) - 20,
|
||||||
const containerWidth = containerRect.width;
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
// 计算目标滚动位置,使按钮居中
|
} else if (buttonRect.right > containerRect.right) {
|
||||||
const targetScrollLeft = btnLeft - (containerWidth - btnWidth) / 2;
|
container.scrollTo({
|
||||||
|
left: scrollLeft + (buttonRect.right - containerRect.right) + 20,
|
||||||
// 平滑滚动到目标位置
|
behavior: 'smooth',
|
||||||
container.scrollTo({
|
});
|
||||||
left: targetScrollLeft,
|
}
|
||||||
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点击,只在点击时才搜索
|
// 处理换源tab点击,只在点击时才搜索
|
||||||
const handleSourceTabClick = () => {
|
const handleSourceTabClick = () => {
|
||||||
@@ -285,11 +239,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
<div
|
<div
|
||||||
onClick={() => setActiveTab('episodes')}
|
onClick={() => setActiveTab('episodes')}
|
||||||
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||||||
${
|
${
|
||||||
activeTab === 'episodes'
|
activeTab === 'episodes'
|
||||||
? 'text-green-600 dark:text-green-400'
|
? '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'
|
: '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()}
|
`.trim()}
|
||||||
>
|
>
|
||||||
选集
|
选集
|
||||||
@@ -298,12 +252,12 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
<div
|
<div
|
||||||
onClick={handleSourceTabClick}
|
onClick={handleSourceTabClick}
|
||||||
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||||||
${
|
${
|
||||||
activeTab === 'sources'
|
activeTab === 'sources'
|
||||||
? 'text-green-600 dark:text-green-400'
|
? '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'
|
: '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()}
|
`.trim()}
|
||||||
>
|
>
|
||||||
换源
|
换源
|
||||||
</div>
|
</div>
|
||||||
@@ -325,7 +279,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
buttonRefs.current[idx] = el;
|
buttonRefs.current[idx] = el;
|
||||||
}}
|
}}
|
||||||
onClick={() => handleCategoryClick(idx)}
|
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
|
isActive
|
||||||
? 'text-green-500 dark:text-green-400'
|
? 'text-green-500 dark:text-green-400'
|
||||||
@@ -379,8 +333,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={episodeNumber}
|
key={episodeNumber}
|
||||||
onClick={() => handleEpisodeClick(episodeNumber - 1)}
|
onClick={() => handleEpisodeClick(episodeNumber)}
|
||||||
className={`h-10 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200
|
className={`h-10 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200
|
||||||
${
|
${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-green-500 text-white shadow-lg shadow-green-500/25 dark:bg-green-600'
|
? '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)
|
!isCurrentSource && handleSourceClick(source)
|
||||||
}
|
}
|
||||||
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
|
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
|
||||||
${
|
${
|
||||||
isCurrentSource
|
isCurrentSource
|
||||||
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border'
|
? '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'
|
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
|
||||||
}`.trim()}
|
}`.trim()}
|
||||||
>
|
>
|
||||||
{/* 封面 */}
|
{/* 封面 */}
|
||||||
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'>
|
<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 sourceKey = `${source.source}-${source.id}`;
|
||||||
const videoInfo = videoInfoMap.get(sourceKey);
|
const videoInfo = videoInfoMap.get(sourceKey);
|
||||||
|
|
||||||
if (videoInfo && videoInfo.quality !== '未知') {
|
if (videoInfo && videoInfo.quality !== '未知') {
|
||||||
if (videoInfo.hasError) {
|
if (videoInfo.hasError) {
|
||||||
return (
|
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 className='text-red-500/90 dark:text-red-400 font-medium text-xs'>
|
||||||
无测速数据
|
无测速数据
|
||||||
</div>
|
</div>
|
||||||
); // 占位div
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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 Link from 'next/link';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -138,6 +138,11 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||||||
label: '综艺',
|
label: '综艺',
|
||||||
href: '/douban?type=show',
|
href: '/douban?type=show',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Settings,
|
||||||
|
label: 'TVBox配置',
|
||||||
|
href: '/config',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ export default function SkipController({
|
|||||||
<div className="skip-controller">
|
<div className="skip-controller">
|
||||||
{/* 倒计时显示 - 片尾自动跳转下一集 */}
|
{/* 倒计时显示 - 片尾自动跳转下一集 */}
|
||||||
{showCountdown && (
|
{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">
|
<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">
|
<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" />
|
<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 && (
|
{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">
|
<div className="flex items-center space-x-3">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{currentSkipSegment.type === 'opening' ? '检测到片头' : '检测到片尾'}
|
{currentSkipSegment.type === 'opening' ? '检测到片头' : '检测到片尾'}
|
||||||
@@ -477,7 +477,7 @@ export default function SkipController({
|
|||||||
|
|
||||||
{/* 设置模式面板 - 增强版批量设置 */}
|
{/* 设置模式面板 - 增强版批量设置 */}
|
||||||
{isSettingMode && (
|
{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">
|
<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">
|
<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 && (
|
{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">
|
<div className="p-3">
|
||||||
<h4 className="font-medium mb-2 text-gray-900 dark:text-gray-100 text-sm flex items-center">
|
<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">
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client';
|
'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 { useRouter } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
@@ -149,6 +149,11 @@ export const UserMenu: React.FC = () => {
|
|||||||
router.push('/admin');
|
router.push('/admin');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTVBoxConfig = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
router.push('/config');
|
||||||
|
};
|
||||||
|
|
||||||
const handleChangePassword = () => {
|
const handleChangePassword = () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setIsChangePasswordOpen(true);
|
setIsChangePasswordOpen(true);
|
||||||
@@ -363,6 +368,15 @@ export const UserMenu: React.FC = () => {
|
|||||||
<span className='font-medium'>设置</span>
|
<span className='font-medium'>设置</span>
|
||||||
</button>
|
</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 && (
|
{showAdminPanel && (
|
||||||
<button
|
<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 { AdminConfig } from './admin.types';
|
||||||
import { D1Storage } from './d1.db';
|
import { D1Storage } from './d1.db';
|
||||||
|
import { KvrocksStorage } from './kvrocks.db';
|
||||||
import { LocalStorage } from './localstorage.db';
|
import { LocalStorage } from './localstorage.db';
|
||||||
import { RedisStorage } from './redis.db';
|
import { RedisStorage } from './redis.db';
|
||||||
import { Favorite, IStorage, PlayRecord } from './types';
|
import { Favorite, IStorage, PlayRecord } from './types';
|
||||||
import { UpstashRedisStorage } from './upstash.db';
|
import { UpstashRedisStorage } from './upstash.db';
|
||||||
|
|
||||||
// storage type 常量: 'localstorage' | 'redis' | 'd1' | 'upstash',默认 'localstorage'
|
// storage type 常量: 'localstorage' | 'redis' | 'kvrocks' | 'd1' | 'upstash',默认 'localstorage'
|
||||||
const STORAGE_TYPE =
|
const STORAGE_TYPE =
|
||||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||||
| 'localstorage'
|
| 'localstorage'
|
||||||
| 'redis'
|
| 'redis'
|
||||||
|
| 'kvrocks'
|
||||||
| 'd1'
|
| 'd1'
|
||||||
| 'upstash'
|
| 'upstash'
|
||||||
| undefined) || 'localstorage';
|
| undefined) || 'localstorage';
|
||||||
@@ -21,6 +23,8 @@ function createStorage(): IStorage {
|
|||||||
switch (STORAGE_TYPE) {
|
switch (STORAGE_TYPE) {
|
||||||
case 'redis':
|
case 'redis':
|
||||||
return new RedisStorage();
|
return new RedisStorage();
|
||||||
|
case 'kvrocks':
|
||||||
|
return new KvrocksStorage();
|
||||||
case 'upstash':
|
case 'upstash':
|
||||||
return new UpstashRedisStorage();
|
return new UpstashRedisStorage();
|
||||||
case 'd1':
|
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';
|
'use client';
|
||||||
|
|
||||||
const CURRENT_VERSION = '20250902153459';
|
const CURRENT_VERSION = '20250903203337';
|
||||||
|
|
||||||
// 版本检查结果枚举
|
// 版本检查结果枚举
|
||||||
export enum UpdateStatus {
|
export enum UpdateStatus {
|
||||||
|
|||||||
+1
-1
@@ -133,6 +133,6 @@ function shouldSkipAuth(pathname: string): boolean {
|
|||||||
// 配置middleware匹配规则
|
// 配置middleware匹配规则
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
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: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
screens: {
|
screens: {
|
||||||
|
'xs': '475px',
|
||||||
'mobile-landscape': {
|
'mobile-landscape': {
|
||||||
raw: '(orientation: landscape) and (max-height: 700px)',
|
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: {
|
fontFamily: {
|
||||||
primary: ['Inter', ...defaultTheme.fontFamily.sans],
|
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