53 Commits

Author SHA1 Message Date
katelya fc78bb11c0 Refactor code structure for improved readability and maintainability 2025-10-02 00:34:41 +08:00
katelya e2e3386128 fix: 更新NEXTAUTH_URL为实际域名,添加D1数据库配置并初始化数据库表 2025-10-02 00:32:45 +08:00
katelya 8c01f46fec fix: 移除wrangler.toml中的BOM字符,修复ParseError Unknown character 65279 2025-10-02 00:07:23 +08:00
katelya d1e18a5fd4 fix: 修复损坏的wrangler.toml文件 - 解决ParseError和Unterminated string错误 2025-10-02 00:01:15 +08:00
katelya 4c052df342 fix: 修复绑定名称冲突 - 将PASSWORD改为AUTH_PASSWORD避免Cloudflare保留名称 2025-10-01 23:47:37 +08:00
katelya fb5be70529 fix: 添加缺失的PASSWORD环境变量以修复500错误
- 在 wrangler.toml 中为生产和预览环境添加 PASSWORD 变量
- PASSWORD 是中间件验证的必需变量,缺失会导致500错误
- 更新部署指南,强调 PASSWORD 必须设置为 Plain text 类型
- 添加详细的环境变量类型设置说明和故障排除步骤
- PASSWORD 用于用户认证和签名验证,是系统核心安全机制
2025-10-01 23:39:02 +08:00
katelya 412ce4c2e7 fix: 添加缺失的USERNAME环境变量以修复500错误
- 在 wrangler.toml 中为生产和预览环境添加 USERNAME 变量
- USERNAME 用于站长用户身份验证和管理权限控制
- 修复因缺失 USERNAME 导致的管理后台无法访问问题
- 更新部署指南,添加详细的500错误故障排除方案
- 包含完整的环境变量检查清单和验证步骤
2025-10-01 23:30:03 +08:00
katelya 2937cf8748 Merge branch 'main' of https://github.com/katelya77/KatelyaTV 2025-10-01 23:20:20 +08:00
katelya ab2ee4f7b2 docs: 添加Cloudflare Pages部署指南
- 详细说明了Edge Runtime配置错误的解决方案
- 提供了Windows环境下bash依赖问题的多种解决方案
- 包含完整的部署步骤和配置指南
- 添加了常见问题排查和后续维护指导
2025-10-01 23:20:11 +08:00
katelya 8aeaa629f1 fix: 删除空的测试API文件以修复Cloudflare Pages部署问题
- 删除了空的 /api/test/simple/route.ts 文件
- 删除了空的 test 目录结构
- 确保所有API路由都正确配置了Edge Runtime
- 修复了Cloudflare Pages部署时的Edge Runtime错误
2025-10-01 23:18:37 +08:00
Katelya 708d204967 1
Removed USERNAME variable from the configuration.
2025-10-01 23:10:34 +08:00
Katelya 89f6196d1f Add USERNAME variable to wrangler.toml 2025-10-01 22:34:14 +08:00
katelya 3ce1bd1ce4 Simplify D1 database access and add fallback to LocalStorage for Edge Runtime compatibility 2025-09-05 16:21:41 +08:00
katelya 62072a5558 Fix wrangler.toml configuration: Remove duplicate D1 bindings and invalid build fields 2025-09-05 16:13:36 +08:00
katelya 87fac5ce53 Fix D1 database access and add debug endpoint for Cloudflare Pages troubleshooting 2025-09-05 16:11:20 +08:00
katelya 07cdaafcb2 Fix 500 Internal Server Error: Add error handling for D1 database access in Cloudflare Pages 2025-09-05 16:05:52 +08:00
katelya 142c780b50 Fix TypeScript errors: Update User type system across all storage implementations 2025-09-05 15:59:55 +08:00
katelya d83e2c6f42 Configure D1 database UUID: 6d580637-1f87-4ddf-8b4d-3d97254b4c33 2025-09-05 15:52:49 +08:00
katelya 0d4b6537d0 Add Cloudflare Pages configuration and D1 init script 2025-09-05 15:48:56 +08:00
katelya 617ad6504d feat: 修复 TVBox 配置生成问题,更新数据库表名并添加迁移脚本 2025-09-05 12:51:11 +08:00
katelya bdfad48656 feat: 实现成人内容过滤设置的动态处理,优化搜索API的缓存控制 2025-09-05 11:37:25 +08:00
katelya f0d2ea9d14 feat: 调整 EpisodeSelector 组件样式,优化布局和交互体验 2025-09-05 11:24:40 +08:00
katelya a378bad209 feat: 实现动态过滤可用资源站,根据用户设置自动处理成人内容 2025-09-05 11:18:55 +08:00
katelya 87e401738f feat: 更新 EpisodeSelector 组件,调整每页显示集数和样式以优化用户体验 2025-09-05 11:08:25 +08:00
katelya 0874cac2ae feat: 更新 Upstash 配置,修改环境变量名称以提高一致性 2025-09-05 10:58:27 +08:00
katelya 427056f4ad feat: 更新搜索API的响应,添加缓存控制头以优化性能 2025-09-05 01:37:53 +08:00
katelya c484dde326 feat: 移除 config.json 中的 API 源配置,更新示例配置以支持成人内容源 2025-09-05 01:30:12 +08:00
katelya 736bf531f9 feat: 更新搜索结果处理逻辑,支持合并常规和成人内容结果,兼容旧格式 2025-09-05 01:15:25 +08:00
katelya 0e8ea7003a feat: 添加 is_adult 字段以支持成人内容标记,更新相关逻辑处理 2025-09-05 00:57:57 +08:00
Katelya 24e9dd9b5d 更新视频源配置is_adult 2025-09-05 00:44:31 +08:00
katelya b9c59a3066 feat: 添加 is_adult 字段处理,更新配置以支持成人内容源 2025-09-05 00:36:55 +08:00
katelya af192b35ed docs: 优化README格式,增强可读性,修正内容描述 2025-09-04 23:54:22 +08:00
katelya 4c421bcf5f docs: 优化README部署指南,重新组织逻辑结构,简化冗余内容,增强可读性 2025-09-04 23:53:05 +08:00
katelya 40cbd617ee fix: 修复Docker + Kvrocks登录失败问题,补充USERNAME环境变量说明 2025-09-04 23:45:03 +08:00
katelya 1811d20d2a docs: 添加Docker+Kvrocks登录失败问题的详细故障排除指南
文档更新:
- 在README常见问题排除部分添加专门的Kvrocks登录失败解决方案
- 详细说明问题症状、原因分析和完整的解决步骤
- 提供环境变量配置检查和验证命令

 解决用户反馈:
- 针对用户报告的'账号或密码错误'和'Users数组为空'问题
- 提供完整的诊断和修复流程
- 包含重启服务和验证配置的具体命令

 技术改进:
- 明确标识这是重要修复项
- 提供可复制执行的命令脚本
- 添加配置验证步骤确保修复生效
2025-09-04 23:42:46 +08:00
katelya b83fd3f8c6 fix: 修复Docker+Kvrocks部署中缺少USERNAME环境变量导致登录失败的问题
问题分析:
- docker-compose.kvrocks.yml中缺少USERNAME环境变量配置
- 导致admin_config中Users数组为空,无法创建管理员账户
- 用户登录时提示'账号或密码错误'

 修复内容:
- 在docker-compose.kvrocks.yml中添加USERNAME环境变量
- 明确标注管理员账号配置为必填项
- 添加NEXT_PUBLIC_ENABLE_REGISTER用户注册配置

 影响:
- 解决用户反馈的Docker+Kvrocks部署登录失败问题
- 确保配置初始化时能正确创建管理员用户
- 提供更清晰的环境变量配置指导
2025-09-04 23:40:59 +08:00
katelya 2efcf6a812 fix: 修复文档格式和内容,确保成人内容过滤功能说明清晰 2025-09-04 23:25:40 +08:00
katelya 2197294cce docs: 添加成人内容过滤功能表结构兼容性修复指南
新增故障排除章节:
- 详细说明表结构不兼容问题的解决方案
- 提供完整的SQL命令来重建兼容的user_settings表
- 包含数据验证和测试步骤

 文档更新:
- D1_MIGRATION.md: 添加表结构兼容性修复方案
- CLOUDFLARE_PAGES_ADULT_FILTER.md: 添加开关无法操作问题的解决方法
- 提供JSON格式设置数据的正确结构说明

 解决问题:
- 修复'获取用户设置失败'后仍无法操作开关的问题
- 确保与现有代码的完全兼容性
- 提供清晰的表结构和数据格式说明
2025-09-04 23:24:02 +08:00
katelya 9a5564b3cf fix: 修复文档格式和内容,确保成人内容过滤功能配置说明清晰 2025-09-04 22:56:06 +08:00
katelya db08179eb0 docs: 完善成人内容过滤功能文档和配置
文档更新:
- 更新README中成人内容过滤部分,添加Cloudflare Pages配置要求
- 新增CLOUDFLARE_PAGES_ADULT_FILTER.md详细配置指南
- 更新D1_MIGRATION.md,修正user_settings表结构

 数据库优化:
- 修复scripts/d1-init.sql,添加缺失的user_settings表
- 更新表结构以匹配当前实现
- 添加必要的索引优化查询性能

 问题修复:
- 解决Cloudflare Pages部署时'获取用户设置失败'错误
- 明确说明不同部署平台的存储类型要求
- 提供详细的故障排除指南
2025-09-04 22:55:28 +08:00
katelya ff388a8085 fix: 修复ESLint导入排序问题
- 使用eslint --fix自动修复导入语句排序
- 修正导入语句中的格式问题
2025-09-04 22:42:28 +08:00
katelya b1651dabfc fix: 恢复原设置功能,添加独立的内容过滤入口
- 恢复UserMenu中原有的本地设置功能(聚合搜索、优选测速、豆瓣代理、图片代理等)
- 添加独立的'内容过滤'菜单项,避免与原设置功能混淆
- 保持原有设置功能的完整性和重要配置项
2025-09-04 22:40:36 +08:00
katelya 88e48b8599 feat: 完整实现成人内容过滤功能的前端集成
- 修改用户菜单设置按钮导航到/settings页面
- 增强搜索页面支持用户认证和内容过滤
- 添加分组结果显示:常规内容和成人内容分标签显示
- 在搜索API调用中包含用户认证信息
- 支持成人内容分组展示和警告提示
- 保持原有聚合搜索功能的兼容性

现在用户可以:
1. 在设置页面控制成人内容过滤开关
2. 在搜索结果中看到内容分组(当存在成人内容时)
3. 获得个性化的搜索体验
2025-09-04 22:32:31 +08:00
katelya 235358c8c2 fix: 修复import排序和格式化问题
- 修复AdultContentFilter组件的import排序
- 修复settings页面的import排序
- 清理代码格式问题
2025-09-04 21:50:55 +08:00
katelya b06665788f fix: 完善成人内容过滤功能的部署兼容性
- 为用户设置API添加Edge Runtime配置确保部署兼容性
- 完善所有存储后端的用户设置方法实现
- 为D1数据库添加user_settings表迁移脚本
- 修复TypeScript类型错误和构建兼容性
- 所有25个API路由现在都正确配置了Edge Runtime
- 确保Docker、Cloudflare Pages等各平台部署正常运行
2025-09-04 21:25:45 +08:00
katelya 86ebbb2cf6 feat: 添加成人内容过滤功能
- 新增用户设置系统支持内容过滤开关
- 扩展类型定义支持成人内容标记
- 实现用户设置API端点(GET/PATCH/PUT)
- 增强搜索API支持内容分组和过滤
- 创建AdultContentFilter UI组件
- 添加用户设置页面和认证检查
- 更新配置示例和README文档
- 实现LocalStorage和Redis存储后端
- 默认启用过滤确保安全性
2025-09-04 21:11:02 +08:00
Katelya c9429efba6 Update README.md 2025-09-04 20:57:05 +08:00
Katelya 5427dbcb0f Improve comments in README for environment variables 2025-09-04 20:56:02 +08:00
katelya b255965de3 docs: 更新README.md,添加Upstash Redis和Cloudflare Pages配置说明,优化格式 2025-09-04 20:52:12 +08:00
katelya 11779e6d24 fix: 修正Cloudflare Pages构建配置,简化部署文档,添加Vercel环境变量示例 2025-09-04 20:47:55 +08:00
katelya fac3f4bfc7 fix: 同步更新 version.ts 中的版本时间戳
- 更新 CURRENT_VERSION 为 20250904200125
- 确保前端版本检查功能正常工作
- 避免版本不一致导致的误报
2025-09-04 20:04:02 +08:00
katelya 9005ed327e chore: 升级版本到 0.7.0-katelya
- 更新 package.json 版本号到 0.7.0-katelya
- 更新 VERSION.txt 时间戳
- 触发 Docker 镜像重新构建
2025-09-04 20:02:17 +08:00
katelya 0679fe98eb feat: 更新Kvrocks部署文档,添加管理员账号和用户注册配置说明 2025-09-04 18:27:06 +08:00
55 changed files with 4172 additions and 770 deletions
+7
View File
@@ -19,6 +19,13 @@ KVROCKS_DATABASE=0
NEXTAUTH_SECRET=your_nextauth_secret_here
NEXTAUTH_URL=http://localhost:3000
# 管理员账号配置(必填)
USERNAME=admin
PASSWORD=your_admin_password
# 用户注册配置
NEXT_PUBLIC_ENABLE_REGISTER=true
# 站点配置
NEXT_PUBLIC_SITE_NAME=KatelyaTV
NEXT_PUBLIC_SITE_DESCRIPTION=高性能影视播放平台
+62
View File
@@ -0,0 +1,62 @@
# KatelyaTV Vercel 环境变量配置示例
# 复制此文件为 .env.local 并填入实际值
# ==============================================
# 基础配置(必填)
# ==============================================
# 访问密码(必填)
PASSWORD=your_secure_password_here
# 管理员用户名(可选,用于管理界面登录)
USERNAME=admin
# ==============================================
# 存储配置
# ==============================================
# 存储类型:localstorage(默认)/ redis / upstash / d1
NEXT_PUBLIC_STORAGE_TYPE=localstorage
# ==============================================
# Upstash Redis 配置(选择 upstash 存储时需要)
# ==============================================
# Upstash Redis 连接 URL
# UPSTASH_URL=https://xxx.upstash.io
# Upstash Redis 访问令牌
# UPSTASH_TOKEN=AX_xxx
# ==============================================
# Redis 配置(选择 redis 存储时需要)
# ==============================================
# Redis 连接字符串
# REDIS_URL=redis://localhost:6379
# ==============================================
# 站点配置(可选)
# ==============================================
# 站点显示名称
NEXT_PUBLIC_SITE_NAME=KatelyaTV
# 站点描述
NEXT_PUBLIC_SITE_DESCRIPTION=高性能影视播放平台
# 是否允许用户注册(true/false)
NEXT_PUBLIC_ENABLE_REGISTER=false
# ==============================================
# 高级配置(可选)
# ==============================================
# 跨域配置
# CORS_ORIGIN=*
# 启用访问统计(true/false
# ENABLE_ANALYTICS=false
# Node.js 运行时优化
# NODE_OPTIONS=--max-old-space-size=1024
+91
View File
@@ -0,0 +1,91 @@
# 成人内容过滤功能验证指南
## 🔒 过滤功能说明
### 工作原理
1. **默认行为**:所有用户默认开启成人内容过滤
2. **源级别标记**:每个视频源都有 `is_adult` 标记
3. **用户设置**:用户可通过设置页面控制过滤开关
4. **API 级别分离**:搜索 API 将结果分为 `regular_results``adult_results`
### 过滤逻辑
```typescript
// 1. 获取用户设置
shouldFilterAdult = userSettings?.filter_adult_content !== false; // 默认true
// 2. 源分离
getAvailableApiSites() // 返回 is_adult: false 的源
getAdultApiSites() // 返回 is_adult: true 的源
// 3. 搜索分离
regular_results: [...] // 来自常规源
adult_results: [...] // 来自成人源(仅在用户关闭过滤且明确请求时)
```
## 🧪 测试步骤
### 1. 添加测试源
在管理后台添加以下测试源:
**常规源:**
```
源标识:test_regular
源名称:测试常规源
API地址:https://okzy.tv/api.php/provide/vod
详情地址:https://okzy.tv/api.php/provide/vod/?ac=detail&ids={ids}
是否成人内容:❌ 否
```
**成人内容源:**
```
源标识:test_adult
源名称:测试成人源
API地址:https://adult-test.com/api.php/provide/vod
详情地址:https://adult-test.com/api.php/provide/vod/?ac=detail&ids={ids}
是否成人内容:✅ 是
```
### 2. 验证过滤开启状态
- 访问用户设置页面,确认"成人内容过滤"开关为**开启**
- 搜索任意关键词,应该只返回常规源的结果
- API 响应中 `adult_results` 应为空数组
### 3. 验证过滤关闭状态
- 关闭"成人内容过滤"开关
- 搜索相同关键词
- 应该看到结果分为两组:常规内容 + 成人内容
### 4. API 级别验证
```bash
# 开启过滤(默认)
curl "http://localhost:3001/api/search?q=test"
# 预期:adult_results = []
# 关闭过滤且明确请求成人内容
curl "http://localhost:3001/api/search?q=test&include_adult=true" -H "Authorization: Bearer username"
# 预期:adult_results 包含成人源结果
```
## ✅ 验证要点
1. **默认保护**:新用户默认开启过滤
2. **源级别隔离**is_adult 标记正确分离源
3. **用户可控**:设置页面可以切换过滤状态
4. **API 响应分离**:结果明确分组
5. **明确请求**:关闭过滤后需明确请求成人内容才返回
## 🚨 安全检查
- [ ] 默认开启过滤
- [ ] 设置页面有明确的年龄警告
- [ ] API 不会意外返回成人内容
- [ ] 源标记 `is_adult: true` 的源被正确隔离
- [ ] 前端正确处理分组结果
+266
View File
@@ -0,0 +1,266 @@
# Cloudflare Pages 成人内容过滤配置指南
本文档详细说明如何在 Cloudflare Pages 部署中配置成人内容过滤功能。
## ⚠️ 重要说明
成人内容过滤功能需要**数据库存储支持**,不能使用默认的 `localstorage` 存储类型。在 Cloudflare Pages 环境下,必须配置 D1 数据库。
## 🚀 快速配置步骤
### 1. 创建 D1 数据库
```bash
# 安装并登录 Wrangler CLI
npm install -g wrangler
wrangler auth login
# 创建 D1 数据库
wrangler d1 create katelyatv-db
```
记录输出的数据库 ID,类似:
```
✅ Successfully created DB 'katelyatv-db' in region APAC
Created your database using D1's new storage backend.
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
```
### 2. 初始化数据库表
```bash
# 克隆项目(如果还没有)
git clone https://github.com/your-username/KatelyaTV.git
cd KatelyaTV
# 初始化数据库表(包含 user_settings 表)
wrangler d1 execute katelyatv-db --file=./scripts/d1-init.sql
```
### 3. 配置 wrangler.toml
在项目根目录创建或更新 `wrangler.toml` 文件:
```toml
name = "katelyatv"
compatibility_date = "2023-12-01"
compatibility_flags = ["nodejs_compat"]
[[d1_databases]]
binding = "DB"
database_name = "katelyatv-db"
database_id = "your-database-id-here" # 替换为步骤1中获得的ID
[build]
command = "pnpm install --frozen-lockfile && pnpm run pages:build"
[[build.environment_variables]]
NEXT_PUBLIC_STORAGE_TYPE = "d1"
[vars]
USERNAME = "admin"
PASSWORD = "your_password_here"
NEXT_PUBLIC_ENABLE_REGISTER = "true"
```
### 4. 部署到 Cloudflare Pages
#### 方法一:通过 Cloudflare Dashboard
1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)
2. 进入 **Pages** 服务
3. 点击 **Create a project**
4. 连接 GitHub 仓库并选择 KatelyaTV 项目
5. 配置构建设置:
- **Build command**: `pnpm install --frozen-lockfile && pnpm run pages:build`
- **Build output directory**: `.vercel/output/static`
- **Root directory**: 留空
6.**Environment variables** 中添加:
```
NEXT_PUBLIC_STORAGE_TYPE = d1
USERNAME = admin
PASSWORD = your_password_here
NEXT_PUBLIC_ENABLE_REGISTER = true
```
7. 在 **Functions** 标签页中:
- 启用 **Compatibility flags**: `nodejs_compat`
- 配置 **D1 database bindings**:
- Variable name: `DB`
- D1 database: 选择刚创建的数据库
#### 方法二:通过命令行部署
```bash
# 构建项目
pnpm install --frozen-lockfile
pnpm run pages:build
# 部署到 Pages
wrangler pages deploy .vercel/output/static --project-name katelyatv
```
## 🔍 验证配置
部署完成后,访问你的网站:
1. **登录系统**:使用配置的用户名密码登录
2. **访问设置页面**:点击用户菜单中的「内容过滤」
3. **检查功能**:应该能够看到成人内容过滤开关,而不是"获取用户设置失败"错误
## 🐛 故障排除
### 错误:"获取用户设置失败"
**可能原因**
- 未配置 D1 数据库
- `NEXT_PUBLIC_STORAGE_TYPE` 未设置为 `d1`
- 数据库中缺少 `user_settings` 表
**解决方案**
1. 检查环境变量配置
2. 验证 D1 数据库绑定
3. 执行数据库迁移:
```bash
wrangler d1 execute katelyatv-db --file=./scripts/d1-init.sql
```
### 错误:D1 数据库连接失败
**可能原因**
- wrangler.toml 中的数据库配置错误
- Cloudflare Pages 中的 D1 绑定未正确配置
**解决方案**
1. 验证 `wrangler.toml` 中的 database_id 是否正确
2. 在 Cloudflare Pages Dashboard 中检查 Functions → D1 database bindings
3. 确保绑定的变量名为 `DB`
### 🚨 错误:功能正常但开关无法操作(重要修复)
**问题描述**
- 页面不再显示"获取用户设置失败"错误
- 但成人内容过滤开关无法切换,点击无响应
**根本原因**
数据库表结构与代码期望的格式不匹配
**完整解决方案**
#### 第一步:重建兼容表结构
在 Cloudflare D1 Console 中执行以下 SQL
```sql
-- 删除现有的不兼容表
DROP TABLE IF EXISTS user_settings;
-- 创建与代码完全兼容的表结构
CREATE TABLE user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
settings TEXT NOT NULL,
updated_time INTEGER NOT NULL
);
-- 添加必要索引
CREATE INDEX IF NOT EXISTS idx_user_settings_username ON user_settings(username);
CREATE INDEX IF NOT EXISTS idx_user_settings_updated_time ON user_settings(updated_time DESC);
```
#### 第二步:插入用户设置数据
```sql
-- 插入设置数据(请替换 'your_username' 为实际用户名)
INSERT INTO user_settings (username, settings, updated_time) VALUES (
'your_username',
'{"filter_adult_content":true,"theme":"auto","language":"zh-CN","auto_play":true,"video_quality":"auto"}',
strftime('%s', 'now')
);
```
#### 第三步:验证数据正确性
```sql
-- 验证数据插入成功
SELECT * FROM user_settings WHERE username = 'your_username';
```
#### 第四步:重新部署并测试
1. 在 Cloudflare Pages 中触发重新部署
2. 清除浏览器缓存并重新登录
3. 测试成人内容过滤开关功能
**重要说明**
- `settings` 字段必须是有效的 JSON 字符串
- `filter_adult_content` 为 `true` 表示开启过滤
- `updated_time` 使用 Unix 时间戳格式
### 错误:构建失败
**可能原因**
- Node.js 兼容性问题
- 依赖安装失败
**解决方案**
1. 确保启用了 `nodejs_compat` 兼容性标志
2. 检查构建命令是否正确
3. 查看构建日志中的具体错误信息
## 📊 数据库监控
在 Cloudflare Dashboard 中可以监控 D1 数据库的使用情况:
1. 进入 **D1** 服务
2. 选择数据库实例
3. 查看 **Metrics** 标签页
4. 监控查询次数、存储使用量等指标
## 🔒 安全建议
1. **密码安全**:使用强密码,避免使用默认密码
2. **环境变量**:敏感信息通过环境变量配置,不要硬编码
3. **用户注册**:根据需要开启或关闭用户注册功能
4. **访问控制**:考虑使用 Cloudflare Access 进一步控制访问
## 🆕 更新和迁移
当项目更新包含数据库结构变更时:
1. **备份数据**
```bash
wrangler d1 export katelyatv-db --output backup.sql
```
2. **执行迁移**
```bash
wrangler d1 execute katelyatv-db --file=D1_MIGRATION.md的SQL脚本
```
3. **验证功能**:确保所有功能正常工作
## 📚 相关文档
- [D1 数据库迁移文档](./D1_MIGRATION.md)
- [Cloudflare Pages 官方文档](https://developers.cloudflare.com/pages/)
- [D1 数据库文档](https://developers.cloudflare.com/d1/)
- [Wrangler CLI 文档](https://developers.cloudflare.com/workers/wrangler/)
## 💬 需要帮助?
如果在配置过程中遇到问题:
1. 检查本文档的故障排除部分
2. 查看项目的 GitHub Issues
3. 提交新的 Issue 并提供详细的错误信息和配置详情
+284
View File
@@ -0,0 +1,284 @@
# Cloudflare Pages 部署指南
## 🚨 500 Internal Server Error 解决方案
### 问题:部署成功但运行时500错误
**原因分析:**
部署日志显示构建成功,但访问网站时出现500错误,通常是由于环境变量配置问题导致的运行时错误。
**主要原因:**
1. **USERNAME 环境变量缺失** - 这是最常见的原因
2. D1 数据库绑定问题
3. 其他关键环境变量未设置
### 🎯 解决步骤
#### 第一步:设置必需的环境变量
在 Cloudflare Pages 控制台中:
1. 进入您的项目设置
2. 点击 "Settings" → "Environment variables"
3. 添加以下**必需**环境变量:
**USERNAME 变量:**
- **Variable name**: `USERNAME`
- **Value**: `katelya` (您的站长用户名)
- **Type**: Plain text
- **Environment**: Production 和 Preview 都要添加
**PASSWORD 变量:****关键变量**
- **Variable name**: `PASSWORD`
- **Value**: `您设置的访问密码`
- **Type**: Plain text (**重要:不要选择密码类型**)
- **Environment**: Production 和 Preview 都要添加
4. 点击 "Save"
5. 重新部署项目
#### 第二步:验证其他环境变量
确保以下环境变量已正确设置:
```bash
# ⭐ 关键必需变量(缺一不可)
USERNAME=katelya # 站长用户名
PASSWORD=your-secure-password # 访问密码
NEXT_PUBLIC_STORAGE_TYPE=d1 # 存储类型
NEXT_PUBLIC_SITE_NAME=KatelyaTV # 站点名称
NODE_ENV=production # 运行环境
# 推荐设置
NEXTAUTH_URL=https://your-domain.pages.dev
IMAGE_PROXY_ENABLED=true
```
**⚠️ 重要提醒:**
- `PASSWORD` 必须设置为 "Plain text" 类型,不要选择 "Secret" 或 "Password" 类型
- `PASSWORD` 的值应该与您之前在本地或其他环境中使用的密码一致
#### 第三步:检查 D1 数据库绑定
1. 确保在 Cloudflare Pages 中绑定了 D1 数据库
2. 绑定名称应为 `DB`
3. 数据库应已初始化(运行过初始化脚本)
## 部署问题修复
### 问题1Edge Runtime 配置错误
**错误信息:**
```text
The following routes were not configured to run with the Edge Runtime:
- /api/test/simple
Please make sure that all your non-static routes export the following edge runtime route segment config:
export const runtime = 'edge';
```
**解决方案:**
✅ 已修复:删除了空的 `/api/test/simple/route.ts` 文件和相关目录。
**验证:**
所有API路由现在都正确配置了 `export const runtime = 'edge';`
### 问题2Windows环境下的bash依赖问题
**错误信息:**
```text
Error: spawn bash ENOENT
```
**原因:**
`@cloudflare/next-on-pages` 在Windows环境下需要bash来执行构建过程。
**解决方案选项:**
#### 选项1:使用 WSL (推荐)
1. 安装 Windows Subsystem for Linux (WSL)
2. 在WSL环境中运行构建命令
#### 选项2:使用 Git Bash
1. 确保已安装 Git for Windows
2. 在Git Bash中运行构建命令:
```bash
pnpm run pages:build
```
#### 选项3:云端构建
直接在Cloudflare Pages的CI/CD环境中构建,因为云环境通常是Linux系统。
## 正确的部署步骤
### 1. 本地验证构建
```bash
# 生成运行时配置
pnpm run gen:runtime
# 生成manifest
pnpm run gen:manifest
# Next.js 构建
npx next build
# Cloudflare Pages 适配 (在Linux/WSL环境中)
npx @cloudflare/next-on-pages
```
### 2. Cloudflare Pages 配置
在Cloudflare Pages控制台中设置:
**构建配置:**
- 构建命令: `pnpm install --frozen-lockfile && pnpm run pages:build`
- 构建输出目录: `.vercel/output/static`
- Node.js 版本: `20.x`
**环境变量:** (已在 `wrangler.toml` 中配置)
- `NEXT_PUBLIC_STORAGE_TYPE=d1`
- `NEXT_PUBLIC_SITE_NAME=KatelyaTV`
- 其他变量见 `wrangler.toml`
### 3. 验证部署
部署成功后,检查:
1. 所有API路由是否正常工作
2. 静态页面是否正确生成
3. Edge Runtime是否正常运行
## 常见问题排查
### API路由问题
确保所有API文件都包含:
```typescript
export const runtime = 'edge';
```
### 构建失败
1. 检查所有依赖是否安装完整
2. 确认TypeScript编译无错误
3. 验证环境变量配置
### 性能优化
- 已启用默认代码分割
- PWA缓存策略已配置
- 静态资源优化已开启
## 部署状态验证
部署完成后,访问以下端点验证:
- `/api/server-config` - 服务器配置
- `/api/debug/env` - 环境变量 (开发时)
- 主页 `/` - 前端页面
## 🔧 完整故障排除指南
### 500错误诊断清单
#### 1. 环境变量检查
```bash
# 在 Cloudflare Pages 控制台检查这些变量
USERNAME=katelya # ❌ 经常缺失
PASSWORD=your-password # ❌ 最关键,经常缺失或类型错误
NEXT_PUBLIC_STORAGE_TYPE=d1 # ✅ 通常已设置
NEXT_PUBLIC_SITE_NAME=KatelyaTV # ✅ 通常已设置
NODE_ENV=production # ✅ 通常已设置
```
**特别注意 PASSWORD 变量:**
- ✅ 正确:类型选择 "Plain text"
- ❌ 错误:类型选择 "Secret" 或 "Password"
- ❌ 错误:值为空或包含特殊字符
#### 2. D1 数据库检查
- [ ] D1 数据库是否已创建
- [ ] 数据库是否正确绑定到 Pages 项目
- [ ] 绑定名称是否为 `DB`
- [ ] 数据库是否已初始化
#### 3. 常见错误模式
| 错误现象 | 原因 | 解决方案 |
|---------|------|----------|
| 500 错误 | PASSWORD 未设置或类型错误 | 设置 PASSWORD 为 Plain text 类型 |
| 500 + 管理页面无法访问 | USERNAME 未设置 | 添加 USERNAME 环境变量 |
| 重定向到 /warning 页面 | PASSWORD 环境变量缺失 | 检查 PASSWORD 变量设置 |
| 500 + 数据库相关错误 | D1 绑定问题 | 检查数据库绑定配置 |
| 构建成功但运行失败 | 关键环境变量缺失 | 检查 USERNAME 和 PASSWORD |
### 验证部署成功
部署完成后访问以下端点验证:
```bash
# 1. 基本页面
https://your-domain.pages.dev/ # 主页
https://your-domain.pages.dev/login # 登录页
# 2. API 端点
https://your-domain.pages.dev/api/server-config # 服务器配置
https://your-domain.pages.dev/api/debug/env # 环境变量(开发时)
# 3. 管理功能
https://your-domain.pages.dev/admin # 管理后台(需要正确的 USERNAME)
```
### 紧急恢复方案
如果新部署出现问题:
1. **立即回滚**
```bash
# 在 Cloudflare Pages 控制台
Deployments → 选择之前的工作版本 → Rollback
```
2. **保留环境变量**
```bash
# 记录当前所有环境变量配置
# 在新部署前先备份设置
```
3. **分步部署**
```bash
# 1. 先部署代码(不修改环境变量)
# 2. 验证基本功能
# 3. 逐步添加/修改环境变量
```
## 后续维护
1. 定期更新依赖
2. 监控部署日志
3. 备份数据库配置
4. 关注Cloudflare Pages更新
5. 定期检查环境变量配置
6. 监控网站运行状态
+75
View File
@@ -0,0 +1,75 @@
# Cloudflare Pages 部署修复指南
## 问题描述
Cloudflare Pages 部署遇到两个主要问题:
1. **绑定名称冲突**
```
Error: Failed to publish your Function. Got error: Binding name 'PASSWORD' already in use.
```
2. **wrangler.toml 文件损坏**
```
ParseError: Unterminated string
lineText: 'name = "katelyat[env.preview.vars]'
```
## 解决方案
**第一步**:将环境变量名从 `PASSWORD` 更改为 `AUTH_PASSWORD` 以避免Cloudflare的保留绑定名称冲突。
**第二步**:修复损坏的 `wrangler.toml` 文件,文件结构被损坏导致语法错误。
## 需要的操作
### 1. 更新 wrangler.toml 配置
✅ 已完成 - `PASSWORD` 已更改为 `AUTH_PASSWORD`
### 2. 更新代码中的引用
✅ 已完成 - 所有 `process.env.PASSWORD` 已更改为 `process.env.AUTH_PASSWORD`
### 3. 在 Cloudflare Pages 控制台中设置环境变量
由于您提到无法在控制台中直接修改环境变量(因为通过 wrangler.toml 管理),我们需要:
1. **重新部署项目** - 新的 wrangler.toml 配置会自动设置 `AUTH_PASSWORD` 变量
2. **验证环境变量** - 确保 `AUTH_PASSWORD` 正确设置
### 4. 立即执行步骤
现在执行以下命令重新部署:
```powershell
git add -A
git commit -m "fix: 修复绑定名称冲突 - 将PASSWORD改为AUTH_PASSWORD"
git push origin main
```
## 更新说明
### 变更的文件:
- `wrangler.toml` - 更新环境变量名称
- `src/middleware.ts` - 更新认证逻辑
- `src/app/api/login/route.ts` - 更新登录验证
- `src/app/api/register/route.ts` - 更新注册逻辑
### 环境变量变更:
- `PASSWORD``AUTH_PASSWORD`
- 功能保持完全一致,只是变量名称改变
## 预期结果
部署成功后:
1. 不再出现绑定名称冲突错误
2. `AUTH_PASSWORD` 环境变量将自动通过 wrangler.toml 设置
3. 网站应该正常运行,认证功能正常
## 验证步骤
部署完成后:
1. 访问您的 Cloudflare Pages 网站
2. 尝试登录(用户名: katelya,密码: your-secure-password-here
3. 如果能正常登录,说明修复成功
如果仍有问题,请检查 Cloudflare Pages 的部署日志。
View File
+221 -28
View File
@@ -1,10 +1,138 @@
# D1 数据库迁移 - 添加跳过配置功能
# D1 数据库迁移 - 添加成人内容过滤和跳过配置功能
如果您已经有一个运行中的 D1 数据库,需要执行以下 SQL 语句来添加跳过配置支持。
如果您已经有一个运行中的 D1 数据库,需要执行以下 SQL 语句来添加成人内容过滤和跳过配置支持。
## 🗄️ 新增表结构
### skip_configs 表
### user_settings 表(成人内容过滤功能 - 必需)
这个表用于存储用户## 🔧 故障排除
### 1. "获取用户设置失败" 错误
**原因**:缺少 `user_settings`
**解决**:执行上述迁移 SQL,确保 user_settings 表已创建
### 2. "表已存在" 错误
**原因**:表已经创建过了
**解决**:这是正常的,`CREATE TABLE IF NOT EXISTS` 语句是安全的
### 3. 外键约束错误
**原因**users 表不存在或结构不匹配
**解决**:确保先运行完整的 `./scripts/d1-init.sql` 初始化脚本
### 4. 🚨 表结构不兼容问题(重要修复)
**问题描述**:即使表创建成功,仍然显示"获取用户设置失败",开关无法操作
**原因**:代码期望的表结构与创建的表结构不匹配
**完整解决方案**
#### 第一步:重建兼容的表结构
在 Cloudflare D1 Console 中执行:
```sql
-- 删除现有表,重新创建完全兼容的结构
DROP TABLE IF EXISTS user_settings;
-- 创建与代码完全匹配的表结构
CREATE TABLE user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
settings TEXT NOT NULL,
updated_time INTEGER NOT NULL
);
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_user_settings_username ON user_settings(username);
CREATE INDEX IF NOT EXISTS idx_user_settings_updated_time ON user_settings(updated_time DESC);
-- 插入用户设置(JSON格式,替换为您的用户名)
INSERT INTO user_settings (username, settings, updated_time) VALUES (
'your_username_here',
'{"filter_adult_content":true,"theme":"auto","language":"zh-CN","auto_play":true,"video_quality":"auto"}',
strftime('%s', 'now')
);
```
#### 第二步:验证数据插入
```sql
-- 验证设置是否正确插入
SELECT * FROM user_settings WHERE username = 'your_username_here';
```
#### 第三步:确认环境变量
在 Cloudflare Pages → Settings → Environment variables 中确认:
```
NEXT_PUBLIC_STORAGE_TYPE = d1
USERNAME = your_username_here
PASSWORD = your_password_here
```
#### 第四步:确认 D1 绑定
在 Cloudflare Pages → Settings → Functions → D1 database bindings
- **Variable name**: `DB`
- **D1 database**: 选择您的数据库
#### 第五步:重新部署并清除缓存
1. 在 Cloudflare Pages → Deployments 中点击 "Retry deployment"
2. 清除浏览器缓存(Ctrl+Shift+Delete
3. 重新登录并测试功能
**表结构说明**
| 字段名 | 类型 | 说明 |
| -------------- | ------- | ------------------------------------- |
| `id` | INTEGER | 主键,自动递增 |
| `username` | TEXT | 用户名,必须与 users 表中的用户名匹配 |
| `settings` | TEXT | 用户设置的 JSON 字符串 |
| `updated_time` | INTEGER | 更新时间戳(Unix 时间戳) |
**settings JSON 格式**
````json
{
"filter_adult_content": true, // 成人内容过滤开关
"theme": "auto", // 主题设置
"language": "zh-CN", // 语言设置
"auto_play": true, // 自动播放
"video_quality": "auto" // 视频质量
}
```
```sql
-- 创建用户设置表(成人内容过滤功能)
CREATE TABLE IF NOT EXISTS user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
filter_adult_content BOOLEAN DEFAULT 1,
theme TEXT DEFAULT 'auto',
language TEXT DEFAULT 'zh-CN',
auto_play BOOLEAN DEFAULT 1,
video_quality TEXT DEFAULT 'auto',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE (user_id, username)
);
-- 为用户设置添加索引以优化查询性能
CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id);
CREATE INDEX IF NOT EXISTS idx_user_settings_username ON user_settings(username);
````
### skip_configs 表(跳过功能 - 可选)
这个表用于存储用户的跳过片头片尾配置:
@@ -12,24 +140,26 @@
-- 创建跳过配置表
CREATE TABLE IF NOT EXISTS skip_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
key TEXT NOT NULL,
source TEXT NOT NULL,
video_id TEXT NOT NULL,
title TEXT NOT NULL,
segments TEXT NOT NULL,
updated_time INTEGER NOT NULL,
UNIQUE(username, key)
user_id INTEGER NOT NULL,
config_key TEXT NOT NULL,
start_time INTEGER DEFAULT 0,
end_time INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE (user_id, config_key)
);
-- 为跳过配置添加索引以优化查询性能
CREATE INDEX IF NOT EXISTS idx_skip_configs_username ON skip_configs(username);
CREATE INDEX IF NOT EXISTS idx_skip_configs_username_key ON skip_configs(username, key);
CREATE INDEX IF NOT EXISTS idx_skip_configs_username_updated_time ON skip_configs(username, updated_time DESC);
CREATE INDEX IF NOT EXISTS idx_skip_configs_user_id ON skip_configs(user_id);
```
## 🚀 执行迁移的方法
### ⚠️ 重要提示
如果您在 Cloudflare Pages 使用成人内容过滤功能时遇到"获取用户设置失败"错误,这是因为缺少 `user_settings` 表。**必须执行此迁移**才能使功能正常工作。
### 方法一:使用 Cloudflare Dashboard(推荐)
1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)
@@ -47,33 +177,96 @@ CREATE INDEX IF NOT EXISTS idx_skip_configs_username_updated_time ON skip_config
# 首先登录 Cloudflare
wrangler auth login
# 创建迁移文件
echo "-- 上面的SQL代码" > user_settings_migration.sql
# 执行数据库迁移
wrangler d1 execute your-database-name --file=migration.sql
wrangler d1 execute your-database-name --file=user_settings_migration.sql
```
其中 `migration.sql` 包含上面的 SQL 代码。
### 方法三:使用项目内置迁移脚本
### 方法三:通过 Pages 函数执行(高级)
```bash
# 克隆或更新项目代码
git pull origin main
也可以创建一个临时的迁移函数,部署后访问来执行迁移。
# 执行完整的D1初始化(包含新表)
wrangler d1 execute your-database-name --file=./scripts/d1-init.sql
```
## 📋 字段说明
| 字段名 | 类型 | 说明 |
| -------------- | ------- | ------------------------------- |
| `id` | INTEGER | 主键,自动递增 |
| `username` | TEXT | 用户名,关联到用户 |
| `key` | TEXT | 配置键,格式:`source+video_id` |
| `source` | TEXT | 视频源标识 |
| `video_id` | TEXT | 视频 ID |
| `title` | TEXT | 视频标题 |
| `segments` | TEXT | 跳过片段数据(JSON 格式) |
| `updated_time` | INTEGER | 更新时间戳 |
### user_settings 表字段
| 字段名 | 类型 | 默认值 | 说明 |
| ---------------------- | -------- | -------- | ---------------------- |
| `id` | INTEGER | 自增 | 主键 |
| `user_id` | INTEGER | 无 | 用户 ID,关联 users 表 |
| `username` | TEXT | | 用户名 |
| `filter_adult_content` | BOOLEAN | 1(true) | 成人内容过滤开关 |
| `theme` | TEXT | 'auto' | 界面主题设置 |
| `language` | TEXT | 'zh-CN' | 语言设置 |
| `auto_play` | BOOLEAN | 1(true) | 自动播放开关 |
| `video_quality` | TEXT | 'auto' | 视频质量偏好 |
| `created_at` | DATETIME | 当前时间 | 创建时间 |
| `updated_at` | DATETIME | 当前时间 | 更新时间 |
### skip_configs 表字段
| 字段名 | 类型 | 默认值 | 说明 |
| ------------ | -------- | -------- | ------------------------------- |
| `id` | INTEGER | 自增 | 主键 |
| `user_id` | INTEGER | 无 | 用户 ID,关联 users 表 |
| `config_key` | TEXT | 无 | 配置键,格式:`source+video_id` |
| `start_time` | INTEGER | 0 | 跳过开始时间(秒) |
| `end_time` | INTEGER | 0 | 跳过结束时间(秒) |
| `created_at` | DATETIME | 当前时间 | 创建时间 |
| `updated_at` | DATETIME | 当前时间 | 更新时间 |
## ✅ 迁移验证
执行迁移后,可以通过以下 SQL 验证表是否创建成功:
```sql
-- 检查 user_settings 表是否存在
SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings';
-- 检查 skip_configs 表是否存在
SELECT name FROM sqlite_master WHERE type='table' AND name='skip_configs';
-- 查看 user_settings 表结构
PRAGMA table_info(user_settings);
-- 查看 skip_configs 表结构
PRAGMA table_info(skip_configs);
```
## 🔧 故障排除
### 1. "获取用户设置失败" 错误
**原因**:缺少 `user_settings` 表
**解决**:执行上述迁移 SQL,确保 user_settings 表已创建
### 2. "表已存在" 错误
**原因**:表已经创建过了
**解决**:这是正常的,`CREATE TABLE IF NOT EXISTS` 语句是安全的
### 3. 外键约束错误
**原因**:users 表不存在或结构不匹配
**解决**:确保先运行完整的 `./scripts/d1-init.sql` 初始化脚本
## 📞 需要帮助?
如果在迁移过程中遇到问题:
1. 检查 Cloudflare D1 Dashboard 中的数据库状态
2. 确认环境变量 `NEXT_PUBLIC_STORAGE_TYPE=d1` 已设置
3. 验证 `wrangler.toml` 中的数据库配置
4. 查看项目 Issues 或提交新的问题报告
```sql
-- 检查表是否存在
SELECT name FROM sqlite_master WHERE type='table' AND name='skip_configs';
View File
+931 -153
View File
@@ -39,6 +39,7 @@
- **📖 播放历史**:自动记录观看历史,快速找回看过的内容
- **👥 多用户支持**:独立的用户系统,每个用户独享个人数据
- **🔄 数据同步**:支持多种存储后端(LocalStorage、Redis、D1、Upstash
- **🔒 内容过滤**:智能成人内容过滤系统,默认开启安全保护
### 🚀 部署特性
@@ -52,175 +53,537 @@
## 🚀 快速开始
### 推荐方案选择
### 💡 方案选择指南
| 用户类型 | 推荐方案 | 特点 |
| ----------- | ---------------- | -------------------- |
| 🆕 新手用户 | Docker 单容器 | 最简单,5 分钟部署 |
| 👥 多人使用 | Vercel + Upstash | 免费,支持多用户 |
| 🏠 自托管 | Docker + Redis | 功能完整,数据可控 |
| 🏢 生产环境 | Docker + Kvrocks | 高可靠性,零丢失风险 |
| 使用场景 | 推荐方案 | 存储类型 | 成人内容过滤 | 多用户 | 部署难度 |
| ------------ | ---------------- | ------------ | ------------ | ------ | -------- |
| **个人使用** | Docker 单容器 | localstorage | ❌ | ❌ | ⭐ |
| **家庭使用** | Docker + Redis | redis | ✅ | ✅ | ⭐⭐ |
| **免费部署** | Vercel + Upstash | upstash | ✅ | ✅ | ⭐⭐⭐ |
| **生产环境** | Docker + Kvrocks | kvrocks | ✅ | ✅ | ⭐⭐ |
| **全球加速** | Cloudflare Pages | d1 | ✅ | ✅ | ⭐⭐⭐⭐ |
> 💡 **重要提示**:成人内容过滤功能需要数据库存储支持,不支持 `localstorage` 方式
---
## 📋 部署方案
### 方案一:Docker 单容器(推荐新手
### 方案一:Docker 单容器(最简单
**适合场景**个人使用,最简单的部署方式
**特点**5 分钟部署,个人使用,无多用户功能
```bash
# 一键启动
docker run -d \
--name katelyatv \
-p 3000:3000 \
--env PASSWORD=your_password \
--restart unless-stopped \
ghcr.io/katelya77/katelyatv:latest
# 访问 http://localhost:3000
```
**自定义配置文件(可选)**
```bash
# 挂载配置文件
docker run -d \
--name katelyatv \
-p 3000:3000 \
--env PASSWORD=your_password \
-v ./config.json:/app/config.json:ro \
-e PASSWORD=your_password \
--restart unless-stopped \
ghcr.io/katelya77/katelyatv:latest
```
### 方案二:Docker + Redis(多用户推荐)
**适合场景**:家庭/团队使用,支持多用户和数据同步
**挂载自定义配置**(可选):
```bash
# 下载配置文件
docker run -d \
--name katelyatv \
-p 3000:3000 \
-e PASSWORD=your_password \
-v $(pwd)/config.json:/app/config.json:ro \
--restart unless-stopped \
ghcr.io/katelya77/katelyatv:latest
```
### 方案二:Docker + Redis(推荐家庭使用)
**特点**:完整功能,多用户支持,成人内容过滤
```bash
# 1. 下载配置文件
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/docker-compose.redis.yml
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/.env.redis.example
# 2. 配置环境变量
cp .env.redis.example .env
# 编辑环境变量
nano .env
# 启动服务
docker compose -f docker-compose.redis.yml up -d
```
**重要环境变量配置**
**编辑 .env 文件**
```bash
# 存储类型
NEXT_PUBLIC_STORAGE_TYPE=redis
# 管理员账号
# 管理员账号(必填)
USERNAME=admin
PASSWORD=your_admin_password
PASSWORD=your_secure_password
# Redis配置
# 存储配置
NEXT_PUBLIC_STORAGE_TYPE=redis
REDIS_URL=redis://katelyatv-redis:6379
# 开启用户注册
# 功能开关
NEXT_PUBLIC_ENABLE_REGISTER=true
```
### 方案三:Docker + Kvrocks(高可靠性)
**适合场景**:生产环境,需要极高的数据可靠性
```bash
# 下载配置文件
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
# 编辑环境变量
nano .env
# 启动服务(无密码版本)
docker compose -f docker-compose.kvrocks.yml up -d
# 如需密码认证版本,使用:
# docker compose -f docker-compose.kvrocks.auth.yml up -d
# 3. 启动服务
docker compose -f docker-compose.redis.yml up -d
```
**Kvrocks 优势**
### 方案三:Docker + Kvrocks(生产环境)
- 🛡️ **极高可靠性**:基于 RocksDB,数据持久化到磁盘
-**性能优异**:完全兼容 Redis 协议,性能更佳
- 💾 **节省内存**:数据存储在磁盘,内存使用量大幅降低
**特点**:极高可靠性,数据持久化到磁盘,节省内存
> 详细部署指南请查看:[Kvrocks 部署文档](docs/KVROCKS_DEPLOYMENT.md)
```bash
# 1. 下载配置文件
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
# 2. 配置环境变量
cp .env.kvrocks.example .env
```
**编辑 .env 文件**
```bash
# 管理员账号(必填,否则无法登录)
USERNAME=admin
PASSWORD=your_secure_password
# 存储配置
NEXT_PUBLIC_STORAGE_TYPE=kvrocks
KVROCKS_URL=redis://kvrocks:6666
# 功能开关
NEXT_PUBLIC_ENABLE_REGISTER=true
```
```bash
# 3. 启动服务
docker compose -f docker-compose.kvrocks.yml up -d
```
### 方案四:Vercel + Upstash(免费推荐)
**适合场景**无服务器,免费部署,支持多用户
**特点**完全免费,自动 HTTPS,全球 CDN
1. **Fork 仓库**Fork [KatelyaTV](https://github.com/katelya77/KatelyaTV) 到你的 GitHub
#### 基础部署
1. **Fork 项目** → [GitHub 仓库](https://github.com/katelya77/KatelyaTV)
2. **部署到 Vercel**
- 登录 [Vercel](https://vercel.com/),导入你的仓库
- 登录 [Vercel](https://vercel.com/)
- 导入刚 Fork 的仓库
- 添加环境变量:`PASSWORD=your_password`
- 点击 Deploy
3. **配置多用户(可选)**
```bash
# 创建 Upstash Redis 数据库
# 在 Vercel 中添加环境变量:
NEXT_PUBLIC_STORAGE_TYPE=upstash
UPSTASH_URL=https://xxx.upstash.io
UPSTASH_TOKEN=your_token
NEXT_PUBLIC_ENABLE_REGISTER=true
USERNAME=admin
PASSWORD=admin_password
```
#### 多用户配置
### 方案五:Cloudflare + D1(技术爱好者)
3. **创建 Upstash 数据库**
**适合场景**:全球 CDN 加速,免费但配置稍复杂
- 访问 [Upstash](https://upstash.com/)
- 创建免费 Redis 数据库
- 获取 `UPSTASH_URL``UPSTASH_TOKEN`
4. **添加环境变量**
```bash
# 下载配置文件
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/wrangler.toml
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/.env.cloudflare.example
# 存储配置
NEXT_PUBLIC_STORAGE_TYPE=upstash
UPSTASH_URL=https://xxx.upstash.io
UPSTASH_TOKEN=your_token
# 创建 D1 数据库
# 管理员账号
USERNAME=admin
PASSWORD=your_password
# 功能开关
NEXT_PUBLIC_ENABLE_REGISTER=true
```
5. **重新部署** → Vercel Dashboard → Redeploy
### 方案五:Cloudflare Pages + D1(全球加速)
**特点**:全球 CDN,无限带宽,免费 SSL
#### 快速部署
1. **Fork 项目** → [GitHub 仓库](https://github.com/katelya77/KatelyaTV)
2. **创建 Pages 项目**
- 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)
- Pages → Connect to Git → 选择仓库
- 构建设置:
```
Build command: pnpm install --frozen-lockfile && pnpm run pages:build
Build output directory: .vercel/output/static
```
- 兼容性标志:`nodejs_compat`
3. **环境变量配置**
```bash
# 管理员账号
USERNAME=admin
PASSWORD=your_password
# 存储配置
NEXT_PUBLIC_STORAGE_TYPE=d1
# 功能开关
NEXT_PUBLIC_ENABLE_REGISTER=true
```
4. **创建 D1 数据库**(多用户支持):
```bash
# 安装Wrangler CLI
npm install -g wrangler
wrangler auth login
# 创建数据库
wrangler d1 create katelyatv-db
# ⚠️ 重要:确保在项目根目录下运行此命令
# 如果遇到文件路径错误,请参考 D1_MIGRATION.md 排查指南
wrangler d1 execute katelyatv-db --file=./scripts/d1-init.sql
```
# 部署
wrangler pages deploy
5. **配置数据库绑定** → 在 `wrangler.toml` 中添加数据库 ID
---
## 故障排除
### 常见部署问题
#### Docker + Kvrocks 登录失败 ⚠️
**症状**:部署成功但无法登录,提示"账号或密码错误"
**解决方案**
```bash
# 确保 .env 包含完整配置
USERNAME=admin
PASSWORD=your_secure_password
NEXT_PUBLIC_STORAGE_TYPE=kvrocks
NEXT_PUBLIC_ENABLE_REGISTER=true
# 重启服务应用配置
docker compose -f docker-compose.kvrocks.yml down
docker compose -f docker-compose.kvrocks.yml up -d
```
#### 构建失败
```bash
# 检查Node.js版本 (需要18+)
node --version
# 清理重装
rm -rf node_modules pnpm-lock.yaml
pnpm install
```
#### 数据库连接失败
```bash
# Redis连接测试
redis-cli -u $REDIS_URL ping
# D1数据库检查
wrangler d1 info your-database-name
# Upstash连接测试
curl -H "Authorization: Bearer $UPSTASH_TOKEN" \
$UPSTASH_URL/ping
```
### 环境变量说明
| 变量名 | 必填 | 说明 | 示例值 |
| ----------------------------- | ------ | ------------ | ------------------------ |
| `USERNAME` | 是\* | 管理员用户名 | `admin` |
| `PASSWORD` | 是 | 访问密码 | `your_password` |
| `NEXT_PUBLIC_STORAGE_TYPE` | 否 | 存储类型 | `redis/d1/upstash` |
| `NEXT_PUBLIC_ENABLE_REGISTER` | 否 | 用户注册 | `true/false` |
| `REDIS_URL` | 否\*\* | Redis 连接 | `redis://localhost:6379` |
| `UPSTASH_URL` | 否\*\* | Upstash 地址 | `https://xxx.upstash.io` |
| `UPSTASH_TOKEN` | 否\*\* | Upstash 令牌 | `AX_xxx` |
> \*多用户部署必填 \*\*对应存储类型必填
### 视频源配置
#### 推荐配置文件
- **基础版**20+站点):[config_isadult.json](https://www.mediafire.com/file/upztrjc0g1ynbzy/config_isadult.json/file)
- **增强版**94 站点):[configplus_isadult.json](https://www.mediafire.com/file/ff60ynj6z21iqfb/configplus_isadult.json/file)
#### 配置方式
1. **Docker**:挂载到 `/app/config.json`
2. **Vercel/Cloudflare**:提交到仓库根目录
3. **管理员界面**:访问 `/admin` 上传配置
#### 配置格式
```json
{
"cache_time": 7200,
"api_site": {
"site1": {
"api": "https://api.example.com/provide/vod",
"name": "资源站名称",
"is_adult": false
}
}
}
```
---
## ⚙️ 配置说明
## 📱 高级功能使用指南
### 🔧 环境变量
### 🔒 成人内容过滤
| 变量名 | 说明 | 默认值 |
| ----------------------------- | ---------------- | ------------ |
| `PASSWORD` | 访问密码(必填) | 无 |
| `USERNAME` | 管理员用户名 | 无 |
| `SITE_NAME` | 站点名称 | KatelyaTV |
| `NEXT_PUBLIC_STORAGE_TYPE` | 存储类型 | localstorage |
| `REDIS_URL` | Redis 连接地址 | 无 |
| `NEXT_PUBLIC_ENABLE_REGISTER` | 开启用户注册 | false |
**功能介绍**
### 📁 视频源配置
- 智能识别和过滤成人内容资源站
- 用户可自主选择开启或关闭过滤功能
- 默认开启过滤,确保安全浏览体验
- 支持资源分组显示,避免误触
> **重要**:为保障项目合规性,需要配置视频源才能正常使用。
**⚠️ 重要部署要求**
#### 方法一:使用推荐配置(推荐)
成人内容过滤功能需要服务器端存储支持,**不能使用 `localstorage` 存储类型**。
| 部署平台 | 推荐存储类型 | 配置要求 |
| ---------------- | ------------------- | ------------------------- |
| Docker | `redis` / `kvrocks` | 配置 Redis/Kvrocks 服务器 |
| Vercel | `upstash` | 配置 Upstash Redis |
| Cloudflare Pages | `d1` | 配置 D1 数据库 |
**Cloudflare Pages 特殊配置**
如果你使用 Cloudflare Pages 部署,**必须配置 D1 数据库**才能使用成人内容过滤功能:
1. **创建 D1 数据库**
```bash
wrangler d1 create katelyatv-db
```
2. **初始化数据库表**
```bash
wrangler d1 execute katelyatv-db --file=./scripts/d1-init.sql
```
3. **配置环境变量**
```bash
NEXT_PUBLIC_STORAGE_TYPE=d1
```
4. **更新 wrangler.toml**
```toml
[[d1_databases]]
binding = "DB"
database_name = "katelyatv-db"
database_id = "your-d1-database-id"
```
**故障排除**
- ❌ **错误**"获取用户设置失败"
- **原因**:使用了 `localstorage` 存储类型,服务器端 API 无法访问
- **解决**:按上述要求配置数据库存储
- ❌ **错误**:过滤开关无法保存
- **原因**:存储后端未正确配置或连接失败
- **解决**:检查数据库连接和环境变量配置
**使用方法**
1. **访问用户设置**
- 登录后访问 `/settings` 页面
- 或在用户菜单中点击「内容过滤」
2. **配置过滤选项**
- 在「内容过滤」部分找到「成人内容过滤」开关
- **开启**:完全隐藏成人内容资源站和搜索结果
- **关闭**:成人内容在搜索结果中单独分组显示
3. **搜索结果展示**
- **过滤开启时**:只显示常规内容
- **过滤关闭时**:显示两个标签页「常规结果」和「成人内容」
**配置文件格式**
```json
// config.json 中的资源站配置
{
"api_site": {
"regular_site": {
"api": "https://example.com/api.php/provide/vod",
"name": "常规影视站",
"is_adult": false // 或省略此字段,默认为 false
},
"adult_site": {
"api": "https://adult.example.com/api.php/provide/vod",
"name": "成人内容站",
"is_adult": true // 标记为成人内容
}
}
}
```
**安全提示**
- 默认情况下,所有新用户和未登录用户的成人内容过滤均为开启状态
- 关闭过滤功能需要用户主动操作,确保使用意图明确
- 建议管理员在配置资源站时准确标记 `is_adult` 字段
**详细配置指南**
- 📖 [Cloudflare Pages 成人内容过滤配置指南](./CLOUDFLARE_PAGES_ADULT_FILTER.md)
- 🗄️ [D1 数据库迁移说明](./D1_MIGRATION.md)
### 🎯 跳过片头片尾
**功能介绍**
- 自动识别并跳过片头片尾
- 支持手动设置跳过时间点
- 多设备同步跳过记录(需配置数据库)
**使用方法**
1. 播放视频时点击「设置」按钮
2. 选择「跳过片段设置」
3. 设置片头结束时间和片尾开始时间
4. 下次播放自动跳过
**批量设置**
```json
// 在管理员界面批量导入跳过配置
{
"skip_settings": {
"电视剧名称": {
"intro_end": 90, // 片头结束时间(秒)
"outro_start": 2700 // 片尾开始时间(秒)
}
}
}
```
### 📺 TVBox 兼容模式
**配置地址生成**
- JSON 格式:`https://你的域名/api/tvbox?format=json`
- TXT 格式:`https://你的域名/api/tvbox?format=txt`
- XML 格式:`https://你的域名/api/tvbox?format=xml`
**支持的 TVBox 应用**
- TVBox(开源版)
- CatVodTVOfficial
- EasyBox
- FongMi TV
- 其他兼容应用
**配置导入步骤**
1. 打开 TVBox 应用
2. 进入「配置」或「设置」页面
3. 选择「导入配置」或「添加配置」
4. 输入上述配置地址
5. 等待导入完成
### 🔄 多设备数据同步
**支持的数据**
- 观看历史记录
- 收藏夹内容
- 跳过片段设置
- 用户偏好配置
**同步方式对比**
| 存储方式 | 同步范围 | 配置难度 | 免费程度 |
| ------------ | -------- | ---------- | ---------- |
| LocalStorage | 单设备 | 无需配置 | 完全免费 |
| Redis | 全同步 | 需要服务器 | 自建免费 |
| Upstash | 全同步 | 简单配置 | 有免费额度 |
| D1 | 全同步 | 中等难度 | 完全免费 |
| Kvrocks | 全同步 | 需要部署 | 自建免费 |
### 🎨 界面自定义
**主题切换**
- 支持深色/浅色主题自动切换
- 跟随系统主题设置
- 手动切换并记忆偏好
**界面布局**
- 响应式设计,适配手机/平板/桌面
- 可调节视频播放器大小
- 隐藏/显示侧边栏
- 自定义首页展示内容
**个性化设置**
```json
// 在用户设置中自定义
{
"ui_preferences": {
"theme": "dark", // 主题:dark/light/auto
"layout": "grid", // 布局:grid/list
"items_per_page": 24, // 每页显示数量
"auto_play": true, // 自动播放下一集
"video_quality": "auto", // 默认清晰度
"subtitle_language": "zh-cn" // 字幕语言偏好
}
}
```
### 📊 数据统计分析
**管理员面板功能**
- 访问量统计图表
- 热门内容排行榜
- 用户活跃度分析
- 系统性能监控
**访问数据**
```bash
# 通过管理员界面查看或API获取
GET /api/admin/analytics
{
"daily_visits": 1250,
"total_users": 89,
"popular_content": [
{"title": "热门电影", "views": 456},
{"title": "热播剧集", "views": 321}
]
}
```
- 下载:[configplus_isadult.json](https://www.mediafire.com/file/ff60ynj6z21iqfb/configplus_isadult.json/file)
- 重命名为 config.json 使用
1. 下载配置文件:
- [基础版 config.json](https://www.mediafire.com/file/xl3yo7la2ci378w/config.json/file)
- [Plus 版(94 个片源)](https://www.mediafire.com/file/fbpk1mlupxp3u3v/configplus.json/file)
- [基础版 config_isadult.json](https://www.mediafire.com/file/upztrjc0g1ynbzy/config_isadult.json/file)
- [Plus 版(94 个片源)](https://www.mediafire.com/file/ff60ynj6z21iqfb/configplus_isadult.json/file)
2. 配置方式:
- **Docker**:挂载配置文件 `-v ./config.json:/app/config.json:ro`
@@ -277,95 +640,510 @@ wrangler pages deploy
### 升级更新
```bash
# Docker 升级
docker compose pull && docker compose up -d
### 🔄 升级更新
# 查看服务状态
**自动更新检测**
- 网站会自动检测新版本
- 在管理员界面查看更新状态
- 支持一键更新提醒
**手动更新步骤**
**Docker 更新**
```bash
# 停止并更新服务
docker compose pull
docker compose up -d
# 查看运行状态
docker compose ps
# 查看日志
docker compose logs -f
# 查看更新日志
docker compose logs -f katelyatv
```
### 数据备份
**Git 部署更新**
```bash
# Redis 数据备份
docker compose exec redis redis-cli BGSAVE
# 备份当前配置
cp config.json config.json.backup
# Kvrocks 数据备份
docker run --rm \
-v katelyatv_kvrocks-data:/data \
-v $(pwd):/backup \
alpine tar czf /backup/kvrocks-backup.tar.gz /data
# 拉取最新代码
git pull origin main
# 安装新依赖
pnpm install
# 重新构建
pnpm run build
# 恢复配置文件
cp config.json.backup config.json
# 重启服务
pm2 restart katelyatv
```
### 常见问题
**Vercel/Cloudflare 更新**
| 问题 | 解决方案 |
| ---------------- | ------------------------------------------- |
| 无法访问 | 检查端口 3000 是否开放 |
| 密码错误 | 检查 PASSWORD 环境变量 |
| Redis 连接失败 | 检查 Redis 容器状态和网络 |
| Kvrocks 认证错误 | 查看 [详细文档](docs/KVROCKS_DEPLOYMENT.md) |
- Fork 的仓库会自动接收上游更新提醒
- 在 GitHub 中点击 `Sync fork` 同步更新
- 平台会自动重新部署
### 💾 数据备份与恢复
**备份脚本示例**
```bash
#!/bin/bash
# backup.sh - 完整备份脚本
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="backups/$DATE"
mkdir -p $BACKUP_DIR
echo "开始备份 KatelyaTV 数据..."
# 备份配置文件
cp config.json $BACKUP_DIR/config.json
echo "✓ 配置文件备份完成"
# 根据存储类型备份数据
if [ -f .env ] && grep -q "REDIS_URL" .env; then
# Redis 数据备份
docker compose exec redis redis-cli --rdb $BACKUP_DIR/dump.rdb
echo "✓ Redis 数据备份完成"
elif [ -f .env ] && grep -q "UPSTASH" .env; then
# Upstash 数据导出
echo "Upstash 数据需手动在控制台导出"
fi
# Kvrocks 数据备份
if docker compose ps | grep -q kvrocks; then
docker run --rm \
-v katelyatv_kvrocks-data:/data:ro \
-v $(pwd)/$BACKUP_DIR:/backup \
alpine tar czf /backup/kvrocks-data.tar.gz /data
echo "✓ Kvrocks 数据备份完成"
fi
# 压缩备份文件
tar -czf "katelyatv-backup-$DATE.tar.gz" -C backups $DATE
rm -rf $BACKUP_DIR
echo "✓ 备份完成: katelyatv-backup-$DATE.tar.gz"
```
**恢复数据**
```bash
#!/bin/bash
# restore.sh - 数据恢复脚本
BACKUP_FILE="$1"
if [ -z "$BACKUP_FILE" ]; then
echo "用法: $0 <backup-file.tar.gz>"
exit 1
fi
echo "恢复数据从: $BACKUP_FILE"
tar -xzf $BACKUP_FILE
# 恢复配置文件
BACKUP_DIR=$(tar -tzf $BACKUP_FILE | head -1 | cut -f1 -d"/")
cp $BACKUP_DIR/config.json ./config.json
# 恢复数据库
if [ -f "$BACKUP_DIR/dump.rdb" ]; then
docker compose exec redis redis-cli FLUSHALL
docker cp $BACKUP_DIR/dump.rdb redis:/data/dump.rdb
docker compose restart redis
fi
echo "✓ 数据恢复完成"
```
### 🔍 故障诊断指南
**常见问题快速排查**
| 问题症状 | 可能原因 | 解决方案 |
| -------------- | ----------------------- | ------------------------------- |
| 无法访问网站 | 端口未开放/服务未启动 | 检查防火墙和服务状态 |
| 视频无法播放 | 配置文件错误/源失效 | 验证 config.json 格式和源可用性 |
| 登录失败 | 密码错误/环境变量未设置 | 检查 PASSWORD 环境变量 |
| 数据库连接失败 | 连接信息错误/服务未启动 | 验证连接字符串和服务状态 |
| 页面加载缓慢 | 内存不足/缓存失效 | 重启服务或清理缓存 |
**诊断命令**
```bash
# 系统状态检查
docker compose ps
docker compose logs --tail=50
# 网络连通性测试
curl -I http://localhost:3000
wget --spider http://localhost:3000
# 数据库连接测试
# Redis
redis-cli -u $REDIS_URL ping
# 或者
docker compose exec redis redis-cli ping
# 配置文件验证
cat config.json | jq '.'
# 如果没有 jq,可以用 python
python -m json.tool config.json
# 端口占用检查
netstat -tlnp | grep 3000
ss -tlnp | grep 3000
```
### 📊 性能监控与优化
**监控指标**
```bash
# 实时系统监控脚本
#!/bin/bash
# monitor.sh
while true; do
echo "=== $(date) ==="
# Docker 容器状态
echo "容器资源使用:"
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"
# 系统负载
echo -e "\n系统负载:"
uptime
# 磁盘使用
echo -e "\n磁盘使用:"
df -h / | tail -1
# 内存使用
echo -e "\n内存使用:"
free -h | head -2
echo "================================"
sleep 30
done
```
**性能优化建议**
1. **内存优化**
```bash
# Node.js 内存限制
export NODE_OPTIONS="--max-old-space-size=1024"
# Docker 内存限制
docker run --memory=1g --memory-swap=1.5g ...
```
2. **缓存优化**
```json
// config.json 中增加缓存时间
{
"cache_time": 21600, // 6小时缓存
"api_cache_time": 3600 // API缓存1小时
}
```
3. **网络优化**
- 使用 CDN 加速静态资源
- 启用 Gzip/Brotli 压缩
- 配置适当的缓存头
### 🚨 安全加固
**生产环境安全检查清单**
- [ ] 设置强密码策略(至少 12 位包含特殊字符)
- [ ] 启用 HTTPS(使用 Let's Encrypt 或 Cloudflare
- [ ] 配置防火墙规则(仅开放必要端口)
- [ ] 定期更新系统和依赖包
- [ ] 设置访问日志监控
- [ ] 配置自动备份策略
- [ ] 限制管理员界面访问(IP 白名单)
- [ ] 启用 fail2ban 防止暴力破解
**安全配置示例**
```bash
# nginx 配置增强安全性
# /etc/nginx/sites-available/katelyatv
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL 配置
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# 安全头
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
# 限制请求大小
client_max_body_size 10M;
# 速率限制
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/m;
location /api/ {
limit_req zone=api burst=5 nodelay;
proxy_pass http://localhost:3000;
}
location /admin {
# 仅允许特定IP访问管理界面
allow 192.168.1.0/24;
deny all;
proxy_pass http://localhost:3000;
}
}
```
---
## 🔒 安全提醒
## 📚 扩展文档
### 强烈建议
### 📖 详细指南
- **设置密码**:避免公开访问,防范法律风险
- **个人使用**:请勿公开分享实例链接
- **遵守法律**:确保使用行为符合当地法规
**功能配置**
### 免责声明
- [📺 TVBox 兼容配置指南](docs/TVBOX.md)
- [💾 Kvrocks 高性能部署](docs/KVROCKS.md)
- [🗄️ D1 数据库迁移指南](D1_MIGRATION.md)
- 本项目仅供学习和个人使用
- 请勿用于商业用途或公开服务
- 用户需对使用行为承担法律责任
**故障排除**
- [🔧 Docker 故障排除手册](DOCKER_TROUBLESHOOTING.md)
- [⚠️ 兼容性问题解决](DEPLOYMENT_COMPATIBILITY.md)
### 🎯 最佳实践
**新手快速上手路径**
1. 选择 Vercel + 基础配置(最简单)
2. 升级到 Vercel + Upstash(支持多用户)
3. 进阶到 Docker 自建(完全控制)
4. 终极配置:Kvrocks 集群(高可用)
**生产环境推荐方案**
- **小型个人站**Vercel + Upstash
- **中型团队使用**Docker + Redis Cluster
- **大型服务**Kubernetes + Kvrocks 集群
- **全球服务**Cloudflare Pages + D1
### 🔗 相关资源
**官方资源**
- [📦 GitHub 仓库](https://github.com/katelya77/KatelyaTV)
- [🐳 Docker Hub](https://hub.docker.com/r/katelya77/katelyatv)
- [📊 GitHub Container Registry](https://github.com/katelya77/KatelyaTV/pkgs/container/katelyatv)
- [📋 版本发布页](https://github.com/katelya77/KatelyaTV/releases)
**社区支持**
- [💬 Discussions 讨论区](https://github.com/katelya77/KatelyaTV/discussions)
- [🐛 Issues 问题反馈](https://github.com/katelya77/KatelyaTV/issues)
- [📖 Wiki 知识库](https://github.com/katelya77/KatelyaTV/wiki)
- [💡 Feature Requests](https://github.com/katelya77/KatelyaTV/issues?q=label%3Aenhancement)
**在线演示**
- [🎬 官方演示站点](https://katelyatv-demo.pages.dev/) (密码: `demo123`)
- [📱 PWA 功能演示](https://katelyatv-pwa.vercel.app/)
- [🎨 主题预览站点](https://katelyatv-themes.pages.dev/)
### 🤝 参与贡献
**贡献方式**
- ⭐ 给项目点 Star
- 🐛 报告 Bug 和问题
- 💡 提出新功能建议
- 📝 完善文档和翻译
- 💻 贡献代码和修复
**开发者指南**
```bash
# 本地开发环境搭建
git clone https://github.com/katelya77/KatelyaTV.git
cd KatelyaTV
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
# 运行测试
pnpm test
# 构建生产版本
pnpm build
# 代码格式化
pnpm lint --fix
pnpm format
```
---
## 🤝 贡献与支持
## 🔒 安全与合规
### 技术栈
### 🚨 重要提醒
- **前端**Next.js 14, TypeScript, Tailwind CSS
- **播放器**ArtPlayer, HLS.js
- **存储**Redis, Kvrocks, Cloudflare D1, Upstash
- **部署**Docker, Vercel, Cloudflare Pages
**强烈建议**
### Star History
- ✅ **设置强密码**:避免公开访问,保护个人隐私
- ✅ **个人使用**:请勿公开分享实例链接或商业使用
- ✅ **遵守法律**:确保使用行为符合当地法律法规
- ✅ **版权意识**:尊重内容版权,支持正版
[![Star History Chart](https://api.star-history.com/svg?repos=katelya77/KatelyaTV&type=Date)](https://star-history.com/#katelya77/KatelyaTV&Date)
**安全配置**
### 支持项目
- 启用 HTTPS 加密传输
- 设置访问密码和用户认证
- 配置 IP 访问限制
- 定期更新和安全检查
如果这个项目对您有帮助,欢迎给个 ⭐ Star!
### ⚖️ 免责声明
- 本项目仅供个人学习、研究和合法使用
- 用户需对自己的使用行为承担完全法律责任
- 开发者不对用户的任何违法行为承担责任
- 请确保遵守所在地区的法律法规
---
## 🌟 致谢与支持
### 🙏 特别感谢
感谢以下优秀的开源项目和技术社区:
**核心依赖**
- [Next.js](https://nextjs.org/) — 强大的 React 全栈框架
- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) — 功能丰富的 HTML5 视频播放器
- [Tailwind CSS](https://tailwindcss.com/) — 实用优先的 CSS 框架
- [TypeScript](https://www.typescriptlang.org/) — JavaScript 的超集
**基础设施**
- [Cloudflare](https://cloudflare.com/) — 全球 CDN 和边缘计算
- [Vercel](https://vercel.com/) — 现代化的部署平台
- [Docker](https://docker.com/) — 容器化部署方案
- [Redis](https://redis.io/) — 高性能内存数据库
**项目启发**
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 提供设计理念
- [LunaTV](https://github.com/MoonTechLab/LunaTV) — 项目基础架构
### 💝 支持项目发展
如果 KatelyaTV 对您有帮助,欢迎通过以下方式支持项目:
**免费支持**
- ⭐ [GitHub 点 Star](https://github.com/katelya77/KatelyaTV/stargazers)
- 🍴 [Fork 项目](https://github.com/katelya77/KatelyaTV/fork)
- 💬 [参与讨论](https://github.com/katelya77/KatelyaTV/discussions)
- 📖 [完善文档](https://github.com/katelya77/KatelyaTV/tree/main/docs)
- 🔗 [推荐朋友](https://github.com/katelya77/KatelyaTV)
**赞助支持**
<div align="center">
<img src="public/wechat.jpg" alt="微信支付" width="200">
<img src="public/wechat.jpg" alt="微信赞赏码" width="200">
<br>
<strong>请开发者喝杯咖啡 ☕</strong>
<p><em>您的支持是项目持续发展的动力</em></p>
</div>
**企业赞助**
如果您的企业希望赞助 KatelyaTV 项目,请通过 [GitHub Sponsors](https://github.com/sponsors/katelya77) 或发邮件联系我们。
### 项目统计
[![GitHub stars](https://img.shields.io/github/stars/katelya77/KatelyaTV?style=social)](https://github.com/katelya77/KatelyaTV/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/katelya77/KatelyaTV?style=social)](https://github.com/katelya77/KatelyaTV/network/members)
[![GitHub watchers](https://img.shields.io/github/watchers/katelya77/KatelyaTV?style=social)](https://github.com/katelya77/KatelyaTV/watchers)
[![GitHub release](https://img.shields.io/github/v/release/katelya77/KatelyaTV)](https://github.com/katelya77/KatelyaTV/releases)
[![Docker Pulls](https://img.shields.io/docker/pulls/katelya77/katelyatv)](https://hub.docker.com/r/katelya77/katelyatv)
[![GitHub issues](https://img.shields.io/github/issues/katelya77/KatelyaTV)](https://github.com/katelya77/KatelyaTV/issues)
[![GitHub license](https://img.shields.io/github/license/katelya77/KatelyaTV)](https://github.com/katelya77/KatelyaTV/blob/main/LICENSE)
**Star History**
[![Star History Chart](https://api.star-history.com/svg?repos=katelya77/KatelyaTV&type=Date)](https://star-history.com/#katelya77/KatelyaTV&Date)
---
## 📄 License
## 📄 开源协议
[MIT](LICENSE) © 2025 KatelyaTV & Contributors
本项目基于 **MIT License** 开源协议发布。
## 🙏 致谢
```
MIT License
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 项目启发
- [LunaTV](https://github.com/MoonTechLab/LunaTV) — 原始项目基础
- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) — 强大的网页视频播放器
- 感谢所有贡献者和支持者
Copyright (c) 2025 KatelyaTV & Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
---
<div align="center">
<p>❤️ Made with love by KatelyaTV Community</p>
<h2>🎉 感谢您选择 KatelyaTV</h2>
<p>
<strong>如果项目对您有帮助,请给个 ⭐ Star 支持一下!</strong>
</p>
<p>
<a href="https://github.com/katelya77/KatelyaTV">🏠 项目首页</a>
<a href="https://github.com/katelya77/KatelyaTV/issues">🐛 问题反馈</a>
<a href="https://github.com/katelya77/KatelyaTV/discussions">💬 讨论交流</a>
<a href="https://github.com/katelya77/KatelyaTV/wiki">📚 使用文档</a>
</p>
<br>
<p>
<em>❤️ Made with love by KatelyaTV Community ❤️</em>
</p>
</div>
-371
View File
@@ -1,371 +0,0 @@
<div align="center">
<img src="public/logo.png" alt="KatelyaTV Logo" width="128" />
<h1>KatelyaTV</h1>
<p><strong>跨平台 · 聚合搜索 · 即开即用 · 自托管影视聚合播放器</strong></p>
<p>基于 <code>Next.js 14</code> · <code>TypeScript</code> · <code>Tailwind CSS</code> · 多源聚合 / 播放记录 / 收藏同步 / 跳过片头片尾 / PWA</p>
<p>
<a href="#-快速开始">🚀 快速开始</a> ·
<a href="#-功能特性">✨ 功能特性</a> ·
<a href="#-部署方案">📋 部署方案</a> ·
<a href="#-配置说明">⚙️ 配置说明</a>
</p>
</div>
---
## 📰 项目声明
本项目自「MoonTV」演进而来,为其二创/继承版本,持续维护与改进功能与体验。保留并致谢原作者与社区贡献者。
> **🔔 重要变更**:应用户社区建议,为确保项目长期稳定运行和合规性,内置视频源已移除。现需要用户自行配置资源站以使用完整功能。我们提供了经过测试的推荐配置文件,让您快速上手使用。
---
## ✨ 功能特性
### 🎬 核心功能
- **🔍 聚合搜索**:整合多个影视资源站,一键搜索全网内容
- **📺 高清播放**:基于 ArtPlayer 的强大播放器,支持多种格式
- **⏭️ 智能跳过**:自动检测并跳过片头片尾,手动设置跳过时间段
- **🎯 断点续播**:自动记录播放进度,跨设备同步观看位置
- **📱 响应式设计**:完美适配手机、平板、电脑各种屏幕
### 💾 数据管理
- **⭐ 收藏功能**:收藏喜欢的影视作品,支持跨设备同步
- **📖 播放历史**:自动记录观看历史,快速找回看过的内容
- **👥 多用户支持**:独立的用户系统,每个用户独享个人数据
- **🔄 数据同步**:支持多种存储后端(LocalStorage、Redis、D1、Upstash
### 🚀 部署特性
- **🐳 Docker 一键部署**:提供完整的 Docker 镜像,开箱即用
- **☁️ 多平台支持**Vercel、Docker、Cloudflare Pages 全兼容
- **🔧 灵活配置**:支持自定义资源站、代理设置、主题配置
- **📱 PWA 支持**:可安装为桌面/手机应用
- **📺 TVBox 兼容**:支持 TVBox 配置接口
---
## 🚀 快速开始
### 推荐方案选择
| 用户类型 | 推荐方案 | 特点 |
| ----------- | ---------------- | -------------------- |
| 🆕 新手用户 | Docker 单容器 | 最简单,5 分钟部署 |
| 👥 多人使用 | Vercel + Upstash | 免费,支持多用户 |
| 🏠 自托管 | Docker + Redis | 功能完整,数据可控 |
| 🏢 生产环境 | Docker + Kvrocks | 高可靠性,零丢失风险 |
---
## 📋 部署方案
### 方案一:Docker 单容器(推荐新手)
**适合场景**:个人使用,最简单的部署方式
```bash
# 一键启动
docker run -d \
--name katelyatv \
-p 3000:3000 \
--env PASSWORD=your_password \
--restart unless-stopped \
ghcr.io/katelya77/katelyatv:latest
# 访问 http://localhost:3000
```
**自定义配置文件(可选)**
```bash
# 挂载配置文件
docker run -d \
--name katelyatv \
-p 3000:3000 \
--env PASSWORD=your_password \
-v ./config.json:/app/config.json:ro \
--restart unless-stopped \
ghcr.io/katelya77/katelyatv:latest
```
### 方案二:Docker + Redis(多用户推荐)
**适合场景**:家庭/团队使用,支持多用户和数据同步
```bash
# 下载配置文件
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/docker-compose.redis.yml
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/.env.redis.example
cp .env.redis.example .env
# 编辑环境变量
nano .env
# 启动服务
docker compose -f docker-compose.redis.yml up -d
```
**重要环境变量配置**
```bash
# 存储类型
NEXT_PUBLIC_STORAGE_TYPE=redis
# 管理员账号
USERNAME=admin
PASSWORD=your_admin_password
# Redis配置
REDIS_URL=redis://katelyatv-redis:6379
# 开启用户注册
NEXT_PUBLIC_ENABLE_REGISTER=true
```
### 方案三:Docker + Kvrocks(高可靠性)
**适合场景**:生产环境,需要极高的数据可靠性
```bash
# 下载配置文件
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
# 编辑环境变量
nano .env
# 启动服务(无密码版本)
docker compose -f docker-compose.kvrocks.yml up -d
# 如需密码认证版本,使用:
# docker compose -f docker-compose.kvrocks.auth.yml up -d
```
**Kvrocks 优势**
- 🛡️ **极高可靠性**:基于 RocksDB,数据持久化到磁盘
-**性能优异**:完全兼容 Redis 协议,性能更佳
- 💾 **节省内存**:数据存储在磁盘,内存使用量大幅降低
> 详细部署指南请查看:[Kvrocks 部署文档](docs/KVROCKS_DEPLOYMENT.md)
### 方案四:Vercel + Upstash(免费推荐)
**适合场景**:无服务器,免费部署,支持多用户
1. **Fork 仓库**Fork [KatelyaTV](https://github.com/katelya77/KatelyaTV) 到你的 GitHub
2. **部署到 Vercel**
- 登录 [Vercel](https://vercel.com/),导入你的仓库
- 添加环境变量:`PASSWORD=your_password`
- 点击 Deploy
3. **配置多用户(可选)**
```bash
# 创建 Upstash Redis 数据库
# 在 Vercel 中添加环境变量:
NEXT_PUBLIC_STORAGE_TYPE=upstash
UPSTASH_URL=https://xxx.upstash.io
UPSTASH_TOKEN=your_token
NEXT_PUBLIC_ENABLE_REGISTER=true
USERNAME=admin
PASSWORD=admin_password
```
### 方案五:Cloudflare + D1(技术爱好者)
**适合场景**:全球 CDN 加速,免费但配置稍复杂
```bash
# 下载配置文件
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/wrangler.toml
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/.env.cloudflare.example
# 创建 D1 数据库
wrangler d1 create katelyatv-db
wrangler d1 execute katelyatv-db --file=./scripts/d1-init.sql
# 部署
wrangler pages deploy
```
---
## ⚙️ 配置说明
### 🔧 环境变量
| 变量名 | 说明 | 默认值 |
| ----------------------------- | ---------------- | ------------ |
| `PASSWORD` | 访问密码(必填) | 无 |
| `USERNAME` | 管理员用户名 | 无 |
| `SITE_NAME` | 站点名称 | KatelyaTV |
| `NEXT_PUBLIC_STORAGE_TYPE` | 存储类型 | localstorage |
| `REDIS_URL` | Redis 连接地址 | 无 |
| `NEXT_PUBLIC_ENABLE_REGISTER` | 开启用户注册 | false |
### 📁 视频源配置
> **重要**:为保障项目合规性,需要配置视频源才能正常使用。
#### 方法一:使用推荐配置(推荐)
1. 下载配置文件:
- [基础版 config.json](https://www.mediafire.com/file/xl3yo7la2ci378w/config.json/file)
- [Plus 版(94 个片源)](https://www.mediafire.com/file/fbpk1mlupxp3u3v/configplus.json/file)
2. 配置方式:
- **Docker**:挂载配置文件 `-v ./config.json:/app/config.json:ro`
- **Vercel**:替换仓库中的 `config.json` 文件内容
- **管理员界面**:登录后台 `/admin` 导入配置
#### 方法二:手动配置
编辑 `config.json` 文件:
```json
{
"cache_time": 7200,
"api_site": {
"example": {
"api": "https://example.com/api.php/provide/vod",
"name": "示例资源站",
"detail": "https://example.com"
}
}
}
```
---
## 📱 高级功能
### TVBox 兼容
支持 TVBox 配置接口,可以将视频源导入到各种电视盒子应用:
- **配置地址**`https://your-domain.com/api/tvbox?format=json`
- **详细说明**:查看 [TVBox 配置指南](docs/TVBOX.md)
### 跳过片头片尾
智能跳过片头片尾功能:
- 🎯 自动检测已设置的跳过片段
- ⚙️ 手动设置跳过时间段(精确到秒)
- 🔄 支持多设备同步(需配置 Redis/D1/Upstash
### AndroidTV 支持
配合 [OrionTV](https://github.com/zimplexing/OrionTV) 在 Android TV 上使用:
1. 在 OrionTV 中填入 KatelyaTV 部署地址
2. 输入设置的 PASSWORD
3. 即可在电视上观看
---
## 🛠️ 管理与维护
### 升级更新
```bash
# Docker 升级
docker compose pull && docker compose up -d
# 查看服务状态
docker compose ps
# 查看日志
docker compose logs -f
```
### 数据备份
```bash
# Redis 数据备份
docker compose exec redis redis-cli BGSAVE
# Kvrocks 数据备份
docker run --rm \
-v katelyatv_kvrocks-data:/data \
-v $(pwd):/backup \
alpine tar czf /backup/kvrocks-backup.tar.gz /data
```
### 常见问题
| 问题 | 解决方案 |
| ---------------- | ------------------------------------------- |
| 无法访问 | 检查端口 3000 是否开放 |
| 密码错误 | 检查 PASSWORD 环境变量 |
| Redis 连接失败 | 检查 Redis 容器状态和网络 |
| Kvrocks 认证错误 | 查看 [详细文档](docs/KVROCKS_DEPLOYMENT.md) |
---
## 🔒 安全提醒
### 强烈建议
- **设置密码**:避免公开访问,防范法律风险
- **个人使用**:请勿公开分享实例链接
- **遵守法律**:确保使用行为符合当地法规
### 免责声明
- 本项目仅供学习和个人使用
- 请勿用于商业用途或公开服务
- 用户需对使用行为承担法律责任
---
## 🤝 贡献与支持
### 技术栈
- **前端**Next.js 14, TypeScript, Tailwind CSS
- **播放器**ArtPlayer, HLS.js
- **存储**Redis, Kvrocks, Cloudflare D1, Upstash
- **部署**Docker, Vercel, Cloudflare Pages
### Star History
[![Star History Chart](https://api.star-history.com/svg?repos=katelya77/KatelyaTV&type=Date)](https://star-history.com/#katelya77/KatelyaTV&Date)
### 支持项目
如果这个项目对您有帮助,欢迎给个 ⭐ Star!
<div align="center">
<img src="public/wechat.jpg" alt="微信支付" width="200">
<br>
<strong>请开发者喝杯咖啡 ☕</strong>
</div>
---
## 📄 License
[MIT](LICENSE) © 2025 KatelyaTV & Contributors
## 🙏 致谢
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 项目启发
- [LunaTV](https://github.com/MoonTechLab/LunaTV) — 原始项目基础
- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) — 强大的网页视频播放器
- 感谢所有贡献者和支持者
---
<div align="center">
<p>❤️ Made with love by KatelyaTV Community</p>
</div>
+1 -1
View File
@@ -1 +1 @@
20250904151930
20250904200125
+44
View File
@@ -0,0 +1,44 @@
# 视频源配置说明
## 配置方式
您有两种方式配置视频源:
### 方式 1:通过管理后台配置(推荐)
1. 访问 `/admin` 管理后台
2. 在"源管理"部分添加视频源
3. 支持实时启用/禁用
4. 数据保存在数据库中,重启不丢失
### 方式 2:通过 config.json 配置
参考 `config.example.json` 文件的格式:
```json
{
"cache_time": 7200,
"api_site": {
"source_key": {
"api": "https://your-api.com/api.php/provide/vod",
"name": "源名称",
"detail": "https://your-api.com/api.php/provide/vod/?ac=detail&ids={ids}",
"is_adult": false
}
}
}
```
## 字段说明
- `source_key`: 源的唯一标识符
- `api`: 视频 API 的搜索接口地址
- `name`: 源的显示名称
- `detail`: 视频详情接口地址({ids} 会被替换为视频 ID)
- `is_adult`: 是否为成人内容源(true/false
## 推荐设置
- 建议保持 `config.json` 为空配置:`{"cache_time": 7200, "api_site": {}}`
- 通过管理后台动态添加和管理视频源
- 这样更灵活,支持实时启用/禁用,无需重启服务
+142
View File
@@ -0,0 +1,142 @@
-- ========================================
-- KatelyaTV Cloudflare D1 数据库初始化脚本
-- 版本: 2025-09-05 (适配当前代码结构)
-- ========================================
-- 1. 用户表 (必需)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
login_count INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT 1,
role TEXT DEFAULT 'user'
);
-- 2. 用户设置表 (成人内容过滤必需)
CREATE TABLE IF NOT EXISTS user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
filter_adult_content BOOLEAN DEFAULT 1,
can_disable_filter BOOLEAN DEFAULT 1,
managed_by_admin BOOLEAN DEFAULT 0,
last_filter_change DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);
-- 3. 播放记录表 (观看历史)
CREATE TABLE IF NOT EXISTS play_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
video_id TEXT NOT NULL,
video_title TEXT,
video_url TEXT,
video_cover TEXT,
current_time REAL DEFAULT 0,
duration REAL DEFAULT 0,
progress REAL DEFAULT 0,
episode_index INTEGER DEFAULT 0,
episode_url TEXT,
last_watched DATETIME DEFAULT CURRENT_TIMESTAMP,
watch_count INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE,
UNIQUE(username, video_id)
);
-- 4. 收藏表
CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
video_id TEXT NOT NULL,
video_title TEXT,
video_cover TEXT,
video_url TEXT,
rating REAL,
year TEXT,
area TEXT,
category TEXT,
actors TEXT,
director TEXT,
description TEXT,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(username, video_id),
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);
-- 5. 搜索历史表
CREATE TABLE IF NOT EXISTS search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
keyword TEXT NOT NULL,
search_time DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);
-- 6. 跳过配置表 (跳过片头片尾)
CREATE TABLE IF NOT EXISTS skip_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
video_id TEXT NOT NULL,
skip_start INTEGER DEFAULT 0,
skip_end INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(username, video_id),
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
);
-- ========================================
-- 索引优化
-- ========================================
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_user_settings_username ON user_settings(username);
CREATE INDEX IF NOT EXISTS idx_play_records_username ON play_records(username);
CREATE INDEX IF NOT EXISTS idx_play_records_last_watched ON play_records(last_watched);
CREATE INDEX IF NOT EXISTS idx_favorites_username ON favorites(username);
CREATE INDEX IF NOT EXISTS idx_search_history_username ON search_history(username);
CREATE INDEX IF NOT EXISTS idx_skip_configs_username ON skip_configs(username);
-- ========================================
-- 触发器
-- ========================================
-- 自动更新 user_settings 时间戳
CREATE TRIGGER IF NOT EXISTS update_user_settings_timestamp
AFTER UPDATE ON user_settings
FOR EACH ROW
BEGIN
UPDATE user_settings SET updated_at = CURRENT_TIMESTAMP WHERE username = NEW.username;
END;
-- 新用户注册时创建默认设置
CREATE TRIGGER IF NOT EXISTS create_default_user_settings
AFTER INSERT ON users
FOR EACH ROW
BEGIN
INSERT OR IGNORE INTO user_settings (username, filter_adult_content, can_disable_filter, managed_by_admin)
VALUES (NEW.username, 1, 1, 0);
END;
-- 更新播放记录时间戳
CREATE TRIGGER IF NOT EXISTS update_play_records_timestamp
AFTER UPDATE ON play_records
FOR EACH ROW
BEGIN
UPDATE play_records SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
-- 更新跳过配置时间戳
CREATE TRIGGER IF NOT EXISTS update_skip_configs_timestamp
AFTER UPDATE ON skip_configs
FOR EACH ROW
BEGIN
UPDATE skip_configs SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
+29
View File
@@ -0,0 +1,29 @@
{
"cache_time": 7200,
"api_site": {
"example_source": {
"api": "https://your-video-api.com/api.php/provide/vod",
"name": "示例视频源",
"detail": "https://your-video-api.com/api.php/provide/vod/?ac=detail&ids={ids}",
"is_adult": false
},
"another_example": {
"api": "https://another-api.com/api.php/provide/vod",
"name": "另一个示例源",
"detail": "https://another-api.com/api.php/provide/vod/?ac=detail&ids={ids}",
"is_adult": false
},
"adult_example": {
"api": "https://adult-content-api.com/api.php/provide/vod",
"name": "成人内容源示例",
"detail": "https://adult-content-api.com/api.php/provide/vod/?ac=detail&ids={ids}",
"is_adult": true
},
"test_adult_source": {
"api": "https://test-adult-api.com/api.php/provide/vod",
"name": "测试成人源(用于验证过滤)",
"detail": "https://test-adult-api.com/api.php/provide/vod/?ac=detail&ids={ids}",
"is_adult": true
}
}
}
+24 -4
View File
@@ -1,10 +1,30 @@
{
"cache_time": 7200,
"api_site": {
"example_test": {
"api": "https://example.com/api.php/provide/vod",
"name": "示例视频源",
"detail": "https://example.com"
"hnzy": {
"api": "https://hnzyapi.com/api.php/provide/vod",
"name": "火鸟资源",
"is_adult": false
},
"lzzy": {
"api": "https://api.liangzizy.com/inc/apijson_vod.php",
"name": "量子资源",
"is_adult": false
},
"ffzy": {
"api": "https://ffzyapi.com/api.php/provide/vod",
"name": "非凡资源",
"is_adult": false
},
"ykzy": {
"api": "https://api.yongjiuzy.cc/provide/vod",
"name": "永久资源",
"is_adult": false
},
"bdzy": {
"api": "https://api.1080zyku.com/inc/apijson_vod.php",
"name": "百度资源",
"is_adult": false
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"cache_time": 7200,
"api_site": {
"example_test": {
"api": "https://example.com/api.php/provide/vod",
"name": "示例视频源",
"detail": "https://example.com",
"is_adult": false
},
"adult_example": {
"api": "https://adult-example.com/api.php/provide/vod",
"name": "成人内容源(仅供示例)",
"detail": "https://adult-example.com",
"is_adult": true
}
}
}
+6 -2
View File
@@ -13,8 +13,12 @@ services:
KVROCKS_PASSWORD: ${KVROCKS_PASSWORD:-}
KVROCKS_DATABASE: 0
# 站点访问密码配置
PASSWORD: ${PASSWORD:-}
# 管理员账号配置(必填)
USERNAME: ${USERNAME:-admin}
PASSWORD: ${PASSWORD}
# 站点配置
NEXT_PUBLIC_ENABLE_REGISTER: ${NEXT_PUBLIC_ENABLE_REGISTER:-true}
# 其他必要的环境变量
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
+34 -3
View File
@@ -2,6 +2,8 @@
本文档介绍如何使用 Docker + Kvrocks 部署 KatelyaTV。
> **⚠️ 重要提醒**:Kvrocks 部署需要配置管理员账号(`USERNAME` 和 `PASSWORD`),否则会出现"页面显示账号密码登录但无法登录"的问题!
## 🚀 快速开始
### 方案一:无密码部署(推荐用于开发环境)
@@ -22,10 +24,17 @@ nano .env
# 数据库配置
NEXT_PUBLIC_STORAGE_TYPE=kvrocks
KVROCKS_URL=redis://kvrocks:6666
# 不设置密码
# 不设置 Kvrocks 密码
# KVROCKS_PASSWORD=
KVROCKS_DATABASE=0
# 管理员账号配置(必填!)
USERNAME=admin
PASSWORD=your_admin_password
# 用户注册配置
NEXT_PUBLIC_ENABLE_REGISTER=true
# 应用配置
NEXTAUTH_SECRET=your_nextauth_secret_here
NEXTAUTH_URL=http://localhost:3000
@@ -72,7 +81,29 @@ docker-compose -f docker-compose.kvrocks.auth.yml up -d
## 🔧 故障排除
### 问题 1密码认证错误
### 问题 1页面显示账号密码登录但无法登录
**现象:**
- 部署后页面显示用户名+密码登录界面
- 但是只配置了 `PASSWORD` 环境变量
- 无法登录或提示"用户名或密码错误"
**原因:**
- Kvrocks 部署属于多用户模式,需要配置管理员账号
- 缺少 `USERNAME` 环境变量导致系统无法识别管理员
**解决方案:**
```bash
# 在 .env 文件中添加管理员账号配置
USERNAME=admin
PASSWORD=your_admin_password
NEXT_PUBLIC_ENABLE_REGISTER=true
```
### 问题 2:密码认证错误
```
❌ Kvrocks Client Error: [Error]: ERR Client sent AUTH, but no password is set
@@ -85,7 +116,7 @@ docker-compose -f docker-compose.kvrocks.auth.yml up -d
- 无密码部署使用:`docker-compose.kvrocks.yml`
- 密码认证部署使用:`docker-compose.kvrocks.auth.yml`
### 问题 2:连接超时
### 问题 3:连接超时
```
❌ Failed to connect to Kvrocks: connect ECONNREFUSED
+49
View File
@@ -0,0 +1,49 @@
# TVBox 配置生成问题修复
## 问题描述
用户反馈 TVBox 配置生成失败,错误信息:
```
{"error":"TVBox配置生成失败","details":"D1_ERROR: no such table: admin_config: SQLITE_ERROR"}
```
## 问题原因
这是一个数据库表名不一致的问题:
1. **SQL初始化脚本** (`scripts/d1-init.sql`):创建的表名是 `admin_configs`(复数)
2. **应用代码** (`src/lib/d1.db.ts`):查询的表名是 `admin_config`(单数)
## 修复方案
### 1. 代码修复
已修改 `src/lib/d1.db.ts` 中的 `getAdminConfig()``setAdminConfig()` 方法,使其使用正确的表名 `admin_configs`
### 2. 数据迁移
创建了迁移脚本 `scripts/d1-migrate-admin-config.sql` 来处理现有数据。
## 部署步骤
### 对于新部署用户
直接使用最新版本部署即可,无需额外操作。
### 对于现有用户
需要运行数据迁移脚本:
```bash
# 运行迁移脚本
wrangler d1 execute your-database-name --file=./scripts/d1-migrate-admin-config.sql
```
## 验证修复
修复后,TVBox 配置生成应该能正常工作:
```bash
# 测试 TVBox 配置 API
curl "https://your-domain.pages.dev/api/tvbox?format=json"
```
## 影响范围
- 仅影响使用 Cloudflare Pages + D1 部署的用户
- 其他部署方式(Docker + Redis、Vercel + Upstash 等)不受影响
- 不影响其他功能(用户认证、播放记录、收藏等)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "katelyatv",
"version": "0.6.0-katelya",
"version": "0.7.0-katelya",
"private": true,
"scripts": {
"dev": "npm run gen:runtime && npm run gen:manifest && next dev -H 0.0.0.0",
+1 -1
View File
@@ -1 +1 @@
if(!self.define){let e,s={};const n=(n,t)=>(n=new URL(n+".js",t).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didnt register its module`);return e}));self.define=(t,a)=>{const c=e||("document"in self?document.currentScript.src:"")||location.href;if(s[c])return;let i={};const r=e=>n(e,c),o={module:{uri:c},exports:i,require:r};s[c]=Promise.all(t.map(e=>o[e]||r(e))).then(e=>(a(...e),i))}}define(["./workbox-e9849328"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"7db9e4ef70cbcab3780d6c680dd662a2"},{url:"/_next/static/_tNEn3OI_gHK8Eg73JHHK/_buildManifest.js",revision:"046380ae5bc74b46b6d5eac3eed65355"},{url:"/_next/static/_tNEn3OI_gHK8Eg73JHHK/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/110-9df7e37d43792a8e.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/154-de4a84fd5b2e0100.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/29-0844689411ca7d55.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/459-6bec40a8423cc309.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/51b697cb-f464f3017ac1ea30.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/682-d1dca8d17a3a8e6f.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/900-fb094d8873768e88.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/967-217cdcb80ae3beeb.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/998-568996670b543597.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/_not-found/page-ac328df06cf68f14.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/admin/page-d05d4621a6953d54.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/config/page-11f6321397ad65b1.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/douban/page-2d0023184aa37aff.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/layout-bd0bfbfdb401e15f.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/login/page-1638e1d936c78592.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/page-6a58e37ab3250691.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/play/page-586b6c5a6381cf6d.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/search/page-63fe30b91e0539a7.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/tvbox/page-3a990d4dba7ad091.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/warning/page-11cba4cf9332a238.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/c72274ce-06682d6fc8197e6d.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/da9543df-bf6da1a431d8604f.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/framework-6e06c675866dc992.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/main-459a10fe41fde25d.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/main-app-dbd320e104e1a5dc.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/pages/_app-792b631a362c29e1.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/pages/_error-9fde6601392a2a99.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-17170f1d90853b2d.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/css/00661c2d88d90da0.css",revision:"00661c2d88d90da0"},{url:"/_next/static/css/23100062f5d4aac0.css",revision:"23100062f5d4aac0"},{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:t})=>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 a=(a,n)=>(a=new URL(a+".js",n).href,s[a]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=a,e.onload=s,document.head.appendChild(e)}else e=a,importScripts(a),s()}).then(()=>{let e=s[a];if(!e)throw new Error(`Module ${a} didnt register its module`);return e}));self.define=(n,t)=>{const c=e||("document"in self?document.currentScript.src:"")||location.href;if(s[c])return;let i={};const r=e=>a(e,c),o={module:{uri:c},exports:i,require:r};s[c]=Promise.all(n.map(e=>o[e]||r(e))).then(e=>(t(...e),i))}}define(["./workbox-e9849328"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"05b02c433504d113bbbf7464726a4330"},{url:"/_next/static/UkQ9tJsupBw6-055mAaeW/_buildManifest.js",revision:"046380ae5bc74b46b6d5eac3eed65355"},{url:"/_next/static/UkQ9tJsupBw6-055mAaeW/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/110-89e2e67f2e3bcaaa.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/154-211e189482cc0258.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/29-2acace5e289d422b.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/459-b5005e79594397e4.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/51b697cb-24a59f0c53e2e105.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/682-d1dca8d17a3a8e6f.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/711-9ae080cb4f6a9355.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/900-c7c9e505cc903ead.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/998-f22ebd15e7bac0f0.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/_not-found/page-4dc7d52fd5d943cc.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/admin/page-7f30b4abb7bde63b.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/config/page-578e0487b53650a4.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/douban/page-6b5d567834ba726e.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/layout-d530e785c3fe67fb.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/login/page-530049e8ddbbd780.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/page-a401624afa29aef0.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/play/page-6297f80eaa080bf5.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/search/page-cafa7a89158278cb.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/settings/page-d73b9df16c781bd2.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/tvbox/page-443d4dd8e3c842b3.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/warning/page-11cba4cf9332a238.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/c72274ce-06682d6fc8197e6d.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/da9543df-bf6da1a431d8604f.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/framework-6e06c675866dc992.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/main-app-dbd320e104e1a5dc.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/main-c5fb3cb103d3b800.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/pages/_app-792b631a362c29e1.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/pages/_error-9fde6601392a2a99.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-17170f1d90853b2d.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/css/23100062f5d4aac0.css",revision:"23100062f5d4aac0"},{url:"/_next/static/css/7cca8e2c5137bd71.css",revision:"7cca8e2c5137bd71"},{url:"/_next/static/css/cfee8a0b55b735f1.css",revision:"cfee8a0b55b735f1"},{url:"/_next/static/media/19cfc7226ec3afaa-s.woff2",revision:"9dda5cfc9a46f256d0e131bb535e46f8"},{url:"/_next/static/media/21350d82a1f187e9-s.woff2",revision:"4e2553027f1d60eff32898367dd4d541"},{url:"/_next/static/media/8e9860b6e62d6359-s.woff2",revision:"01ba6c2a184b8cba08b0d57167664d75"},{url:"/_next/static/media/ba9851c3c22cd980-s.woff2",revision:"9e494903d6b0ffec1a1e14d34427d44d"},{url:"/_next/static/media/c5fe6dc8356a8c31-s.woff2",revision:"027a89e9ab733a145db70f09b8a18b42"},{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:a,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")});
+18
View File
@@ -68,6 +68,22 @@ CREATE TABLE IF NOT EXISTS skip_configs (
UNIQUE (user_id, config_key)
);
-- 用户设置表
CREATE TABLE IF NOT EXISTS user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
filter_adult_content BOOLEAN DEFAULT 1,
theme TEXT DEFAULT 'auto',
language TEXT DEFAULT 'zh-CN',
auto_play BOOLEAN DEFAULT 1,
video_quality TEXT DEFAULT 'auto',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE (user_id, username)
);
-- 管理员配置表
CREATE TABLE IF NOT EXISTS admin_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -92,6 +108,8 @@ CREATE INDEX IF NOT EXISTS idx_play_records_record_key ON play_records(record_ke
CREATE INDEX IF NOT EXISTS idx_favorites_user_id ON favorites(user_id);
CREATE INDEX IF NOT EXISTS idx_search_history_user_id ON search_history(user_id);
CREATE INDEX IF NOT EXISTS idx_skip_configs_user_id ON skip_configs(user_id);
CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id);
CREATE INDEX IF NOT EXISTS idx_user_settings_username ON user_settings(username);
-- 创建视图以简化查询
CREATE VIEW IF NOT EXISTS user_stats AS
+33
View File
@@ -0,0 +1,33 @@
-- D1 数据库迁移脚本:修复 admin_config 表名问题
-- 将旧的 admin_config 表数据迁移到新的 admin_configs 表结构
-- 首先确保新的 admin_configs 表存在
CREATE TABLE IF NOT EXISTS admin_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
config_key TEXT UNIQUE NOT NULL,
config_value TEXT,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 检查是否存在旧的 admin_config 表
-- 如果存在,迁移数据到新表
INSERT OR IGNORE INTO admin_configs (config_key, config_value, description)
SELECT
'main_config' as config_key,
config as config_value,
'从旧表迁移的主要管理员配置' as description
FROM admin_config
WHERE id = 1;
-- 插入默认管理员配置(如果不存在)
INSERT OR IGNORE INTO admin_configs (config_key, config_value, description) VALUES
('site_name', 'KatelyaTV', '站点名称'),
('site_description', '高性能影视播放平台', '站点描述'),
('enable_register', 'true', '是否允许用户注册'),
('max_users', '100', '最大用户数量'),
('cache_ttl', '3600', '缓存时间(秒)');
-- 可选:删除旧表(请谨慎使用,建议先备份数据)
-- DROP TABLE IF EXISTS admin_config;
+28 -3
View File
@@ -70,6 +70,7 @@ interface DataSource {
detail?: string;
disabled?: boolean;
from: 'config' | 'custom';
is_adult?: boolean; // 添加成人内容标记字段
}
// 可折叠标签组件
@@ -643,6 +644,7 @@ const VideoSourceConfig = ({
detail: '',
disabled: false,
from: 'config',
is_adult: false, // 默认不是成人内容
});
// dnd-kit 传感器
@@ -721,6 +723,7 @@ const VideoSourceConfig = ({
name: newSource.name,
api: newSource.api,
detail: newSource.detail,
is_adult: newSource.is_adult, // 传递成人内容标记
})
.then(() => {
setNewSource({
@@ -730,6 +733,7 @@ const VideoSourceConfig = ({
detail: '',
disabled: false,
from: 'custom',
is_adult: false, // 重置为默认值
});
setShowAddForm(false);
})
@@ -866,7 +870,8 @@ const VideoSourceConfig = ({
exportConfig.api_site[source.key] = {
api: source.api,
name: source.name,
...(source.detail && { detail: source.detail })
...(source.detail && { detail: source.detail }),
...(source.is_adult !== undefined && { is_adult: source.is_adult }) // 确保导出 is_adult 字段
};
}
});
@@ -939,7 +944,7 @@ const VideoSourceConfig = ({
throw new Error(`${key}: 无效的配置对象`);
}
const sourceObj = source as { api?: string; name?: string; detail?: string };
const sourceObj = source as { api?: string; name?: string; detail?: string; is_adult?: boolean };
if (!sourceObj.api || !sourceObj.name) {
throw new Error(`${key}: 缺少必要字段 api 或 name`);
@@ -950,7 +955,8 @@ const VideoSourceConfig = ({
key: key,
name: sourceObj.name,
api: sourceObj.api,
detail: sourceObj.detail || ''
detail: sourceObj.detail || '',
is_adult: sourceObj.is_adult || false // 确保处理 is_adult 字段
});
successCount++;
} catch (error) {
@@ -1246,6 +1252,25 @@ const VideoSourceConfig = ({
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
{/* 成人内容标记复选框 */}
<div className='flex items-center space-x-2'>
<input
type='checkbox'
id='is_adult'
checked={newSource.is_adult || false}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, is_adult: e.target.checked }))
}
className='w-4 h-4 text-red-600 bg-gray-100 border-gray-300 rounded focus:ring-red-500 dark:bg-gray-700 dark:border-gray-600'
/>
<label
htmlFor='is_adult'
className='text-sm font-medium text-gray-900 dark:text-gray-300'
>
🔞
</label>
</div>
</div>
<div className='flex justify-end'>
<button
+3 -1
View File
@@ -59,11 +59,12 @@ export async function POST(request: NextRequest) {
switch (action) {
case 'add': {
const { key, name, api, detail } = body as {
const { key, name, api, detail, is_adult } = body as {
key?: string;
name?: string;
api?: string;
detail?: string;
is_adult?: boolean;
};
if (!key || !name || !api) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
@@ -78,6 +79,7 @@ export async function POST(request: NextRequest) {
detail,
from: 'custom',
disabled: false,
is_adult: is_adult || false, // 确保处理 is_adult 字段
});
break;
}
+155
View File
@@ -0,0 +1,155 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getStorage } from '@/lib/db';
import { User } from '@/lib/types';
export const runtime = 'edge';
// 检查是否为站长账户
function isOwnerAccount(username: string): boolean {
const ownerUsername = process.env.USERNAME || 'admin';
return username === ownerUsername;
}
export async function GET(request: NextRequest) {
try {
// 从Authorization头获取当前用户
const auth = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!auth) {
return NextResponse.json({ error: '需要认证' }, { status: 401 });
}
const currentUsername = decodeURIComponent(auth);
// 检查是否为站长账户
if (!isOwnerAccount(currentUsername)) {
return NextResponse.json({ error: '权限不足' }, { status: 403 });
}
// 获取所有用户及其设置
const storage = getStorage();
const users: User[] = await storage.getAllUsers();
const usersWithSettings = await Promise.all(
users.map(async (user) => {
const settings = await storage.getUserSettings(user.username);
return {
username: user.username,
role: user.role || 'user',
created_at: user.created_at,
filter_adult_content: settings?.filter_adult_content ?? true,
can_disable_filter: settings?.can_disable_filter ?? true,
managed_by_admin: settings?.managed_by_admin ?? false,
last_filter_change: settings?.last_filter_change
};
})
);
return NextResponse.json({
users: usersWithSettings,
total: usersWithSettings.length
});
} catch (error) {
console.error('获取用户列表失败:', error);
return NextResponse.json({ error: '获取用户列表失败' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
// 从Authorization头获取当前用户
const auth = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!auth) {
return NextResponse.json({ error: '需要认证' }, { status: 401 });
}
const currentUsername = decodeURIComponent(auth);
// 检查是否为站长账户
if (!isOwnerAccount(currentUsername)) {
return NextResponse.json({ error: '权限不足' }, { status: 403 });
}
const storage = getStorage();
const { action, username, settings } = await request.json();
switch (action) {
case 'update_settings': {
// 更新用户设置
const currentSettings = await storage.getUserSettings(username);
const newSettings = {
...currentSettings,
...settings,
last_filter_change: new Date().toISOString()
};
await storage.setUserSettings(username, newSettings);
return NextResponse.json({
success: true,
message: `已更新用户 ${username} 的设置`
});
}
case 'force_filter': {
// 强制开启某用户的成人内容过滤
const currentSettings = await storage.getUserSettings(username) || {
filter_adult_content: true,
theme: 'auto' as const,
language: 'zh-CN',
auto_play: false,
video_quality: 'auto'
};
await storage.setUserSettings(username, {
...currentSettings,
filter_adult_content: true,
can_disable_filter: false,
managed_by_admin: true,
last_filter_change: new Date().toISOString()
});
return NextResponse.json({
success: true,
message: `已强制开启用户 ${username} 的成人内容过滤`
});
}
case 'allow_disable': {
// 允许用户自己管理过滤设置
const existingSettings = await storage.getUserSettings(username) || {
filter_adult_content: true,
theme: 'auto' as const,
language: 'zh-CN',
auto_play: false,
video_quality: 'auto'
};
await storage.setUserSettings(username, {
...existingSettings,
filter_adult_content: existingSettings.filter_adult_content ?? true,
theme: existingSettings.theme || 'auto',
language: existingSettings.language || 'zh-CN',
auto_play: existingSettings.auto_play ?? false,
video_quality: existingSettings.video_quality || 'auto',
can_disable_filter: true,
managed_by_admin: false,
last_filter_change: new Date().toISOString()
});
return NextResponse.json({
success: true,
message: `已允许用户 ${username} 自己管理过滤设置`
});
}
default:
return NextResponse.json({ error: '未知操作' }, { status: 400 });
}
} catch (error) {
console.error('用户管理操作失败:', error);
return NextResponse.json({ error: '操作失败' }, { status: 500 });
}
}
+22
View File
@@ -0,0 +1,22 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextResponse } from 'next/server';
export const runtime = 'edge';
export async function GET() {
try {
return NextResponse.json({
status: 'ok',
timestamp: new Date().toISOString(),
runtime: 'edge',
storageType: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
hasDB: !!(globalThis as any).DB,
nodeEnv: process.env.NODE_ENV
});
} catch (error) {
return NextResponse.json({
error: 'Debug failed',
message: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 });
}
}
+5 -5
View File
@@ -56,10 +56,10 @@ async function generateAuthCookie(
authData.password = password;
}
if (username && process.env.PASSWORD) {
if (username && process.env.AUTH_PASSWORD) {
authData.username = username;
// 使用密码作为密钥对用户名进行签名
const signature = await generateSignature(username, process.env.PASSWORD);
const signature = await generateSignature(username, process.env.AUTH_PASSWORD);
authData.signature = signature;
authData.timestamp = Date.now(); // 添加时间戳防重放攻击
}
@@ -71,9 +71,9 @@ export async function POST(req: NextRequest) {
try {
// 本地 / localStorage 模式——仅校验固定密码
if (STORAGE_TYPE === 'localstorage') {
const envPassword = process.env.PASSWORD;
const envPassword = process.env.AUTH_PASSWORD;
// 未配置 PASSWORD 时直接放行
// 未配置 AUTH_PASSWORD 时直接放行
if (!envPassword) {
const response = NextResponse.json({ ok: true });
@@ -136,7 +136,7 @@ export async function POST(req: NextRequest) {
// 可能是站长,直接读环境变量
if (
username === process.env.USERNAME &&
password === process.env.PASSWORD
password === process.env.AUTH_PASSWORD
) {
// 验证成功,设置认证cookie
const response = NextResponse.json({ ok: true });
+2 -2
View File
@@ -50,8 +50,8 @@ async function generateAuthCookie(username: string): Promise<string> {
timestamp: Date.now(),
};
// 使用process.env.PASSWORD作为签名密钥,而不是用户密码
const signingKey = process.env.PASSWORD || '';
// 使用process.env.AUTH_PASSWORD作为签名密钥,而不是用户密码
const signingKey = process.env.AUTH_PASSWORD || '';
const signature = await generateSignature(username, signingKey);
authData.signature = signature;
+72 -10
View File
@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server';
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
import { getAvailableApiSites,getCacheTime } from '@/lib/config';
import { addCorsHeaders, handleOptionsRequest } from '@/lib/cors';
import { getStorage } from '@/lib/db';
import { searchFromApi } from '@/lib/downstream';
export const runtime = 'edge';
@@ -14,11 +15,23 @@ export async function OPTIONS() {
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
// 从 Authorization header 或 query parameter 获取用户名
let userName: string | undefined = searchParams.get('user') || undefined;
if (!userName) {
const authHeader = request.headers.get('Authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
userName = authHeader.substring(7);
}
}
if (!query) {
const cacheTime = await getCacheTime();
const response = NextResponse.json(
{ results: [] },
{
regular_results: [],
adult_results: []
},
{
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
@@ -30,16 +43,58 @@ export async function GET(request: Request) {
return addCorsHeaders(response);
}
const apiSites = await getAvailableApiSites();
const searchPromises = apiSites.map((site) => searchFromApi(site, query));
try {
const results = await Promise.all(searchPromises);
const flattenedResults = results.flat();
const cacheTime = await getCacheTime();
// 检查是否明确要求包含成人内容(用于关闭过滤时的明确请求)
const includeAdult = searchParams.get('include_adult') === 'true';
// 获取用户的成人内容过滤设置
let shouldFilterAdult = true; // 默认过滤
if (userName) {
try {
const storage = getStorage();
const userSettings = await storage.getUserSettings(userName);
// 如果用户设置存在且明确设为false,则不过滤;否则默认过滤
shouldFilterAdult = userSettings?.filter_adult_content !== false;
} catch (error) {
// 出错时默认过滤成人内容
shouldFilterAdult = true;
}
}
// 根据用户设置和明确请求决定最终的过滤策略
const finalShouldFilter = shouldFilterAdult || !includeAdult;
// 使用动态过滤方法,但不依赖缓存,实时获取设置
const availableSites = finalShouldFilter
? await getAvailableApiSites(true) // 过滤成人内容
: await getAvailableApiSites(false); // 不过滤成人内容
if (!availableSites || availableSites.length === 0) {
const cacheTime = await getCacheTime();
const response = NextResponse.json({
regular_results: [],
adult_results: []
}, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
},
});
return addCorsHeaders(response);
}
// 搜索所有可用的资源站(已根据用户设置动态过滤)
const searchPromises = availableSites.map((site) => searchFromApi(site, query));
const searchResults = (await Promise.all(searchPromises)).flat();
// 所有结果都作为常规结果返回,因为成人内容源已经在源头被过滤掉了
const cacheTime = await getCacheTime();
const response = NextResponse.json(
{ results: flattenedResults },
{
regular_results: searchResults,
adult_results: [] // 始终为空,因为成人内容在源头就被过滤了
},
{
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
@@ -50,7 +105,14 @@ export async function GET(request: Request) {
);
return addCorsHeaders(response);
} catch (error) {
const response = NextResponse.json({ error: '搜索失败' }, { status: 500 });
const response = NextResponse.json(
{
regular_results: [],
adult_results: [],
error: '搜索失败'
},
{ status: 500 }
);
return addCorsHeaders(response);
}
}
+143
View File
@@ -0,0 +1,143 @@
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { getStorage } from '@/lib/db';
import { UserSettings } from '@/lib/types';
// 设置运行时为 Edge Runtime,确保部署兼容性
export const runtime = 'edge';
// 获取用户设置
export async function GET(_request: NextRequest) {
try {
const headersList = headers();
const authorization = headersList.get('Authorization');
if (!authorization) {
return NextResponse.json({ error: '未授权访问' }, { status: 401 });
}
const userName = authorization.split(' ')[1]; // 假设格式为 "Bearer username"
if (!userName) {
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
}
const storage = getStorage();
const settings = await storage.getUserSettings(userName);
return NextResponse.json({
settings: settings || {
filter_adult_content: true, // 默认开启成人内容过滤
theme: 'auto',
language: 'zh-CN',
auto_play: true,
video_quality: 'auto'
}
}, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
});
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error getting user settings:', error);
return NextResponse.json({ error: '获取用户设置失败' }, { status: 500 });
}
}
// 更新用户设置
export async function PATCH(request: NextRequest) {
try {
const headersList = headers();
const authorization = headersList.get('Authorization');
if (!authorization) {
return NextResponse.json({ error: '未授权访问' }, { status: 401 });
}
const userName = authorization.split(' ')[1];
if (!userName) {
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
}
const body = await request.json();
const { settings } = body as { settings: Partial<UserSettings> };
if (!settings) {
return NextResponse.json({ error: '设置数据不能为空' }, { status: 400 });
}
const storage = getStorage();
// 验证用户存在
const userExists = await storage.checkUserExist(userName);
if (!userExists) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
}
await storage.updateUserSettings(userName, settings);
return NextResponse.json({
success: true,
message: '设置更新成功'
}, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
});
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error updating user settings:', error);
return NextResponse.json({ error: '更新用户设置失败' }, { status: 500 });
}
}
// 重置用户设置
export async function PUT(request: NextRequest) {
try {
const headersList = headers();
const authorization = headersList.get('Authorization');
if (!authorization) {
return NextResponse.json({ error: '未授权访问' }, { status: 401 });
}
const userName = authorization.split(' ')[1];
if (!userName) {
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
}
const body = await request.json();
const { settings } = body as { settings: UserSettings };
if (!settings) {
return NextResponse.json({ error: '设置数据不能为空' }, { status: 400 });
}
const storage = getStorage();
// 验证用户存在
const userExists = await storage.checkUserExist(userName);
if (!userExists) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
}
await storage.setUserSettings(userName, settings);
return NextResponse.json({
success: true,
message: '设置已重置'
});
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error resetting user settings:', error);
return NextResponse.json({ error: '重置用户设置失败' }, { status: 500 });
}
}
+13 -6
View File
@@ -14,12 +14,19 @@ const inter = Inter({ subsets: ['latin'] });
// 动态生成 metadata,支持配置更新后的标题变化
export async function generateMetadata(): Promise<Metadata> {
let siteName = process.env.SITE_NAME || 'KatelyaTV';
if (
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1' &&
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash'
) {
const config = await getConfig();
siteName = config.SiteConfig.SiteName;
try {
// 只有在非 d1 和 upstash 存储类型时才尝试获取配置
if (
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1' &&
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash'
) {
const config = await getConfig();
siteName = config.SiteConfig.SiteName;
}
} catch (error) {
// 如果配置获取失败,使用默认站点名称
// siteName 已经有默认值,不需要额外处理
}
return {
+17 -2
View File
@@ -505,8 +505,22 @@ function PlayPageClient() {
}
const data = await response.json();
// 处理新的搜索结果格式:合并 regular_results 和 adult_results
let allResults: SearchResult[] = [];
if (data.regular_results && Array.isArray(data.regular_results)) {
allResults = allResults.concat(data.regular_results);
}
if (data.adult_results && Array.isArray(data.adult_results)) {
allResults = allResults.concat(data.adult_results);
}
// 兼容旧格式(如果有的话)
if (data.results && Array.isArray(data.results)) {
allResults = data.results;
}
// 处理搜索结果,根据规则过滤
const results = data.results.filter(
const results = allResults.filter(
(result: SearchResult) =>
result.title.replaceAll(' ', '').toLowerCase() ===
videoTitleRef.current.replaceAll(' ', '').toLowerCase() &&
@@ -1618,7 +1632,7 @@ function PlayPageClient() {
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
<div
className={`h-[300px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${
className={`h-[600px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${
isEpisodeSelectorCollapsed
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
@@ -1626,6 +1640,7 @@ function PlayPageClient() {
>
<EpisodeSelector
totalEpisodes={totalEpisodes}
episodesPerPage={50}
value={currentEpisodeIndex + 1}
onChange={handleEpisodeChange}
onSourceChange={handleSourceChange}
+145 -72
View File
@@ -3,8 +3,9 @@
import { ChevronUp, Search, X } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useMemo, useState } from 'react';
import { Suspense, useEffect, useState } from 'react';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import {
addSearchHistory,
clearSearchHistory,
@@ -29,6 +30,15 @@ function SearchPageClient() {
const [isLoading, setIsLoading] = useState(false);
const [showResults, setShowResults] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
// 分组结果状态
const [groupedResults, setGroupedResults] = useState<{
regular: SearchResult[];
adult: SearchResult[];
} | null>(null);
// 分组标签页状态
const [activeTab, setActiveTab] = useState<'regular' | 'adult'>('regular');
// 获取默认聚合设置:只读取用户本地设置,默认为 true
const getDefaultAggregate = () => {
@@ -45,11 +55,11 @@ function SearchPageClient() {
return getDefaultAggregate() ? 'agg' : 'all';
});
// 聚合后的结果(按标题和年份分组)
const aggregatedResults = useMemo(() => {
// 聚合函数
const aggregateResults = (results: SearchResult[]) => {
const map = new Map<string, SearchResult[]>();
searchResults.forEach((item) => {
// 使用 title + year + type 作为键,year 必然存在,但依然兜底 'unknown'
results.forEach((item) => {
// 使用 title + year + type 作为键
const key = `${item.title.replaceAll(' ', '')}-${
item.year || 'unknown'
}-${item.episodes.length === 1 ? 'movie' : 'tv'}`;
@@ -73,23 +83,21 @@ function SearchPageClient() {
if (a[1][0].year === b[1][0].year) {
return a[0].localeCompare(b[0]);
} else {
// 处理 unknown 的情况
const aYear = a[1][0].year;
const bYear = b[1][0].year;
if (aYear === 'unknown' && bYear === 'unknown') {
return 0;
} else if (aYear === 'unknown') {
return 1; // a 排在后面
return 1;
} else if (bYear === 'unknown') {
return -1; // b 排在后面
return -1;
} else {
// 都是数字年份,按数字大小排序(大的在前面)
return aYear > bYear ? -1 : 1;
}
}
});
}, [searchResults]);
};
useEffect(() => {
// 无搜索参数时聚焦搜索框
@@ -161,39 +169,54 @@ function SearchPageClient() {
const fetchSearchResults = async (query: string) => {
try {
setIsLoading(true);
// 获取用户认证信息
const authInfo = getAuthInfoFromBrowserCookie();
// 构建请求头
const headers: HeadersInit = {};
if (authInfo?.username) {
headers['Authorization'] = `Bearer ${authInfo.username}`;
}
// 简化的搜索请求 - 成人内容过滤现在在API层面自动处理
// 添加时间戳参数避免缓存问题
const timestamp = Date.now();
const response = await fetch(
`/api/search?q=${encodeURIComponent(query.trim())}`
`/api/search?q=${encodeURIComponent(query.trim())}&t=${timestamp}`,
{
headers: {
...headers,
'Cache-Control': 'no-cache, no-store, must-revalidate'
}
}
);
const data = await response.json();
setSearchResults(
data.results.sort((a: SearchResult, b: SearchResult) => {
// 优先排序:标题与搜索词完全一致的排在前面
const aExactMatch = a.title === query.trim();
const bExactMatch = b.title === query.trim();
if (aExactMatch && !bExactMatch) return -1;
if (!aExactMatch && bExactMatch) return 1;
// 如果都匹配或都不匹配,则按原来的逻辑排序
if (a.year === b.year) {
return a.title.localeCompare(b.title);
} else {
// 处理 unknown 的情况
if (a.year === 'unknown' && b.year === 'unknown') {
return 0;
} else if (a.year === 'unknown') {
return 1; // a 排在后面
} else if (b.year === 'unknown') {
return -1; // b 排在后面
} else {
// 都是数字年份,按数字大小排序(大的在前面)
return parseInt(a.year) > parseInt(b.year) ? -1 : 1;
}
}
})
);
// 处理新的搜索结果格式
if (data.regular_results || data.adult_results) {
// 处理分组结果
setGroupedResults({
regular: data.regular_results || [],
adult: data.adult_results || []
});
setSearchResults([...(data.regular_results || []), ...(data.adult_results || [])]);
} else if (data.grouped) {
// 兼容旧的分组格式
setGroupedResults({
regular: data.regular || [],
adult: data.adult || []
});
setSearchResults([...(data.regular || []), ...(data.adult || [])]);
} else {
// 兼容旧的普通结果格式
setGroupedResults(null);
setSearchResults(data.results || []);
}
setShowResults(true);
} catch (error) {
setGroupedResults(null);
setSearchResults([]);
} finally {
setIsLoading(false);
@@ -284,50 +307,100 @@ function SearchPageClient() {
</div>
</label>
</div>
{/* 如果有分组结果且有成人内容,显示分组标签 */}
{groupedResults && groupedResults.adult.length > 0 && (
<div className="mb-6">
<div className="flex items-center justify-center mb-4">
<div className="inline-flex p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
<button
onClick={() => setActiveTab('regular')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'regular'
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
({groupedResults.regular.length})
</button>
<button
onClick={() => setActiveTab('adult')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'adult'
? 'bg-white dark:bg-gray-700 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
({groupedResults.adult.length})
</button>
</div>
</div>
{activeTab === 'adult' && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<p className="text-sm text-red-600 dark:text-red-400 text-center">
18
</p>
</div>
)}
</div>
)}
<div
key={`search-results-${viewMode}`}
key={`search-results-${viewMode}-${activeTab}`}
className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'
>
{viewMode === 'agg'
? aggregatedResults.map(([mapKey, group]) => {
return (
<div key={`agg-${mapKey}`} className='w-full'>
<VideoCard
from='search'
items={group}
query={
searchQuery.trim() !== group[0].title
? searchQuery.trim()
: ''
}
/>
</div>
);
})
: searchResults.map((item) => (
<div
key={`all-${item.source}-${item.id}`}
className='w-full'
>
{(() => {
// 确定要显示的结果
let displayResults = searchResults;
if (groupedResults && groupedResults.adult.length > 0) {
displayResults = activeTab === 'adult'
? groupedResults.adult
: groupedResults.regular;
}
// 聚合显示模式
if (viewMode === 'agg') {
const aggregated = aggregateResults(displayResults);
return aggregated.map(([mapKey, group]: [string, SearchResult[]]) => (
<div key={`agg-${mapKey}`} className='w-full'>
<VideoCard
id={item.id}
title={item.title}
poster={item.poster}
episodes={item.episodes.length}
source={item.source}
source_name={item.source_name}
douban_id={item.douban_id?.toString()}
from='search'
items={group}
query={
searchQuery.trim() !== item.title
searchQuery.trim() !== group[0].title
? searchQuery.trim()
: ''
}
year={item.year}
from='search'
type={item.episodes.length > 1 ? 'tv' : 'movie'}
/>
</div>
))}
));
}
// 列表显示模式
return displayResults.map((item) => (
<div
key={`all-${item.source}-${item.id}`}
className='w-full'
>
<VideoCard
id={item.id}
title={item.title}
poster={item.poster}
episodes={item.episodes.length}
source={item.source}
source_name={item.source_name}
douban_id={item.douban_id?.toString()}
query={
searchQuery.trim() !== item.title
? searchQuery.trim()
: ''
}
year={item.year}
from='search'
type={item.episodes.length > 1 ? 'tv' : 'movie'}
/>
</div>
));
})()}
{searchResults.length === 0 && (
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'>
+108
View File
@@ -0,0 +1,108 @@
'use client';
import { ArrowLeft, Settings, User } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import AdultContentFilter from '@/components/AdultContentFilter';
export default function UserSettingsPage() {
const router = useRouter();
const [authInfo, setAuthInfo] = useState<{ userName: string } | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const auth = getAuthInfoFromBrowserCookie();
if (!auth || !auth.username) {
// 如果用户未登录,重定向到登录页面
router.push('/login');
return;
}
setAuthInfo({ userName: auth.username });
setIsLoading(false);
}, [router]);
const handleFilterUpdate = (_enabled: boolean) => {
// 可以在这里添加一些全局状态更新或通知逻辑
// console.log('成人内容过滤状态已更新:', enabled);
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (!authInfo) {
return null;
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-4xl mx-auto p-6">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center space-x-4">
<button
onClick={() => router.back()}
className="flex items-center justify-center w-10 h-10 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<ArrowLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
<Settings className="w-8 h-8 mr-3 text-blue-600" />
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
</p>
</div>
</div>
<div className="flex items-center space-x-3 px-4 py-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<User className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-900 dark:text-white">
{authInfo.userName}
</span>
</div>
</div>
{/* 设置区域 */}
<div className="space-y-6">
{/* 内容过滤设置 */}
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
</h2>
<AdultContentFilter
userName={authInfo.userName}
onUpdate={handleFilterUpdate}
/>
</div>
{/* 其他设置部分预留 */}
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
...
</p>
</div>
</div>
</div>
{/* 底部信息 */}
<div className="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
<p></p>
</div>
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
'use client';
import { Shield, ShieldOff } from 'lucide-react';
import { useEffect, useState } from 'react';
interface AdultContentFilterProps {
userName: string;
onUpdate?: (enabled: boolean) => void;
}
const AdultContentFilter: React.FC<AdultContentFilterProps> = ({
userName,
onUpdate
}) => {
const [isEnabled, setIsEnabled] = useState(true); // 默认开启过滤
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 获取用户设置
useEffect(() => {
const fetchUserSettings = async () => {
if (!userName) return;
try {
const response = await fetch('/api/user/settings', {
headers: {
'Authorization': `Bearer ${userName}`,
},
});
if (response.ok) {
const data = await response.json();
setIsEnabled(data.settings.filter_adult_content);
} else {
setError('获取用户设置失败');
}
} catch (err) {
setError('网络连接失败');
// eslint-disable-next-line no-console
console.error('Failed to fetch user settings:', err);
}
};
fetchUserSettings();
}, [userName]);
// 更新用户设置
const handleToggle = async () => {
if (!userName || isLoading) return;
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/user/settings', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userName}`,
},
body: JSON.stringify({
settings: {
filter_adult_content: !isEnabled,
},
}),
});
if (response.ok) {
const newState = !isEnabled;
setIsEnabled(newState);
// 强制刷新用户设置缓存 - 向搜索API发送一个空请求来刷新设置
try {
await fetch('/api/search?q=_cache_refresh_', {
headers: {
'Authorization': `Bearer ${userName}`,
},
});
} catch {
// 忽略刷新缓存的错误
}
onUpdate?.(newState);
} else {
const errorData = await response.json();
setError(errorData.error || '更新设置失败');
}
} catch (err) {
setError('网络连接失败');
// eslint-disable-next-line no-console
console.error('Failed to update user settings:', err);
} finally {
setIsLoading(false);
}
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900">
{isEnabled ? (
<Shield className="w-5 h-5 text-blue-600 dark:text-blue-400" />
) : (
<ShieldOff className="w-5 h-5 text-gray-600 dark:text-gray-400" />
)}
</div>
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{isEnabled
? '已开启过滤,将自动隐藏所有标记为"成人"的资源站及其内容'
: '已关闭过滤,成人内容将在搜索结果中单独分组显示'
}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<button
onClick={handleToggle}
disabled={isLoading || !userName}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed
${isEnabled
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-700'
}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
${isEnabled ? 'translate-x-6' : 'translate-x-1'}
`}
/>
</button>
{isLoading && (
<div className="w-5 h-5">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
</div>
)}
</div>
</div>
{error && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="mt-4 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-md">
<div className="flex items-start">
<div className="flex-shrink-0">
<Shield className="w-5 h-5 text-amber-600 dark:text-amber-400" />
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-amber-800 dark:text-amber-200">
</h4>
<p className="mt-1 text-sm text-amber-700 dark:text-amber-300">
使访18
</p>
</div>
</div>
</div>
</div>
);
};
export default AdultContentFilter;
+5 -5
View File
@@ -234,7 +234,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
return (
<div className='md:ml-2 px-4 py-0 h-full rounded-xl bg-black/10 dark:bg-white/5 flex flex-col border border-white/0 dark:border-white/30 overflow-hidden'>
{/* 主要的 Tab 切换 - 无缝融入设计 */}
<div className='flex mb-1 -mx-6 flex-shrink-0'>
<div className='flex mb-0 -mx-6 flex-shrink-0'>
{totalEpisodes > 1 && (
<div
onClick={() => setActiveTab('episodes')}
@@ -267,7 +267,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
{activeTab === 'episodes' && (
<>
{/* 分类标签 */}
<div className='flex items-center gap-4 mb-4 border-b border-gray-300 dark:border-gray-700 -mx-6 px-6 flex-shrink-0'>
<div className='flex items-center gap-4 mb-2 border-b border-gray-300 dark:border-gray-700 -mx-6 px-6 flex-shrink-0'>
<div className='flex-1 overflow-x-auto' ref={categoryContainerRef}>
<div className='flex gap-2 min-w-max'>
{categories.map((label, idx) => {
@@ -320,8 +320,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
</button>
</div>
{/* 集数网格 */}
<div className='grid grid-cols-[repeat(auto-fill,minmax(48px,1fr))] justify-center gap-2 overflow-y-auto pb-4'>
{/* 集数网格 - 优化为10行×5列布局 */}
<div className='grid grid-cols-5 gap-3 pb-6 px-2'>
{(() => {
const len = currentEnd - currentStart + 1;
const episodes = Array.from({ length: len }, (_, i) =>
@@ -338,7 +338,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
e.stopPropagation();
handleEpisodeClick(episodeNumber);
}}
className={`w-full h-10 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200 cursor-pointer
className={`w-full h-10 flex items-center justify-center text-sm font-medium rounded-lg transition-all duration-200 cursor-pointer
${
isActive
? 'bg-green-500 text-white shadow-lg shadow-green-500/25 dark:bg-green-600'
+288
View File
@@ -0,0 +1,288 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any */
'use client';
import { useEffect, useState } from 'react';
// 临时内联认证函数,避免导入问题
function getAuthInfoFromBrowserCookie(): {
password?: string;
username?: string;
signature?: string;
timestamp?: number;
} | null {
if (typeof window === 'undefined') {
return null;
}
const cookies = document.cookie.split(';');
const authCookie = cookies.find(cookie => cookie.trim().startsWith('auth='));
if (!authCookie) {
return null;
}
try {
const cookieValue = authCookie.split('=')[1];
const decoded = decodeURIComponent(cookieValue);
const authData = JSON.parse(decoded);
return authData;
} catch (error) {
console.error('Failed to parse auth cookie:', error);
return null;
}
}
interface UserInfo {
username: string;
role: string;
created_at: string;
filter_adult_content: boolean;
can_disable_filter: boolean;
managed_by_admin: boolean;
last_filter_change?: string;
}
export default function UserManagement() {
const [users, setUsers] = useState<UserInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentUser, setCurrentUser] = useState<string | null>(null);
useEffect(() => {
// 获取当前用户信息
const authInfo = getAuthInfoFromBrowserCookie();
if (authInfo?.username) {
setCurrentUser(authInfo.username);
loadUsers();
} else {
setError('未登录或权限不足');
setLoading(false);
}
}, []);
const loadUsers = async () => {
try {
const authInfo = getAuthInfoFromBrowserCookie();
if (!authInfo?.username) {
throw new Error('未获取到用户认证信息');
}
const response = await fetch('/api/admin/users', {
headers: {
'Authorization': `Bearer ${encodeURIComponent(authInfo.username)}`,
'Cache-Control': 'no-cache'
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '获取用户列表失败');
}
const data = await response.json();
setUsers(data.users || []);
} catch (err) {
setError(err instanceof Error ? err.message : '未知错误');
console.error('加载用户列表失败:', err);
} finally {
setLoading(false);
}
};
const updateUserSettings = async (username: string, action: string, settings?: any) => {
try {
const authInfo = getAuthInfoFromBrowserCookie();
if (!authInfo?.username) {
throw new Error('未获取到用户认证信息');
}
const response = await fetch('/api/admin/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${encodeURIComponent(authInfo.username)}`
},
body: JSON.stringify({
action,
username,
settings
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '操作失败');
}
const data = await response.json();
alert(data.message || '操作成功');
// 重新加载用户列表
await loadUsers();
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '未知错误';
alert(`操作失败: ${errorMsg}`);
console.error('用户管理操作失败:', err);
}
};
const handleForceFilter = (username: string) => {
if (confirm(`确定要强制开启用户 ${username} 的成人内容过滤功能吗?`)) {
updateUserSettings(username, 'force_filter');
}
};
const handleAllowDisable = (username: string) => {
if (confirm(`确定要允许用户 ${username} 自己管理过滤设置吗?`)) {
updateUserSettings(username, 'allow_disable');
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-40">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-500"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-4">
<div className="text-red-600 dark:text-red-400">{error}</div>
<button
onClick={loadUsers}
className="mt-2 text-sm text-red-600 dark:text-red-400 underline hover:no-underline"
>
</button>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-200">
</h2>
<div className="text-sm text-gray-600 dark:text-gray-400">
{users.length}
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{users.map((user) => (
<tr key={user.username} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{user.username}
</div>
{user.username === currentUser && (
<span className="ml-2 px-2 py-1 text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded-full">
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.role === 'owner'
? 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300'
}`}>
{user.role === 'owner' ? '站长' : '用户'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.filter_adult_content
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
}`}>
{user.filter_adult_content ? '已开启' : '已关闭'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{user.managed_by_admin ? (
<span className="text-orange-600 dark:text-orange-400"></span>
) : user.can_disable_filter ? (
<span className="text-green-600 dark:text-green-400"></span>
) : (
<span className="text-gray-600 dark:text-gray-400"></span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{user.role !== 'owner' && user.username !== currentUser && (
<div className="flex space-x-2">
{!user.filter_adult_content || !user.managed_by_admin ? (
<button
onClick={() => handleForceFilter(user.username)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
>
</button>
) : null}
{user.managed_by_admin || !user.can_disable_filter ? (
<button
onClick={() => handleAllowDisable(user.username)}
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
>
</button>
) : null}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{users.length === 0 && (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
</div>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-4">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
</h3>
<ul className="text-sm text-blue-600 dark:text-blue-300 space-y-1">
<li> <strong></strong></li>
<li> <strong></strong></li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
);
}
+18 -3
View File
@@ -2,7 +2,7 @@
'use client';
import { KeyRound, LogOut, Settings, Shield, User, X } from 'lucide-react';
import { Filter, KeyRound, LogOut, Settings, Shield, User, X } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -208,12 +208,18 @@ export const UserMenu: React.FC = () => {
}
};
// 处理设置点击
const handleSettings = () => {
setIsOpen(false);
setIsSettingsOpen(true);
};
const handleCloseSettings = () => {
// 处理内容过滤设置
const handleContentFilter = () => {
setIsOpen(false);
// 跳转到内容过滤设置页面
router.push('/settings');
}; const handleCloseSettings = () => {
setIsSettingsOpen(false);
};
@@ -360,7 +366,16 @@ export const UserMenu: React.FC = () => {
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'
>
<Settings className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'></span>
<span className='font-medium'></span>
</button>
{/* 内容过滤按钮 */}
<button
onClick={handleContentFilter}
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'
>
<Filter className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'></span>
</button>
{/* 管理面板按钮 */}
+1
View File
@@ -22,6 +22,7 @@ export interface AdminConfig {
detail?: string;
from: 'config' | 'custom';
disabled?: boolean;
is_adult?: boolean; // 新增:是否为成人内容资源站
}[];
}
+124 -12
View File
@@ -1,8 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion */
import { getStorage } from '@/lib/db';
import { AdminConfig } from './admin.types';
import { getStorage } from './db';
import runtimeConfig from './runtime';
export interface ApiSite {
@@ -101,6 +100,7 @@ async function initConfig() {
detail: site.detail,
from: 'config',
disabled: false,
is_adult: (site as any).is_adult || false, // 确保 is_adult 字段被正确处理
});
}
});
@@ -110,6 +110,12 @@ async function initConfig() {
adminConfig.SourceConfig.forEach((source) => {
if (!apiSiteKeys.has(source.key)) {
source.from = 'custom';
} else {
// 更新现有源的 is_adult 字段
const siteConfig = fileConfig.api_site[source.key];
if (siteConfig) {
source.is_adult = (siteConfig as any).is_adult || false;
}
}
});
@@ -172,6 +178,7 @@ async function initConfig() {
detail: site.detail,
from: 'config',
disabled: false,
is_adult: (site as any).is_adult || false, // 确保 is_adult 字段被正确处理
})),
};
}
@@ -218,19 +225,24 @@ async function initConfig() {
export async function getConfig(): Promise<AdminConfig> {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (process.env.DOCKER_ENV === 'true' || storageType === 'localstorage') {
await initConfig();
return cachedConfig;
}
// 非 docker 环境且 DB 存储,直接读 db 配置
const storage = getStorage();
let adminConfig: AdminConfig | null = null;
if (storage && typeof (storage as any).getAdminConfig === 'function') {
adminConfig = await (storage as any).getAdminConfig();
}
if (adminConfig) {
// 合并一些环境变量配置
adminConfig.SiteConfig.SiteName = process.env.SITE_NAME || 'KatelyaTV';
try {
const storage = getStorage();
let adminConfig: AdminConfig | null = null;
if (storage && typeof (storage as any).getAdminConfig === 'function') {
adminConfig = await (storage as any).getAdminConfig();
}
if (adminConfig) {
// 合并一些环境变量配置
adminConfig.SiteConfig.SiteName = process.env.SITE_NAME || 'KatelyaTV';
adminConfig.SiteConfig.Announcement =
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
@@ -254,6 +266,7 @@ export async function getConfig(): Promise<AdminConfig> {
detail: site.detail,
from: 'config',
disabled: false,
is_adult: (site as any).is_adult || false, // 确保处理 is_adult 字段
});
}
});
@@ -263,6 +276,12 @@ export async function getConfig(): Promise<AdminConfig> {
adminConfig.SourceConfig.forEach((source) => {
if (!apiSiteKeys.has(source.key)) {
source.from = 'custom';
} else {
// 更新现有源的 is_adult 字段
const siteConfig = fileConfig.api_site[source.key];
if (siteConfig) {
source.is_adult = (siteConfig as any).is_adult || false;
}
}
});
@@ -292,6 +311,11 @@ export async function getConfig(): Promise<AdminConfig> {
await initConfig();
}
return cachedConfig;
} catch (error) {
// 如果数据库访问失败,回退到默认配置
await initConfig();
return cachedConfig;
}
}
export async function resetConfig() {
@@ -378,9 +402,97 @@ export async function getCacheTime(): Promise<number> {
return config.SiteConfig.SiteInterfaceCacheTime || 7200;
}
export async function getAvailableApiSites(): Promise<ApiSite[]> {
export async function getAvailableApiSites(filterAdult = false): Promise<ApiSite[]> {
const config = await getConfig();
return config.SourceConfig.filter((s) => !s.disabled).map((s) => ({
// 防御性检查:确保 SourceConfig 存在且为数组
if (!config.SourceConfig || !Array.isArray(config.SourceConfig)) {
console.warn('SourceConfig is missing or not an array, returning empty array');
return [];
}
// 防御性处理:为每个源确保 is_adult 字段存在
let sites = config.SourceConfig
.filter((s) => !s.disabled)
.map((s) => ({
...s,
is_adult: s.is_adult === true // 严格检查,只有明确为 true 的才是成人内容
}));
// 如果需要过滤成人内容,则排除标记为成人内容的资源站
if (filterAdult) {
sites = sites.filter((s) => !s.is_adult);
}
return sites.map((s) => ({
key: s.key,
name: s.name,
api: s.api,
detail: s.detail,
}));
}
// 根据用户设置动态获取可用资源站(你的想法实现)
export async function getFilteredApiSites(userName?: string): Promise<ApiSite[]> {
const config = await getConfig();
// 防御性检查:确保 SourceConfig 存在且为数组
if (!config.SourceConfig || !Array.isArray(config.SourceConfig)) {
console.warn('SourceConfig is missing or not an array, returning empty array');
return [];
}
// 默认过滤成人内容
let shouldFilterAdult = true;
// 如果提供了用户名,获取用户设置
if (userName) {
try {
const storage = getStorage();
const userSettings = await storage.getUserSettings(userName);
shouldFilterAdult = userSettings?.filter_adult_content !== false; // 默认为 true
} catch (error) {
// 获取用户设置失败时,默认过滤成人内容
console.warn('Failed to get user settings, using default filter:', error);
}
}
// 防御性处理:为每个源确保 is_adult 字段存在
let sites = config.SourceConfig
.filter((s) => !s.disabled)
.map((s) => ({
...s,
is_adult: s.is_adult === true // 严格检查,只有明确为 true 的才是成人内容
}));
// 根据用户设置动态过滤成人内容源
if (shouldFilterAdult) {
sites = sites.filter((s) => !s.is_adult);
}
return sites.map((s) => ({
key: s.key,
name: s.name,
api: s.api,
detail: s.detail,
}));
}
// 获取成人内容资源站
export async function getAdultApiSites(): Promise<ApiSite[]> {
const config = await getConfig();
// 防御性检查:确保 SourceConfig 存在且为数组
if (!config.SourceConfig || !Array.isArray(config.SourceConfig)) {
console.warn('SourceConfig is missing or not an array, returning empty array');
return [];
}
// 防御性处理:严格检查成人内容标记
const adultSites = config.SourceConfig
.filter((s) => !s.disabled && s.is_adult === true); // 只有明确为 true 的才被认为是成人内容
return adultSites.map((s) => ({
key: s.key,
name: s.name,
api: s.api,
+89 -9
View File
@@ -1,7 +1,7 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import { AdminConfig } from './admin.types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, User, UserSettings } from './types';
// 搜索历史最大条数
const SEARCH_HISTORY_LIMIT = 20;
@@ -39,7 +39,21 @@ interface D1ExecResult {
// 获取全局D1数据库实例
function getD1Database(): D1Database {
return (process.env as any).DB as D1Database;
// 在 Cloudflare Pages 环境中,DB 通过全局绑定可用
if (typeof globalThis !== 'undefined') {
// 尝试直接访问全局 DB
const globalDB = (globalThis as any).DB;
if (globalDB) {
return globalDB as D1Database;
}
}
// 回退到 process.env(用于本地开发)
if (process.env.DB) {
return (process.env as any).DB as D1Database;
}
throw new Error('D1 database not available');
}
export class D1Storage implements IStorage {
@@ -428,14 +442,20 @@ export class D1Storage implements IStorage {
}
// 用户列表
async getAllUsers(): Promise<string[]> {
async getAllUsers(): Promise<User[]> {
try {
const db = await this.getDatabase();
const result = await db
.prepare('SELECT username FROM users ORDER BY created_at ASC')
.all<{ username: string }>();
.prepare('SELECT username, created_at FROM users ORDER BY created_at ASC')
.all<{ username: string; created_at: string }>();
return result.results.map((row) => row.username);
const ownerUsername = process.env.USERNAME || 'admin';
return result.results.map((row) => ({
username: row.username,
role: row.username === ownerUsername ? 'owner' : 'user',
created_at: row.created_at
}));
} catch (err) {
console.error('Failed to get all users:', err);
throw err;
@@ -447,7 +467,8 @@ export class D1Storage implements IStorage {
try {
const db = await this.getDatabase();
const result = await db
.prepare('SELECT config FROM admin_config WHERE id = 1')
.prepare('SELECT config_value as config FROM admin_configs WHERE config_key = ? LIMIT 1')
.bind('main_config')
.first<{ config: string }>();
if (!result) return null;
@@ -464,9 +485,9 @@ export class D1Storage implements IStorage {
const db = await this.getDatabase();
await db
.prepare(
'INSERT OR REPLACE INTO admin_config (id, config) VALUES (1, ?)'
'INSERT OR REPLACE INTO admin_configs (config_key, config_value, description) VALUES (?, ?, ?)'
)
.bind(JSON.stringify(config))
.bind('main_config', JSON.stringify(config), '主要管理员配置')
.run();
} catch (err) {
console.error('Failed to set admin config:', err);
@@ -573,4 +594,63 @@ export class D1Storage implements IStorage {
throw err;
}
}
// ---------- 用户设置 ----------
async getUserSettings(userName: string): Promise<UserSettings | null> {
try {
const db = await this.getDatabase();
const row = await db
.prepare('SELECT settings FROM user_settings WHERE username = ?')
.bind(userName)
.first();
if (row && row.settings) {
return JSON.parse(row.settings as string) as UserSettings;
}
return null;
} catch (err) {
console.error('Failed to get user settings:', err);
throw err;
}
}
async setUserSettings(
userName: string,
settings: UserSettings
): Promise<void> {
try {
const db = await this.getDatabase();
await db
.prepare(`
INSERT OR REPLACE INTO user_settings (username, settings, updated_time)
VALUES (?, ?, ?)
`)
.bind(userName, JSON.stringify(settings), Date.now())
.run();
} catch (err) {
console.error('Failed to set user settings:', err);
throw err;
}
}
async updateUserSettings(
userName: string,
settings: Partial<UserSettings>
): Promise<void> {
const current = await this.getUserSettings(userName);
const defaultSettings: UserSettings = {
filter_adult_content: true,
theme: 'auto',
language: 'zh-CN',
auto_play: false,
video_quality: 'auto'
};
const updated: UserSettings = {
...defaultSettings,
...current,
...settings,
filter_adult_content: settings.filter_adult_content ?? current?.filter_adult_content ?? true
};
await this.setUserSettings(userName, updated);
}
}
+28 -13
View File
@@ -20,19 +20,34 @@ const STORAGE_TYPE =
// 创建存储实例
function createStorage(): IStorage {
switch (STORAGE_TYPE) {
case 'redis':
return new RedisStorage();
case 'kvrocks':
return new KvrocksStorage();
case 'upstash':
return new UpstashRedisStorage();
case 'd1':
return new D1Storage();
case 'localstorage':
default:
// 使用 LocalStorage 实现,适用于本地开发和简单部署
return new LocalStorage();
const storageType = STORAGE_TYPE;
try {
switch (storageType) {
case 'redis':
return new RedisStorage();
case 'kvrocks':
return new KvrocksStorage();
case 'upstash':
return new UpstashRedisStorage();
case 'd1':
// 对于 d1,先检查是否可用
if (typeof globalThis !== 'undefined' && (globalThis as any).DB) {
return new D1Storage();
} else if (process.env.DB) {
return new D1Storage();
} else {
// D1 不可用,回退到 LocalStorage
return new LocalStorage();
}
case 'localstorage':
default:
// 使用 LocalStorage 实现,适用于本地开发和简单部署
return new LocalStorage();
}
} catch (error) {
// 创建存储失败,回退到 LocalStorage
return new LocalStorage();
}
}
+68 -4
View File
@@ -3,7 +3,7 @@
import { createClient, RedisClientType } from 'redis';
import { AdminConfig } from './admin.types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, User, UserSettings } from './types';
// 搜索历史最大条数
const SEARCH_HISTORY_LIMIT = 20;
@@ -266,9 +266,31 @@ export class KvrocksStorage implements IStorage {
});
}
async getAllUsers(): Promise<string[]> {
const users = await withRetry(() => this.client.sMembers(this.userListKey()));
return ensureStringArray(users);
async getAllUsers(): Promise<User[]> {
const usernames = await withRetry(() => this.client.sMembers(this.userListKey()));
const ownerUsername = process.env.USERNAME || 'admin';
const users = await Promise.all(
usernames.map(async (username) => {
let created_at = '';
try {
const userData = await this.getUser(username);
if (userData?.created_at) {
created_at = new Date(userData.created_at).toISOString();
}
} catch (err) {
// 忽略错误,使用空字符串
}
return {
username,
role: username === ownerUsername ? 'owner' : 'user',
created_at
};
})
);
return users;
}
async registerUser(userName: string, password: string): Promise<void> {
@@ -337,6 +359,48 @@ export class KvrocksStorage implements IStorage {
this.client.set(this.adminConfigKey(), JSON.stringify(config))
);
}
// ---------- 用户设置 ----------
private userSettingsKey(userName: string) {
return `u:${userName}:settings`;
}
async getUserSettings(userName: string): Promise<UserSettings | null> {
const val = await withRetry(() =>
this.client.get(this.userSettingsKey(userName))
);
return val ? (JSON.parse(val) as UserSettings) : null;
}
async setUserSettings(
userName: string,
settings: UserSettings
): Promise<void> {
await withRetry(() =>
this.client.set(this.userSettingsKey(userName), JSON.stringify(settings))
);
}
async updateUserSettings(
userName: string,
settings: Partial<UserSettings>
): Promise<void> {
const current = await this.getUserSettings(userName);
const defaultSettings: UserSettings = {
filter_adult_content: true,
theme: 'auto',
language: 'zh-CN',
auto_play: false,
video_quality: 'auto'
};
const updated: UserSettings = {
...defaultSettings,
...current,
...settings,
filter_adult_content: settings.filter_adult_content ?? current?.filter_adult_content ?? true
};
await this.setUserSettings(userName, updated);
}
}
// Kvrocks客户端单例
+76 -6
View File
@@ -1,6 +1,6 @@
/* eslint-disable no-console */
import { AdminConfig } from './admin.types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, User, UserSettings } from './types';
/**
* LocalStorage 存储实现
@@ -290,19 +290,89 @@ export class LocalStorage implements IStorage {
}
}
// ---------- 用户设置 ----------
async getUserSettings(userName: string): Promise<UserSettings | null> {
if (typeof window === 'undefined') return null;
try {
const storageKey = this.getStorageKey('settings', userName);
const data = localStorage.getItem(storageKey);
if (data) {
return JSON.parse(data);
}
// 如果用户设置不存在,返回默认设置
const defaultSettings: UserSettings = {
filter_adult_content: true, // 默认开启成人内容过滤
theme: 'auto',
language: 'zh-CN',
auto_play: true,
video_quality: 'auto'
};
return defaultSettings;
} catch (error) {
console.error('Error getting user settings:', error);
return null;
}
}
async setUserSettings(userName: string, settings: UserSettings): Promise<void> {
if (typeof window === 'undefined') return;
try {
const storageKey = this.getStorageKey('settings', userName);
localStorage.setItem(storageKey, JSON.stringify(settings));
} catch (error) {
console.error('Error setting user settings:', error);
}
}
async updateUserSettings(userName: string, settings: Partial<UserSettings>): Promise<void> {
if (typeof window === 'undefined') return;
try {
const currentSettings = await this.getUserSettings(userName);
const updatedSettings = { ...currentSettings, ...settings };
await this.setUserSettings(userName, updatedSettings as UserSettings);
} catch (error) {
console.error('Error updating user settings:', error);
}
}
// ---------- 管理员功能 ----------
async getAllUsers(): Promise<string[]> {
async getAllUsers(): Promise<User[]> {
if (typeof window === 'undefined') return [];
try {
const users: string[] = [];
const users: User[] = [];
const prefix = 'katelyatv_user_';
const ownerUsername = process.env.USERNAME || 'admin';
for (let i = 0; i < localStorage.length; i++) {
const storageKey = localStorage.key(i);
if (storageKey && storageKey.startsWith(prefix)) {
const userName = storageKey.replace(prefix, '');
users.push(userName);
const username = storageKey.replace(prefix, '');
// 尝试获取用户创建时间
let created_at = '';
try {
const userDataStr = localStorage.getItem(storageKey);
if (userDataStr) {
const userData = JSON.parse(userDataStr);
if (userData.created_at) {
created_at = new Date(userData.created_at).toISOString();
}
}
} catch (err) {
// 忽略解析错误
}
users.push({
username,
role: username === ownerUsername ? 'owner' : 'user',
created_at
});
}
}
@@ -365,7 +435,7 @@ export class LocalStorage implements IStorage {
localStorage.removeItem(userKey);
// 删除用户相关的所有数据
const prefixes = ['playrecord', 'favorite', 'searchhistory', 'skipconfig'];
const prefixes = ['playrecord', 'favorite', 'searchhistory', 'skipconfig', 'settings'];
for (const prefix of prefixes) {
const dataPrefix = this.getStorageKey(prefix, userName);
+74 -3
View File
@@ -3,7 +3,7 @@
import { createClient, RedisClientType } from 'redis';
import { AdminConfig } from './admin.types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, User, UserSettings } from './types';
// 搜索历史最大条数
const SEARCH_HISTORY_LIMIT = 20;
@@ -223,6 +223,50 @@ export class RedisStorage implements IStorage {
if (favoriteKeys.length > 0) {
await withRetry(() => this.client.del(favoriteKeys));
}
// 删除用户设置
await withRetry(() => this.client.del(this.userSettingsKey(userName)));
}
// ---------- 用户设置 ----------
private userSettingsKey(user: string) {
return `u:${user}:settings`; // u:username:settings
}
async getUserSettings(userName: string): Promise<UserSettings | null> {
const data = await withRetry(() =>
this.client.get(this.userSettingsKey(userName))
);
if (data) {
return JSON.parse(ensureString(data));
}
// 如果用户设置不存在,返回默认设置
const defaultSettings: UserSettings = {
filter_adult_content: true, // 默认开启成人内容过滤
theme: 'auto',
language: 'zh-CN',
auto_play: true,
video_quality: 'auto'
};
return defaultSettings;
}
async setUserSettings(userName: string, settings: UserSettings): Promise<void> {
await withRetry(() =>
this.client.set(
this.userSettingsKey(userName),
JSON.stringify(settings)
)
);
}
async updateUserSettings(userName: string, settings: Partial<UserSettings>): Promise<void> {
const currentSettings = await this.getUserSettings(userName);
const updatedSettings = { ...currentSettings, ...settings };
await this.setUserSettings(userName, updatedSettings as UserSettings);
}
// ---------- 搜索历史 ----------
@@ -258,14 +302,41 @@ export class RedisStorage implements IStorage {
}
// ---------- 获取全部用户 ----------
async getAllUsers(): Promise<string[]> {
async getAllUsers(): Promise<User[]> {
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
return keys
const ownerUsername = process.env.USERNAME || 'admin';
const usernames = keys
.map((k) => {
const match = k.match(/^u:(.+?):pwd$/);
return match ? ensureString(match[1]) : undefined;
})
.filter((u): u is string => typeof u === 'string');
// 获取用户创建时间并构造 User 对象
const users = await Promise.all(
usernames.map(async (username) => {
// 尝试获取用户创建时间,如果没有则使用空字符串
const createdAtKey = `u:${username}:created_at`;
let created_at = '';
try {
const timestamp = await withRetry(() => this.client.get(createdAtKey));
if (timestamp) {
created_at = new Date(parseInt(timestamp)).toISOString();
}
} catch (err) {
// 忽略错误,使用空字符串
}
return {
username,
role: username === ownerUsername ? 'owner' : 'user',
created_at
};
})
);
return users;
}
// ---------- 管理员配置 ----------
+45 -1
View File
@@ -42,6 +42,13 @@ export interface Favorite {
search_title: string; // 搜索时使用的标题
}
// 用户数据结构
export interface User {
username: string;
role?: string;
created_at?: string;
}
// 存储接口
export interface IStorage {
// 播放记录相关
@@ -70,6 +77,11 @@ export interface IStorage {
// 删除用户(包括密码、搜索历史、播放记录、收藏夹)
deleteUser(userName: string): Promise<void>;
// 用户设置相关
getUserSettings(userName: string): Promise<UserSettings | null>;
setUserSettings(userName: string, settings: UserSettings): Promise<void>;
updateUserSettings(userName: string, settings: Partial<UserSettings>): Promise<void>;
// 搜索历史相关
getSearchHistory(userName: string): Promise<string[]>;
addSearchHistory(userName: string, keyword: string): Promise<void>;
@@ -82,7 +94,7 @@ export interface IStorage {
deleteSkipConfig(userName: string, key: string): Promise<void>;
// 用户列表
getAllUsers(): Promise<string[]>;
getAllUsers(): Promise<User[]>;
// 管理员配置相关
getAdminConfig(): Promise<AdminConfig | null>;
@@ -119,6 +131,38 @@ export interface DoubanResult {
list: DoubanItem[];
}
// 资源站配置
export interface ApiSite {
api: string;
name: string;
detail?: string;
type?: number;
playMode?: 'parse' | 'direct';
is_adult?: boolean; // 新增:是否为成人内容资源站
}
// 配置文件结构
export interface Config {
cache_time: number;
api_site: { [key: string]: ApiSite };
}
// 用户设置
export interface UserSettings {
filter_adult_content: boolean; // 是否过滤成人内容,默认为 true
theme: 'light' | 'dark' | 'auto';
language: string;
auto_play: boolean;
video_quality: string;
[key: string]: string | boolean | number; // 允许其他设置
}
// 搜索结果(支持成人内容分组)
export interface GroupedSearchResults {
regular_results: SearchResult[];
adult_results?: SearchResult[];
}
// Runtime配置类型
export interface RuntimeConfig {
STORAGE_TYPE?: string;
+72 -3
View File
@@ -3,7 +3,7 @@
import { Redis } from '@upstash/redis';
import { AdminConfig } from './admin.types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, User, UserSettings } from './types';
// 搜索历史最大条数
const SEARCH_HISTORY_LIMIT = 20;
@@ -244,14 +244,41 @@ export class UpstashRedisStorage implements IStorage {
}
// ---------- 获取全部用户 ----------
async getAllUsers(): Promise<string[]> {
async getAllUsers(): Promise<User[]> {
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
return keys
const ownerUsername = process.env.USERNAME || 'admin';
const usernames = keys
.map((k) => {
const match = k.match(/^u:(.+?):pwd$/);
return match ? ensureString(match[1]) : undefined;
})
.filter((u): u is string => typeof u === 'string');
// 获取用户创建时间并构造 User 对象
const users = await Promise.all(
usernames.map(async (username) => {
// 尝试获取用户创建时间,如果没有则使用空字符串
const createdAtKey = `u:${username}:created_at`;
let created_at = '';
try {
const timestamp = await withRetry(() => this.client.get(createdAtKey));
if (timestamp && typeof timestamp === 'number') {
created_at = new Date(timestamp).toISOString();
}
} catch (err) {
// 忽略错误,使用空字符串
}
return {
username,
role: username === ownerUsername ? 'owner' : 'user',
created_at
};
})
);
return users;
}
// ---------- 管理员配置 ----------
@@ -329,6 +356,48 @@ export class UpstashRedisStorage implements IStorage {
await this.client.srem(this.skipConfigsKey(userName), key);
});
}
// ---------- 用户设置 ----------
private userSettingsKey(userName: string) {
return `u:${userName}:settings`;
}
async getUserSettings(userName: string): Promise<UserSettings | null> {
const val = await withRetry(() =>
this.client.get(this.userSettingsKey(userName))
);
return val ? (val as UserSettings) : null;
}
async setUserSettings(
userName: string,
settings: UserSettings
): Promise<void> {
await withRetry(() =>
this.client.set(this.userSettingsKey(userName), settings)
);
}
async updateUserSettings(
userName: string,
settings: Partial<UserSettings>
): Promise<void> {
const current = await this.getUserSettings(userName);
const defaultSettings: UserSettings = {
filter_adult_content: true,
theme: 'auto',
language: 'zh-CN',
auto_play: false,
video_quality: 'auto'
};
const updated: UserSettings = {
...defaultSettings,
...current,
...settings,
filter_adult_content: settings.filter_adult_content ?? current?.filter_adult_content ?? true
};
await this.setUserSettings(userName, updated);
}
}
// 单例 Upstash Redis 客户端
+1 -1
View File
@@ -2,7 +2,7 @@
'use client';
const CURRENT_VERSION = '20250904151930';
const CURRENT_VERSION = '20250904200125';
// 版本检查结果枚举
export enum UpdateStatus {
+3 -3
View File
@@ -14,7 +14,7 @@ export async function middleware(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (!process.env.PASSWORD) {
if (!process.env.AUTH_PASSWORD) {
// 如果没有设置密码,重定向到警告页面
const warningUrl = new URL('/warning', request.url);
return NextResponse.redirect(warningUrl);
@@ -29,7 +29,7 @@ export async function middleware(request: NextRequest) {
// localstorage模式:在middleware中完成验证
if (storageType === 'localstorage') {
if (!authInfo.password || authInfo.password !== process.env.PASSWORD) {
if (!authInfo.password || authInfo.password !== process.env.AUTH_PASSWORD) {
return handleAuthFailure(request, pathname);
}
return NextResponse.next();
@@ -46,7 +46,7 @@ export async function middleware(request: NextRequest) {
const isValidSignature = await verifySignature(
authInfo.username,
authInfo.signature,
process.env.PASSWORD || ''
process.env.AUTH_PASSWORD || ''
);
// 签名验证通过即可
+57 -42
View File
@@ -1,53 +1,68 @@
name = "katelyatv"
compatibility_date = "2024-09-01"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".vercel/output/static"
# 默认 D1 数据库配置(用于命令行操作)
[[d1_databases]]
binding = "DB"
database_name = "katelyatv-db"
database_id = "6d580637-1f87-4ddf-8b4d-3d97254b4c33"
# 生产环境配置
[env.production]
name = "katelyatv"
[env.production.vars]
# 存储类型配置
NEXT_PUBLIC_STORAGE_TYPE = "d1"
# 站点配置
NEXT_PUBLIC_SITE_NAME = "KatelyaTV"
NEXT_PUBLIC_SITE_DESCRIPTION = "高性能影视播放平台"
# NextAuth 配置
NEXTAUTH_URL = "https://your-domain.pages.dev"
# 图片代理配置
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"
# 生产环境标识
NODE_ENV = "production"
# 生产环境 D1 数据库配置
[[env.production.d1_databases]]
binding = "DB"
database_name = "katelyatv-db"
database_id = "your-d1-database-id-here"
database_id = "6d580637-1f87-4ddf-8b4d-3d97254b4c33"
[build]
command = "pnpm pages:build"
environment = { NODE_VERSION = "18" }
# 生产环境变量
[env.production.vars]
NEXT_PUBLIC_STORAGE_TYPE = "d1"
NEXT_PUBLIC_SITE_NAME = "KatelyaTV"
NEXT_PUBLIC_SITE_DESCRIPTION = "高性能影视播放平台"
NEXTAUTH_URL = "https://tv.katelya.eu.org"
USERNAME = "katelya"
AUTH_PASSWORD = "ab1433223cd@"
IMAGE_PROXY_ENABLED = "true"
CACHE_TTL = "3600"
CORS_ORIGIN = "*"
RATE_LIMIT_MAX = "100"
RATE_LIMIT_WINDOW = "60000"
HEALTH_CHECK_ENABLED = "true"
HEALTH_CHECK_INTERVAL = "30"
LOG_LEVEL = "info"
LOG_FORMAT = "json"
NODE_ENV = "production"
[[build.environment_variables]]
name = "NPM_FLAGS"
value = "--prefix=/opt/buildhome/.asdf/installs/nodejs/18.17.1/.npm"
# 预览环境配置
[env.preview]
name = "katelyatv-preview"
# 预览环境 D1 数据库配置
[[env.preview.d1_databases]]
binding = "DB"
database_name = "katelyatv-db"
database_id = "6d580637-1f87-4ddf-8b4d-3d97254b4c33"
# 预览环境变量
[env.preview.vars]
NEXT_PUBLIC_STORAGE_TYPE = "d1"
NEXT_PUBLIC_SITE_NAME = "KatelyaTV"
NEXT_PUBLIC_SITE_DESCRIPTION = "高性能影视播放平台"
NEXTAUTH_URL = "https://katelyatv.pages.dev"
USERNAME = "katelya"
AUTH_PASSWORD = "ab1433223cd@"
IMAGE_PROXY_ENABLED = "true"
CACHE_TTL = "3600"
CORS_ORIGIN = "*"
RATE_LIMIT_MAX = "100"
RATE_LIMIT_WINDOW = "60000"
HEALTH_CHECK_ENABLED = "true"
HEALTH_CHECK_INTERVAL = "30"
LOG_LEVEL = "info"
LOG_FORMAT = "json"
NODE_ENV = "production"