Compare commits
56 Commits
Selene
...
Selene_Bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4168228647 | ||
|
|
6477230d4e | ||
|
|
74dc91d59e | ||
|
|
9829e867b2 | ||
|
|
8be31af615 | ||
|
|
cfa50165a0 | ||
|
|
5288951f13 | ||
|
|
f527ec849f | ||
|
|
3feaac91d4 | ||
|
|
ca18aef7c4 | ||
|
|
cbc52adc0e | ||
|
|
aa2fd66420 | ||
|
|
882f254432 | ||
|
|
0237b77447 | ||
|
|
465821a6c9 | ||
|
|
c86f9cdca7 | ||
|
|
c4198e0954 | ||
|
|
1ad91eeebd | ||
|
|
2d0df4bb80 | ||
|
|
5b6d6fbdff | ||
|
|
38dab1a8c2 | ||
|
|
d6c0642ffc | ||
|
|
92526e2d24 | ||
|
|
32724f2d0e | ||
|
|
d778c988bd | ||
|
|
e754feeb68 | ||
|
|
f1d3f805ec | ||
|
|
ada7d12fa3 | ||
|
|
7c62e1612c | ||
|
|
6494c80159 | ||
|
|
1a7005a8f9 | ||
|
|
8a6866e0b5 | ||
|
|
943efe9269 | ||
|
|
74f0533727 | ||
|
|
58f3bf99dc | ||
|
|
935c2b23f3 | ||
|
|
82bb6ee537 | ||
|
|
88f99cd4ac | ||
|
|
554bc49a51 | ||
|
|
258c95f6a2 | ||
|
|
64bedb04b6 | ||
|
|
d8dbd10389 | ||
|
|
8b0d999619 | ||
|
|
f5c36330d9 | ||
|
|
69ad303045 | ||
|
|
1c763bee39 | ||
|
|
425c4eae67 | ||
|
|
9b42fb0c2e | ||
|
|
2a336ac15b | ||
|
|
292dd4701e | ||
|
|
f4a4768c16 | ||
|
|
73232fd172 | ||
|
|
f96f9ebbac | ||
|
|
24a411c104 | ||
|
|
b3764a7938 | ||
|
|
9b335da4ce |
80
.github/workflows/cloudflare-deploy.yml
vendored
80
.github/workflows/cloudflare-deploy.yml
vendored
@@ -67,9 +67,88 @@ jobs:
|
||||
echo "INIT_CONFIG=${{ secrets.INIT_CONFIG }}" >> $GITHUB_ENV
|
||||
echo "CONFIG_SUBSCRIPTION_URL=${{ secrets.CONFIG_SUBSCRIPTION_URL }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Replace D1 Database ID in wrangler.toml
|
||||
run: |
|
||||
if [ -n "${{ secrets.D1_DATABASE_ID }}" ]; then
|
||||
sed -i 's/REPLACE_WITH_YOUR_D1_DATABASE_ID/${{ secrets.D1_DATABASE_ID }}/g' wrangler.toml
|
||||
echo "D1 Database ID replaced successfully"
|
||||
else
|
||||
echo "D1_DATABASE_ID secret not set, skipping replacement"
|
||||
fi
|
||||
|
||||
- name: Update wrangler.toml with environment variables
|
||||
run: |
|
||||
# Function to append variable to wrangler.toml if it has a value
|
||||
append_var() {
|
||||
local var_name=$1
|
||||
local var_value=$2
|
||||
if [ -n "$var_value" ]; then
|
||||
echo "$var_name = \"$var_value\"" >> wrangler.toml
|
||||
fi
|
||||
}
|
||||
|
||||
# Append all environment variables to wrangler.toml
|
||||
append_var "NEXT_PUBLIC_STORAGE_TYPE" "${{ secrets.NEXT_PUBLIC_STORAGE_TYPE }}"
|
||||
append_var "NEXT_PUBLIC_SITE_NAME" "${{ secrets.NEXT_PUBLIC_SITE_NAME }}"
|
||||
append_var "NEXT_PUBLIC_SEARCH_MAX_PAGE" "${{ secrets.NEXT_PUBLIC_SEARCH_MAX_PAGE }}"
|
||||
append_var "NEXT_PUBLIC_DOUBAN_PROXY_TYPE" "${{ secrets.NEXT_PUBLIC_DOUBAN_PROXY_TYPE }}"
|
||||
append_var "NEXT_PUBLIC_DOUBAN_PROXY" "${{ secrets.NEXT_PUBLIC_DOUBAN_PROXY }}"
|
||||
append_var "NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE" "${{ secrets.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE }}"
|
||||
append_var "NEXT_PUBLIC_DOUBAN_IMAGE_PROXY" "${{ secrets.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY }}"
|
||||
append_var "NEXT_PUBLIC_DISABLE_YELLOW_FILTER" "${{ secrets.NEXT_PUBLIC_DISABLE_YELLOW_FILTER }}"
|
||||
append_var "NEXT_PUBLIC_FLUID_SEARCH" "${{ secrets.NEXT_PUBLIC_FLUID_SEARCH }}"
|
||||
append_var "NEXT_PUBLIC_PROXY_M3U8_TOKEN" "${{ secrets.NEXT_PUBLIC_PROXY_M3U8_TOKEN }}"
|
||||
append_var "NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES" "${{ secrets.NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES }}"
|
||||
append_var "NEXT_PUBLIC_VOICE_CHAT_STRATEGY" "${{ secrets.NEXT_PUBLIC_VOICE_CHAT_STRATEGY }}"
|
||||
append_var "NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD" "${{ secrets.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD }}"
|
||||
append_var "NEXT_PUBLIC_ENABLE_SOURCE_SEARCH" "${{ secrets.NEXT_PUBLIC_ENABLE_SOURCE_SEARCH }}"
|
||||
append_var "USERNAME" "${{ secrets.USERNAME }}"
|
||||
append_var "PASSWORD" "${{ secrets.PASSWORD }}"
|
||||
append_var "UPSTASH_URL" "${{ secrets.UPSTASH_URL }}"
|
||||
append_var "UPSTASH_TOKEN" "${{ secrets.UPSTASH_TOKEN }}"
|
||||
append_var "TMDB_API_KEY" "${{ secrets.TMDB_API_KEY }}"
|
||||
append_var "TMDB_PROXY" "${{ secrets.TMDB_PROXY }}"
|
||||
append_var "TMDB_REVERSE_PROXY" "${{ secrets.TMDB_REVERSE_PROXY }}"
|
||||
append_var "DANMAKU_API_BASE" "${{ secrets.DANMAKU_API_BASE }}"
|
||||
append_var "DANMAKU_API_TOKEN" "${{ secrets.DANMAKU_API_TOKEN }}"
|
||||
append_var "ANNOUNCEMENT" "${{ secrets.ANNOUNCEMENT }}"
|
||||
append_var "CRON_PASSWORD" "${{ secrets.CRON_PASSWORD }}"
|
||||
append_var "SITE_BASE" "${{ secrets.SITE_BASE }}"
|
||||
append_var "REDIS_URL" "${{ secrets.REDIS_URL }}"
|
||||
append_var "KVROCKS_URL" "${{ secrets.KVROCKS_URL }}"
|
||||
append_var "INIT_CONFIG" "${{ secrets.INIT_CONFIG }}"
|
||||
append_var "CONFIG_SUBSCRIPTION_URL" "${{ secrets.CONFIG_SUBSCRIPTION_URL }}"
|
||||
|
||||
echo "Updated wrangler.toml:"
|
||||
cat wrangler.toml
|
||||
|
||||
- name: Build for Cloudflare
|
||||
run: pnpm run build:cloudflare
|
||||
|
||||
- name: Install Wrangler
|
||||
run: pnpm add -g wrangler@4.60.0
|
||||
|
||||
- name: Run D1 Database Migrations
|
||||
run: |
|
||||
if [ -z "${{ secrets.D1_DATABASE_ID }}" ]; then
|
||||
echo "D1_DATABASE_ID not set, skipping migrations"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Running D1 database migrations..."
|
||||
|
||||
for migration_file in ./migrations/*.sql; do
|
||||
if [ -f "$migration_file" ]; then
|
||||
echo "Executing migration: $(basename $migration_file)"
|
||||
wrangler d1 execute moontvplus --remote --file="$migration_file" || echo "⚠️ Migration may have been skipped (already applied)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ Migrations completed"
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
- name: Deploy to Cloudflare
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
@@ -77,3 +156,4 @@ jobs:
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: deploy
|
||||
packageManager: pnpm
|
||||
wranglerVersion: "4.60.0"
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -57,4 +57,9 @@ public/workbox-*.js
|
||||
public/workbox-*.js.map
|
||||
|
||||
/.claude
|
||||
/.vscode
|
||||
/.vscode
|
||||
# SQLite 开发数据库
|
||||
.data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
51
CHANGELOG
51
CHANGELOG
@@ -1,8 +1,54 @@
|
||||
## [211.0.0] - 2026-02-01
|
||||
### Added
|
||||
- cloudflare部署增加d1支持
|
||||
- 电视直播支持flv播放
|
||||
- 登录注册页面输入框增加图标
|
||||
|
||||
### Changed
|
||||
- 豆瓣预告片改为前端获取
|
||||
- 豆瓣预告片可播放声音
|
||||
- 轮播图缓存采用乐观更新策略
|
||||
- 管理面板无权限时显示错误提示
|
||||
|
||||
## [210.2.0] - 2026-01-29
|
||||
### Added
|
||||
- 外部播放器增加app打开
|
||||
- 新增tvbox屏蔽源配置
|
||||
- 新增视频源权重功能
|
||||
|
||||
### Changed
|
||||
- 设备管理当前设备置顶且显示黄色边框
|
||||
- live分片代理移除content-length发送
|
||||
|
||||
### Fixed
|
||||
- 修复tmdbkey错误和未登录时获取外部观影室密钥无限重定向
|
||||
|
||||
## [210.1.3] - 2026-01-28
|
||||
### Fixed
|
||||
- 修复跳转登录不登出
|
||||
|
||||
## [210.1.2] - 2026-01-27
|
||||
### Fixed
|
||||
- 修复刷新token失效无限重定向
|
||||
|
||||
## [210.1.1] - 2026-01-27
|
||||
### Fixed
|
||||
- 修复token过期重定向无法续期的问题
|
||||
|
||||
## [210.1.0] - 2026-01-27
|
||||
### Added
|
||||
- 增加直链播放
|
||||
- 直播兼容txt格式
|
||||
|
||||
### Changed
|
||||
- 首页轮播图和继续观看可隐藏
|
||||
- 刷新token改为前端进行防止redis数据库方式报错
|
||||
|
||||
## [210.0.0] - 2026-01-25
|
||||
### Added
|
||||
- 新增网络直播功能
|
||||
- 动漫磁力搜索增加蜜柑
|
||||
- 弹幕磁力搜索增加动漫花园
|
||||
- 动漫磁力搜索增加动漫花园
|
||||
- pansou增加关键词过滤
|
||||
- cloudflare workers部署支持
|
||||
|
||||
@@ -705,4 +751,5 @@
|
||||
### Added
|
||||
|
||||
- 基于 Semantic Versioning 的版本号机制
|
||||
- 版本信息面板,展示本地变更日志和远程更新日志
|
||||
|
||||
- 版本信息面板,展示本地变更日志和远程更新日志
|
||||
|
||||
211
README.md
211
README.md
@@ -4,6 +4,7 @@
|
||||
<img src="public/logo.png" alt="MoonTVPlus Logo" width="120">
|
||||
</div>
|
||||
|
||||
|
||||
> 🎬 **MoonTVPlus** 是基于 [MoonTV v100](https://github.com/MoonTechLab/LunaTV) 二次开发的增强版影视聚合播放器。它在原版基础上新增了外部播放器支持、视频超分、弹幕系统、评论抓取等实用功能,提供更强大的观影体验。
|
||||
|
||||
<div align="center">
|
||||
@@ -50,6 +51,7 @@
|
||||
<img src="public/screenshot3.png" alt="项目截图" style="max-width:600px">
|
||||
</details>
|
||||
|
||||
|
||||
### 请不要在 B站、小红书、微信公众号、抖音、今日头条或其他中国大陆社交平台发布视频或文章宣传本项目,不授权任何“科技周刊/月刊”类项目或站点收录本项目。
|
||||
|
||||
## 🗺 目录
|
||||
@@ -72,18 +74,18 @@
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 分类 | 主要依赖 |
|
||||
| --------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router |
|
||||
| UI & 样式 | [Tailwind CSS 3](https://tailwindcss.com/) |
|
||||
| 语言 | TypeScript 4 |
|
||||
| 分类 | 主要依赖 |
|
||||
| --------- | ------------------------------------------------------------ |
|
||||
| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router |
|
||||
| UI & 样式 | [Tailwind CSS 3](https://tailwindcss.com/) |
|
||||
| 语言 | TypeScript 4 |
|
||||
| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) |
|
||||
| 代码质量 | ESLint · Prettier · Jest |
|
||||
| 部署 | Docker |
|
||||
| 代码质量 | ESLint · Prettier · Jest |
|
||||
| 部署 | Docker |
|
||||
|
||||
## 部署
|
||||
|
||||
本项目**支持 Docker 和Vercel平台** 部署。
|
||||
本项目**支持 Docker、Vercel 和 Cloudflare Workers 平台** 部署。
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus)
|
||||
|
||||
@@ -91,7 +93,103 @@
|
||||
|
||||
[](https://zeabur.com/templates/SCHCAY/deploy)
|
||||
|
||||
### Kvrocks 存储(推荐)
|
||||
|
||||
|
||||
### Cloudflare Workers 部署(通过 GitHub Actions)
|
||||
|
||||
Cloudflare Workers 提供免费的边缘计算服务,通过 GitHub Actions 可以实现自动化部署。
|
||||
|
||||
#### 前置要求
|
||||
|
||||
1. 一个 Cloudflare 账号
|
||||
2. Fork 本项目到你的 GitHub 账号
|
||||
3. 准备一个 Upstash Redis 实例(推荐)
|
||||
|
||||
#### 配置步骤
|
||||
|
||||
**1. 获取 Cloudflare API Token 和 Account ID**
|
||||
|
||||
- 访问 [Cloudflare Dashboard](https://dash.cloudflare.com/)
|
||||
- 点击右上角头像 > My Profile > API Tokens
|
||||
- 点击 "Create Token",选择 "Edit Cloudflare Workers" 模板
|
||||
- 或使用自定义 Token,需要以下权限:
|
||||
- Account - Cloudflare Workers Scripts - Edit
|
||||
- Account - Cloudflare Workers KV Storage - Edit
|
||||
- 创建后复制生成的 API Token
|
||||
- 在 Dashboard 首页右侧可以看到你的 Account ID
|
||||
|
||||
**2. 配置 GitHub Secrets**
|
||||
|
||||
进入你 Fork 的仓库,点击 Settings > Secrets and variables > Actions > New repository secret,添加以下必需的 Secrets:
|
||||
|
||||
**必需配置:**
|
||||
|
||||
| Secret 名称 | 说明 | 示例值 |
|
||||
| -------------------------- | --------------------- | ------------------------ |
|
||||
| `CLOUDFLARE_API_TOKEN` | Cloudflare API Token | `your_api_token_here` |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare Account ID | `abc123def456` |
|
||||
| `USERNAME` | 站长账号 | `admin` |
|
||||
| `PASSWORD` | 站长密码 | `your_secure_password` |
|
||||
| `NEXT_PUBLIC_STORAGE_TYPE` | 存储类型 | `upstash` |
|
||||
| `UPSTASH_URL` | Upstash Redis URL | `https://xxx.upstash.io` |
|
||||
| `UPSTASH_TOKEN` | Upstash Redis Token | `your_upstash_token` |
|
||||
|
||||
**3. 触发部署**
|
||||
|
||||
配置完成后,有两种方式触发部署:
|
||||
|
||||
**方式一:手动触发**
|
||||
|
||||
- 进入仓库的 Actions 页面
|
||||
- 选择 "Deploy to Cloudflare" workflow
|
||||
- 点击 "Run workflow" 按钮
|
||||
- 选择分支(通常是 main 或 dev)
|
||||
- 点击 "Run workflow" 开始部署
|
||||
|
||||
**方式二:自动触发(可选)**
|
||||
|
||||
如果想要在推送代码时自动部署,可以修改 `.github/workflows/cloudflare-deploy.yml` 文件:
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main # 或你的主分支名称
|
||||
workflow_dispatch:
|
||||
```
|
||||
|
||||
**4. 查看部署状态**
|
||||
|
||||
- 在 Actions 页面可以看到部署进度
|
||||
- 部署成功后,访问 `https://your-project-name.your-account.workers.dev`
|
||||
- 也可以在 Cloudflare Dashboard 的 Workers & Pages 中查看部署的应用
|
||||
|
||||
**5. 绑定自定义域名(可选)**
|
||||
|
||||
- 在 Cloudflare Dashboard 中进入你的 Worker
|
||||
- 点击 Settings > Triggers > Custom Domains
|
||||
- 添加你的自定义域名
|
||||
|
||||
**6. 使用 D1 数据库(可选)**
|
||||
|
||||
如果想使用 Cloudflare D1 数据库代替 Upstash Redis,需要进行以下配置:
|
||||
|
||||
1. 在 Cloudflare Dashboard 中创建一个 D1 数据库
|
||||
2. 复制数据库 ID
|
||||
3. 在 GitHub Secrets 中配置:
|
||||
- 将 `NEXT_PUBLIC_STORAGE_TYPE` 设置为 `d1`
|
||||
- 添加 `D1_DATABASE_ID` 并填入你的数据库 ID
|
||||
- 无需配置 `UPSTASH_URL` 和 `UPSTASH_TOKEN`
|
||||
|
||||
**7. 配置外部定时任务(可选)**
|
||||
|
||||
可使用外部定时请求/api/cron/mtvpls端点以触发定时任务,或新建一个workers请求触发,推荐每小时请求一次。
|
||||
|
||||
---
|
||||
|
||||
### Docker 部署
|
||||
|
||||
#### Kvrocks 存储(推荐)
|
||||
|
||||
```yml
|
||||
services:
|
||||
@@ -163,6 +261,7 @@ networks:
|
||||
1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。
|
||||
2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN**
|
||||
3. 使用如下 docker compose
|
||||
|
||||
```yml
|
||||
services:
|
||||
moontv-core:
|
||||
@@ -234,47 +333,48 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 | 可选值 | 默认值 |
|
||||
| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
||||
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
||||
| CRON_PASSWORD | 定时任务 API 访问密码(用于保护 /api/cron 端点) | 任意字符串 | mtvpls |
|
||||
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
|
||||
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
||||
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
|
||||
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
|
||||
| REDIS_URL | redis 连接 url | 连接 url | 空 |
|
||||
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
|
||||
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
|
||||
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
||||
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
|
||||
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
|
||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
|
||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
|
||||
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
|
||||
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
|
||||
| NEXT_PUBLIC_PROXY_M3U8_TOKEN | M3U8 代理 API 鉴权 Token(外部播放器跳转时的鉴权token,不填为无鉴权) | 任意字符串 | (空) |
|
||||
| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) |
|
||||
| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false |
|
||||
| TVBOX_SUBSCRIBE_TOKEN | TVBOX 订阅 API 访问 Token,如启用TVBOX功能必须设置该项 | 任意字符串 | (空) |
|
||||
| WATCH_ROOM_ENABLED | 是否启用观影室功能(vercel部署不支持该功能,可使用外部服务器) | true/false | false |
|
||||
| WATCH_ROOM_SERVER_TYPE | 观影室服务器类型 | internal/external | internal |
|
||||
| WATCH_ROOM_EXTERNAL_SERVER_URL | 外部观影室服务器地址(当 SERVER_TYPE 为 external 时必填) | WebSocket URL | (空) |
|
||||
| WATCH_ROOM_EXTERNAL_SERVER_AUTH | 外部观影室服务器认证令牌(当 SERVER_TYPE 为 external 时必填) | 任意字符串 | (空) |
|
||||
| NEXT_PUBLIC_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback |
|
||||
| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false |
|
||||
| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data |
|
||||
| VIDEOINFO_CACHE_MINUTES | 私人影库视频信息在内存中的缓存时长(分钟) | 正整数 | 1440(1天) |
|
||||
| NEXT_PUBLIC_ENABLE_SOURCE_SEARCH | 是否开启源站寻片功能 | true/false | true |
|
||||
| MAX_PLAY_RECORDS_PER_USER | 单个用户播放记录清理阈值(超过此数量将自动清理旧记录) | 正整数 | 100 |
|
||||
| INIT_CONFIG | 初始配置(JSON 格式,包含 api_site、custom_category、lives 等) | JSON 字符串 | (空) |
|
||||
| CONFIG_SUBSCRIPTION_URL | 配置订阅 URL(Base58 编码的配置文件地址,优先级高于 INIT_CONFIG) | URL | (空) |
|
||||
| TMDB_API_KEY | TMDB API 密钥 | 任意字符串 | (空) |
|
||||
| TMDB_PROXY | TMDB 代理地址 | URL | (空) |
|
||||
| TMDB_REVERSE_PROXY | TMDB 反向代理地址 | URL | (空) |
|
||||
| DANMAKU_API_BASE | 弹幕 API 地址 | URL | http://localhost:9321 |
|
||||
| DANMAKU_API_TOKEN | 弹幕 API Token | 任意字符串 | 87654321 |
|
||||
| 变量 | 说明 | 可选值 | 默认值 |
|
||||
| ---------------------------------------- | ------------------------------------------------------------ | --------------------------- | ------------------------------------------------------------ |
|
||||
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
||||
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
||||
| CRON_PASSWORD | 定时任务 API 访问密码(用于保护 /api/cron 端点) | 任意字符串 | mtvpls |
|
||||
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
|
||||
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
||||
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash、d1 | 无默认,必填字段 |
|
||||
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
|
||||
| REDIS_URL | redis 连接 url | 连接 url | 空 |
|
||||
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
|
||||
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
|
||||
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
||||
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
|
||||
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
|
||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
|
||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
|
||||
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
|
||||
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
|
||||
| NEXT_PUBLIC_PROXY_M3U8_TOKEN | M3U8 代理 API 鉴权 Token(外部播放器跳转时的鉴权token,不填为无鉴权) | 任意字符串 | (空) |
|
||||
| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) |
|
||||
| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false |
|
||||
| TVBOX_SUBSCRIBE_TOKEN | TVBOX 订阅 API 访问 Token,如启用TVBOX功能必须设置该项 | 任意字符串 | (空) |
|
||||
| TVBOX_BLOCKED_SOURCES | TVBOX 订阅屏蔽源列表(多个源用逗号分隔,匹配视频源的 key) | 逗号分隔的源 key | (空) |
|
||||
| WATCH_ROOM_ENABLED | 是否启用观影室功能(vercel部署不支持该功能,可使用外部服务器) | true/false | false |
|
||||
| WATCH_ROOM_SERVER_TYPE | 观影室服务器类型 | internal/external | internal |
|
||||
| WATCH_ROOM_EXTERNAL_SERVER_URL | 外部观影室服务器地址(当 SERVER_TYPE 为 external 时必填) | WebSocket URL | (空) |
|
||||
| WATCH_ROOM_EXTERNAL_SERVER_AUTH | 外部观影室服务器认证令牌(当 SERVER_TYPE 为 external 时必填) | 任意字符串 | (空) |
|
||||
| NEXT_PUBLIC_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback |
|
||||
| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false |
|
||||
| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data |
|
||||
| VIDEOINFO_CACHE_MINUTES | 私人影库视频信息在内存中的缓存时长(分钟) | 正整数 | 1440(1天) |
|
||||
| NEXT_PUBLIC_ENABLE_SOURCE_SEARCH | 是否开启源站寻片功能 | true/false | true |
|
||||
| MAX_PLAY_RECORDS_PER_USER | 单个用户播放记录清理阈值(超过此数量将自动清理旧记录) | 正整数 | 100 |
|
||||
| INIT_CONFIG | 初始配置(JSON 格式,包含 api_site、custom_category、lives 等) | JSON 字符串 | (空) |
|
||||
| CONFIG_SUBSCRIPTION_URL | 配置订阅 URL(Base58 编码的配置文件地址,优先级高于 INIT_CONFIG) | URL | (空) |
|
||||
| TMDB_API_KEY | TMDB API 密钥 | 任意字符串 | (空) |
|
||||
| TMDB_PROXY | TMDB 代理地址 | URL | (空) |
|
||||
| TMDB_REVERSE_PROXY | TMDB 反向代理地址 | URL | (空) |
|
||||
| DANMAKU_API_BASE | 弹幕 API 地址 | URL | http://localhost:9321 |
|
||||
| DANMAKU_API_TOKEN | 弹幕 API Token | 任意字符串 | 87654321 |
|
||||
|
||||
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
||||
|
||||
@@ -307,13 +407,16 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
|
||||
**配置步骤:**
|
||||
|
||||
1. 按照 [watch-room-server](https://github.com/tgs9915/watch-room-server) 的文档部署外部服务器
|
||||
|
||||
2. 在 MoonTVPlus 中设置以下环境变量:
|
||||
|
||||
```env
|
||||
WATCH_ROOM_ENABLED=true
|
||||
WATCH_ROOM_SERVER_TYPE=external
|
||||
WATCH_ROOM_EXTERNAL_SERVER_URL=wss://your-watch-room-server.com
|
||||
WATCH_ROOM_EXTERNAL_SERVER_AUTH=your_secure_token
|
||||
```
|
||||
|
||||
3. 重启应用即可使用外部观影室服务器
|
||||
|
||||
|
||||
@@ -332,6 +435,7 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
|
||||
|
||||
|
||||
## 超分功能说明
|
||||
|
||||
超分功能需要浏览器支持webgpu并且你的浏览器环境不能是http(如非要在http中使用,需要在浏览器端设置允许不安全的内容)
|
||||
|
||||
|
||||
@@ -350,13 +454,16 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
|
||||
### 配置步骤
|
||||
|
||||
1. 在环境变量中设置以下配置:
|
||||
|
||||
```env
|
||||
# 启用 TVBOX 订阅功能
|
||||
ENABLE_TVBOX_SUBSCRIBE=true
|
||||
# 设置订阅访问 Token(请使用强密码)
|
||||
TVBOX_SUBSCRIBE_TOKEN=your_secure_random_token
|
||||
# 可选:屏蔽特定视频源(多个源用逗号分隔,填写视频源的 key)
|
||||
TVBOX_BLOCKED_SOURCES=source1,source2
|
||||
```
|
||||
|
||||
|
||||
2. 重启应用后,登录网站,点击用户菜单中的"订阅"按钮
|
||||
|
||||
3. 复制生成的订阅链接到 TVBOX 应用中使用
|
||||
@@ -398,4 +505,4 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#mtvpls/moontvplus&Date)
|
||||
[](https://www.star-history.com/#mtvpls/moontvplus&Date)
|
||||
@@ -1,2 +1,2 @@
|
||||
210.0.0
|
||||
211.0.0
|
||||
|
||||
|
||||
170
migrations/001_initial_schema.sql
Normal file
170
migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,170 @@
|
||||
-- ============================================
|
||||
-- MoonTV Plus - Cloudflare D1 数据库结构
|
||||
-- 版本: 1.0.0
|
||||
-- 创建时间: 2026-01-30
|
||||
-- ============================================
|
||||
|
||||
-- 1. 用户表
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
username TEXT PRIMARY KEY,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('owner', 'admin', 'user')),
|
||||
banned INTEGER DEFAULT 0,
|
||||
tags TEXT, -- JSON array: ["vip", "premium"]
|
||||
oidc_sub TEXT UNIQUE,
|
||||
enabled_apis TEXT, -- JSON array: ["api1", "api2"]
|
||||
created_at INTEGER NOT NULL,
|
||||
playrecord_migrated INTEGER DEFAULT 0,
|
||||
favorite_migrated INTEGER DEFAULT 0,
|
||||
skip_migrated INTEGER DEFAULT 0,
|
||||
last_movie_request_time INTEGER,
|
||||
email TEXT,
|
||||
email_notifications INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_oidc_sub ON users(oidc_sub) WHERE oidc_sub IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
|
||||
|
||||
-- 2. 播放记录表
|
||||
CREATE TABLE IF NOT EXISTS play_records (
|
||||
username TEXT NOT NULL,
|
||||
key TEXT NOT NULL, -- format: "source+id" (e.g., "tmdb+12345")
|
||||
title TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
cover TEXT,
|
||||
year TEXT,
|
||||
episode_index INTEGER NOT NULL,
|
||||
total_episodes INTEGER NOT NULL,
|
||||
play_time INTEGER NOT NULL, -- 播放进度(秒)
|
||||
total_time INTEGER NOT NULL, -- 总时长(秒)
|
||||
save_time INTEGER NOT NULL, -- 保存时间戳
|
||||
search_title TEXT,
|
||||
PRIMARY KEY (username, key),
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_play_records_save_time ON play_records(username, save_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_play_records_source ON play_records(username, source_name);
|
||||
|
||||
-- 3. 收藏表
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
username TEXT NOT NULL,
|
||||
key TEXT NOT NULL, -- format: "source+id"
|
||||
source_name TEXT NOT NULL,
|
||||
total_episodes INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
year TEXT,
|
||||
cover TEXT,
|
||||
save_time INTEGER NOT NULL,
|
||||
search_title TEXT,
|
||||
origin TEXT CHECK(origin IN ('vod', 'live')),
|
||||
is_completed INTEGER DEFAULT 0,
|
||||
vod_remarks TEXT,
|
||||
PRIMARY KEY (username, key),
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_favorites_save_time ON favorites(username, save_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_favorites_source ON favorites(username, source_name);
|
||||
|
||||
-- 4. 搜索历史表
|
||||
CREATE TABLE IF NOT EXISTS search_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
keyword TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE,
|
||||
UNIQUE(username, keyword)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_search_history_user_time ON search_history(username, timestamp DESC);
|
||||
|
||||
-- 5. 跳过配置表(片头片尾)
|
||||
CREATE TABLE IF NOT EXISTS skip_configs (
|
||||
username TEXT NOT NULL,
|
||||
key TEXT NOT NULL, -- format: "source+id"
|
||||
enable INTEGER NOT NULL DEFAULT 1,
|
||||
intro_time INTEGER NOT NULL DEFAULT 0, -- 片头时长(秒)
|
||||
outro_time INTEGER NOT NULL DEFAULT 0, -- 片尾时长(秒)
|
||||
PRIMARY KEY (username, key),
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 6. 弹幕过滤配置表
|
||||
CREATE TABLE IF NOT EXISTS danmaku_filter_configs (
|
||||
username TEXT PRIMARY KEY,
|
||||
rules TEXT NOT NULL, -- JSON array: [{"keyword": "xxx", "type": "normal", "enabled": true}]
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 7. 通知表
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('favorite_update', 'system', 'announcement', 'movie_request', 'request_fulfilled')),
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
read INTEGER DEFAULT 0,
|
||||
metadata TEXT, -- JSON object
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_time ON notifications(username, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(username, read, timestamp DESC);
|
||||
|
||||
-- 8. 求片请求表
|
||||
CREATE TABLE IF NOT EXISTS movie_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
tmdb_id INTEGER,
|
||||
title TEXT NOT NULL,
|
||||
year TEXT,
|
||||
media_type TEXT NOT NULL CHECK(media_type IN ('movie', 'tv')),
|
||||
season INTEGER,
|
||||
poster TEXT,
|
||||
overview TEXT,
|
||||
requested_by TEXT NOT NULL, -- JSON array: ["user1", "user2"]
|
||||
request_count INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending', 'fulfilled')) DEFAULT 'pending',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
fulfilled_at INTEGER,
|
||||
fulfilled_source TEXT,
|
||||
fulfilled_id TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_movie_requests_status ON movie_requests(status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_movie_requests_tmdb ON movie_requests(tmdb_id) WHERE tmdb_id IS NOT NULL;
|
||||
|
||||
-- 9. 用户求片关联表(用于快速查询用户的求片记录)
|
||||
CREATE TABLE IF NOT EXISTS user_movie_requests (
|
||||
username TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
PRIMARY KEY (username, request_id),
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE,
|
||||
FOREIGN KEY (request_id) REFERENCES movie_requests(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_movie_requests_user ON user_movie_requests(username);
|
||||
|
||||
-- 10. 全局配置表(键值对存储)
|
||||
CREATE TABLE IF NOT EXISTS global_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 11. 管理员配置表(单例)
|
||||
CREATE TABLE IF NOT EXISTS admin_config (
|
||||
id INTEGER PRIMARY KEY CHECK(id = 1), -- 确保只有一条记录
|
||||
config TEXT NOT NULL, -- JSON object
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 12. 收藏更新检查时间表
|
||||
CREATE TABLE IF NOT EXISTS favorite_check_times (
|
||||
username TEXT PRIMARY KEY,
|
||||
last_check_time INTEGER NOT NULL,
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
@@ -35,7 +35,7 @@ const nextConfig = {
|
||||
],
|
||||
},
|
||||
|
||||
webpack(config) {
|
||||
webpack(config, { isServer }) {
|
||||
// Grab the existing rule that handles SVG imports
|
||||
const fileLoaderRule = config.module.rules.find((rule) =>
|
||||
rule.test?.test?.('.svg')
|
||||
@@ -71,6 +71,21 @@ const nextConfig = {
|
||||
crypto: false,
|
||||
};
|
||||
|
||||
// Exclude better-sqlite3 and D1 modules from client-side bundle
|
||||
if (!isServer) {
|
||||
config.externals = config.externals || [];
|
||||
config.externals.push({
|
||||
'better-sqlite3': 'commonjs better-sqlite3',
|
||||
});
|
||||
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'better-sqlite3': false,
|
||||
'@/lib/d1.db': false,
|
||||
'@/lib/d1-adapter': false,
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"gen:manifest": "node scripts/generate-manifest.js",
|
||||
"postbuild": "echo 'Build completed - sitemap generation disabled'",
|
||||
"prepare": "husky install",
|
||||
"watch-room:server": "node server/watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY"
|
||||
"watch-room:server": "node server/watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY",
|
||||
"init:sqlite": "node scripts/init-sqlite.js",
|
||||
"db:reset": "rm -f .data/moontv.db .data/moontv.db-shm .data/moontv.db-wal && node scripts/init-sqlite.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -36,6 +38,7 @@
|
||||
"anime4k-webgpu": "^1.0.0",
|
||||
"artplayer": "^5.3.0",
|
||||
"artplayer-plugin-danmuku": "^5.2.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"bs58": "^6.0.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"clsx": "^2.0.0",
|
||||
@@ -64,6 +67,7 @@
|
||||
"react-window": "^2.2.3",
|
||||
"redis": "^4.6.7",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"swiper": "^11.2.8",
|
||||
@@ -81,6 +85,7 @@
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/bs58": "^5.0.0",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/node": "24.0.3",
|
||||
|
||||
196
pnpm-lock.yaml
generated
196
pnpm-lock.yaml
generated
@@ -47,6 +47,9 @@ importers:
|
||||
artplayer-plugin-danmuku:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0
|
||||
better-sqlite3:
|
||||
specifier: ^12.6.2
|
||||
version: 12.6.2
|
||||
bs58:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@@ -131,6 +134,9 @@ importers:
|
||||
remark-gfm:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
server-only:
|
||||
specifier: ^0.0.1
|
||||
version: 0.0.1
|
||||
socket.io:
|
||||
specifier: ^4.8.1
|
||||
version: 4.8.3
|
||||
@@ -177,6 +183,9 @@ importers:
|
||||
'@testing-library/react':
|
||||
specifier: ^15.0.7
|
||||
version: 15.0.7(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@types/better-sqlite3':
|
||||
specifier: ^7.6.11
|
||||
version: 7.6.13
|
||||
'@types/bs58':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
@@ -3146,6 +3155,9 @@ packages:
|
||||
'@types/babel__traverse@7.28.0':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/better-sqlite3@7.6.13':
|
||||
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
|
||||
|
||||
'@types/bs58@5.0.0':
|
||||
resolution: {integrity: sha512-cAw/jKBzo98m6Xz1X5ETqymWfIMbXbu6nK15W4LQYjeHJkVqSmM5PO8Bd9KVHQJ/F4rHcSso9LcjtgCW6TGu2w==}
|
||||
deprecated: This is a stub types definition. bs58 provides its own type definitions, so you do not need this installed.
|
||||
@@ -3986,6 +3998,9 @@ packages:
|
||||
base-x@5.0.1:
|
||||
resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
base64id@2.0.0:
|
||||
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
||||
engines: {node: ^4.5.0 || >= 5.9}
|
||||
@@ -3994,6 +4009,10 @@ packages:
|
||||
resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==}
|
||||
hasBin: true
|
||||
|
||||
better-sqlite3@12.6.2:
|
||||
resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==}
|
||||
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
|
||||
|
||||
big.js@5.2.2:
|
||||
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
|
||||
|
||||
@@ -4004,6 +4023,9 @@ packages:
|
||||
bindings@1.5.0:
|
||||
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
|
||||
|
||||
bl@4.1.0:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
blake3-wasm@2.1.5:
|
||||
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
|
||||
|
||||
@@ -4047,6 +4069,9 @@ packages:
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
builtin-modules@3.3.0:
|
||||
resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4150,6 +4175,9 @@ packages:
|
||||
resolution: {integrity: sha512-mxIojEAQcuEvT/lyXq+jf/3cO/KoA6z4CeNDGGevTybECPOMFCnQy3OPahluUkbqgPNGw5Bi78UC7Po6Lhy+NA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
||||
chownr@1.1.4:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
|
||||
chownr@2.0.0:
|
||||
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4499,9 +4527,17 @@ packages:
|
||||
decode-named-character-reference@1.2.0:
|
||||
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
dedent@0.7.0:
|
||||
resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
|
||||
|
||||
deep-extend@0.6.0:
|
||||
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
@@ -4979,6 +5015,10 @@ packages:
|
||||
resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
expand-template@2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
expect@27.5.1:
|
||||
resolution: {integrity: sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
@@ -5124,6 +5164,9 @@ packages:
|
||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
fs-constants@1.0.0:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
|
||||
fs-extra@10.1.0:
|
||||
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -5217,6 +5260,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
github-from-package@0.0.0:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -5402,6 +5448,9 @@ packages:
|
||||
idb@7.1.1:
|
||||
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -6397,6 +6446,10 @@ packages:
|
||||
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mimic-response@3.1.0:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
min-document@2.19.2:
|
||||
resolution: {integrity: sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==}
|
||||
|
||||
@@ -6463,6 +6516,9 @@ packages:
|
||||
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
mkdirp-classic@0.5.3:
|
||||
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
||||
|
||||
mkdirp@1.0.4:
|
||||
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -6520,6 +6576,9 @@ packages:
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
napi-build-utils@2.0.0:
|
||||
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
|
||||
|
||||
napi-postinstall@0.3.4:
|
||||
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
@@ -6580,6 +6639,10 @@ packages:
|
||||
no-case@3.0.4:
|
||||
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
|
||||
|
||||
node-abi@3.87.0:
|
||||
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
@@ -6961,6 +7024,11 @@ packages:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
prelude-ls@1.1.2:
|
||||
resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -7110,6 +7178,10 @@ packages:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
rc@1.2.8:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
react-dom@18.3.1:
|
||||
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
||||
peerDependencies:
|
||||
@@ -7384,6 +7456,9 @@ packages:
|
||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
server-only@0.0.1:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -7441,6 +7516,12 @@ packages:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
simple-concat@1.0.1:
|
||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||
|
||||
simple-get@4.0.1:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
@@ -7654,6 +7735,10 @@ packages:
|
||||
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-json-comments@2.0.1:
|
||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
strip-json-comments@3.1.1:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7752,6 +7837,13 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-fs@2.1.4:
|
||||
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
|
||||
|
||||
tar-stream@2.2.0:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar@6.2.1:
|
||||
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -7944,6 +8036,9 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
|
||||
type-check@0.3.2:
|
||||
resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -12807,6 +12902,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.28.5
|
||||
|
||||
'@types/better-sqlite3@7.6.13':
|
||||
dependencies:
|
||||
'@types/node': 24.0.3
|
||||
|
||||
'@types/bs58@5.0.0':
|
||||
dependencies:
|
||||
bs58: 6.0.0
|
||||
@@ -13888,10 +13987,17 @@ snapshots:
|
||||
|
||||
base-x@5.0.1: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
base64id@2.0.0: {}
|
||||
|
||||
baseline-browser-mapping@2.9.11: {}
|
||||
|
||||
better-sqlite3@12.6.2:
|
||||
dependencies:
|
||||
bindings: 1.5.0
|
||||
prebuild-install: 7.1.3
|
||||
|
||||
big.js@5.2.2: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
@@ -13900,6 +14006,12 @@ snapshots:
|
||||
dependencies:
|
||||
file-uri-to-path: 1.0.0
|
||||
|
||||
bl@4.1.0:
|
||||
dependencies:
|
||||
buffer: 5.7.1
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
blake3-wasm@2.1.5: {}
|
||||
|
||||
body-parser@2.2.2:
|
||||
@@ -13955,6 +14067,11 @@ snapshots:
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@5.7.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
builtin-modules@3.3.0: {}
|
||||
|
||||
busboy@1.6.0:
|
||||
@@ -14065,6 +14182,8 @@ snapshots:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
|
||||
chownr@1.1.4: {}
|
||||
|
||||
chownr@2.0.0: {}
|
||||
|
||||
chownr@3.0.0: {}
|
||||
@@ -14386,8 +14505,14 @@ snapshots:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
|
||||
decompress-response@6.0.0:
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
|
||||
dedent@0.7.0: {}
|
||||
|
||||
deep-extend@0.6.0: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
@@ -15059,6 +15184,8 @@ snapshots:
|
||||
|
||||
exit@0.1.2: {}
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
|
||||
expect@27.5.1:
|
||||
dependencies:
|
||||
'@jest/types': 27.5.1
|
||||
@@ -15251,6 +15378,8 @@ snapshots:
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fs-constants@1.0.0: {}
|
||||
|
||||
fs-extra@10.1.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -15350,6 +15479,8 @@ snapshots:
|
||||
split2: 3.2.2
|
||||
through2: 4.0.2
|
||||
|
||||
github-from-package@0.0.0: {}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -15579,6 +15710,8 @@ snapshots:
|
||||
|
||||
idb@7.1.1: {}
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
@@ -17123,6 +17256,8 @@ snapshots:
|
||||
|
||||
mimic-function@5.0.1: {}
|
||||
|
||||
mimic-response@3.1.0: {}
|
||||
|
||||
min-document@2.19.2:
|
||||
dependencies:
|
||||
dom-walk: 0.1.2
|
||||
@@ -17191,6 +17326,8 @@ snapshots:
|
||||
dependencies:
|
||||
minipass: 7.1.2
|
||||
|
||||
mkdirp-classic@0.5.3: {}
|
||||
|
||||
mkdirp@1.0.4: {}
|
||||
|
||||
mkdirp@3.0.1: {}
|
||||
@@ -17240,6 +17377,8 @@ snapshots:
|
||||
|
||||
nanoid@5.1.6: {}
|
||||
|
||||
napi-build-utils@2.0.0: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
||||
natural-compare-lite@1.4.0: {}
|
||||
@@ -17310,6 +17449,10 @@ snapshots:
|
||||
lower-case: 2.0.2
|
||||
tslib: 2.8.1
|
||||
|
||||
node-abi@3.87.0:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch@2.6.7:
|
||||
@@ -17649,6 +17792,21 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
expand-template: 2.0.3
|
||||
github-from-package: 0.0.0
|
||||
minimist: 1.2.8
|
||||
mkdirp-classic: 0.5.3
|
||||
napi-build-utils: 2.0.0
|
||||
node-abi: 3.87.0
|
||||
pump: 3.0.3
|
||||
rc: 1.2.8
|
||||
simple-get: 4.0.1
|
||||
tar-fs: 2.1.4
|
||||
tunnel-agent: 0.6.0
|
||||
|
||||
prelude-ls@1.1.2: {}
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
@@ -17742,6 +17900,13 @@ snapshots:
|
||||
iconv-lite: 0.7.2
|
||||
unpipe: 1.0.0
|
||||
|
||||
rc@1.2.8:
|
||||
dependencies:
|
||||
deep-extend: 0.6.0
|
||||
ini: 1.3.8
|
||||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
react-dom@18.3.1(react@18.3.1):
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -18096,6 +18261,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
server-only@0.0.1: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -18193,6 +18360,14 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
simple-concat@1.0.1: {}
|
||||
|
||||
simple-get@4.0.1:
|
||||
dependencies:
|
||||
decompress-response: 6.0.0
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
slash@3.0.0: {}
|
||||
@@ -18454,6 +18629,8 @@ snapshots:
|
||||
dependencies:
|
||||
min-indent: 1.0.1
|
||||
|
||||
strip-json-comments@2.0.1: {}
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
strnum@1.1.2: {}
|
||||
@@ -18562,6 +18739,21 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
tar-fs@2.1.4:
|
||||
dependencies:
|
||||
chownr: 1.1.4
|
||||
mkdirp-classic: 0.5.3
|
||||
pump: 3.0.3
|
||||
tar-stream: 2.2.0
|
||||
|
||||
tar-stream@2.2.0:
|
||||
dependencies:
|
||||
bl: 4.1.0
|
||||
end-of-stream: 1.4.5
|
||||
fs-constants: 1.0.0
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
tar@6.2.1:
|
||||
dependencies:
|
||||
chownr: 2.0.0
|
||||
@@ -18760,6 +18952,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
type-check@0.3.2:
|
||||
dependencies:
|
||||
prelude-ls: 1.1.2
|
||||
|
||||
62
scripts/init-sqlite.js
Normal file
62
scripts/init-sqlite.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// SHA-256 加密密码(与 Redis 保持一致)
|
||||
function hashPassword(password) {
|
||||
return crypto.createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
|
||||
// 确保 .data 目录存在
|
||||
const dataDir = path.join(__dirname, '../.data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 创建数据库
|
||||
const dbPath = path.join(dataDir, 'moontv.db');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
console.log('📦 Initializing SQLite database for development...');
|
||||
console.log('📍 Database location:', dbPath);
|
||||
|
||||
// 读取迁移脚本
|
||||
const migrationPath = path.join(__dirname, '../migrations/001_initial_schema.sql');
|
||||
if (!fs.existsSync(migrationPath)) {
|
||||
console.error('❌ Migration file not found:', migrationPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = fs.readFileSync(migrationPath, 'utf8');
|
||||
|
||||
// 执行迁移
|
||||
try {
|
||||
db.exec(sql);
|
||||
console.log('✅ Database schema created successfully!');
|
||||
|
||||
// 创建默认管理员用户(可选)
|
||||
const username = process.env.USERNAME || 'admin';
|
||||
const password = process.env.PASSWORD || '123456789';
|
||||
const passwordHash = hashPassword(password);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR IGNORE INTO users (username, password_hash, role, created_at, playrecord_migrated, favorite_migrated, skip_migrated)
|
||||
VALUES (?, ?, 'owner', ?, 1, 1, 1)
|
||||
`);
|
||||
|
||||
stmt.run(username, passwordHash, Date.now());
|
||||
console.log(`✅ Default admin user created: ${username}`);
|
||||
} catch (err) {
|
||||
console.error('❌ Migration failed:', err);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('🎉 SQLite database initialized successfully!');
|
||||
console.log('');
|
||||
console.log('Next steps:');
|
||||
console.log('1. Set NEXT_PUBLIC_STORAGE_TYPE=d1 in .env');
|
||||
console.log('2. Run: npm run dev');
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
|
||||
@@ -364,6 +364,7 @@ interface DataSource {
|
||||
disabled?: boolean;
|
||||
from: 'config' | 'custom';
|
||||
proxyMode?: boolean;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
// 直播源数据类型
|
||||
@@ -4500,6 +4501,52 @@ const VideoSourceConfig = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateWeight = (key: string, weight: number) => {
|
||||
// 先乐观更新本地状态
|
||||
setSources((prev) =>
|
||||
prev.map((s) =>
|
||||
s.key === key ? { ...s, weight } : s
|
||||
)
|
||||
);
|
||||
|
||||
// 调用API更新
|
||||
withLoading(`updateWeight_${key}`, async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/source', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'update_weight',
|
||||
key,
|
||||
weight,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || `操作失败: ${response.status}`);
|
||||
}
|
||||
|
||||
await refreshConfig();
|
||||
} catch (error) {
|
||||
// 失败时回滚本地状态到配置中的值
|
||||
const originalWeight = config?.SourceConfig?.find(s => s.key === key)?.weight ?? 0;
|
||||
setSources((prev) =>
|
||||
prev.map((s) =>
|
||||
s.key === key ? { ...s, weight: originalWeight } : s
|
||||
)
|
||||
);
|
||||
showError(
|
||||
error instanceof Error ? error.message : '更新权重失败',
|
||||
showAlert
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}).catch(() => {
|
||||
console.error('操作失败', 'update_weight', key, weight);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddSource = () => {
|
||||
if (!newSource.name || !newSource.key || !newSource.api) return;
|
||||
withLoading('addSource', async () => {
|
||||
@@ -4726,8 +4773,48 @@ const VideoSourceConfig = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 权重输入组件 - 使用本地状态避免输入时失焦
|
||||
const WeightInput = memo(({ sourceKey, initialWeight }: { sourceKey: string; initialWeight: number }) => {
|
||||
const [localWeight, setLocalWeight] = useState(initialWeight);
|
||||
|
||||
// 当外部权重变化时同步
|
||||
useEffect(() => {
|
||||
setLocalWeight(initialWeight);
|
||||
}, [initialWeight]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type='number'
|
||||
inputMode='numeric'
|
||||
min='0'
|
||||
max='100'
|
||||
value={localWeight}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 0;
|
||||
const clampedValue = Math.min(100, Math.max(0, value));
|
||||
setLocalWeight(clampedValue);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const newValue = parseInt(e.target.value) || 0;
|
||||
const clampedValue = Math.min(100, Math.max(0, newValue));
|
||||
const originalWeight = config?.SourceConfig?.find(s => s.key === sourceKey)?.weight ?? 0;
|
||||
|
||||
// 只有在值发生变化时才调用API
|
||||
if (clampedValue !== originalWeight) {
|
||||
handleUpdateWeight(sourceKey, clampedValue);
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className='w-16 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent'
|
||||
title='权重范围:0-100,用于排序和优选评分'
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// 可拖拽行封装 (dnd-kit)
|
||||
const DraggableRow = ({ source }: { source: DataSource }) => {
|
||||
const DraggableRow = memo(({ source }: { source: DataSource }) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: source.key });
|
||||
|
||||
@@ -4812,6 +4899,9 @@ const VideoSourceConfig = ({
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap' style={{ touchAction: 'auto' }}>
|
||||
<WeightInput sourceKey={source.key} initialWeight={source.weight ?? 0} />
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
|
||||
{(() => {
|
||||
const status = getValidationStatus(source.key);
|
||||
@@ -4864,7 +4954,7 @@ const VideoSourceConfig = ({
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
// 全选/取消全选
|
||||
const handleSelectAll = useCallback(
|
||||
@@ -5195,6 +5285,9 @@ const VideoSourceConfig = ({
|
||||
<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>
|
||||
@@ -11197,8 +11290,12 @@ function AdminPageClient() {
|
||||
setRole(data.Role);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '获取配置失败';
|
||||
showError(msg, showAlert);
|
||||
setError(msg);
|
||||
// 只在首次加载时设置错误状态,避免弹窗和错误页面同时显示
|
||||
if (showLoading) {
|
||||
setError(msg);
|
||||
} else {
|
||||
showError(msg, showAlert);
|
||||
}
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setLoading(false);
|
||||
@@ -11331,8 +11428,42 @@ function AdminPageClient() {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// 错误已通过弹窗展示,此处直接返回空
|
||||
return null;
|
||||
// 显示无权限提示页面
|
||||
return (
|
||||
<PageLayout activePath='/admin'>
|
||||
<div className='min-h-screen flex items-center justify-center px-4'>
|
||||
<div className='max-w-md w-full'>
|
||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 text-center'>
|
||||
<div className='mb-6'>
|
||||
<div className='mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center'>
|
||||
<AlertCircle className='w-8 h-8 text-red-600 dark:text-red-400' />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4'>
|
||||
无权限访问
|
||||
</h2>
|
||||
<p className='text-gray-600 dark:text-gray-400 mb-6'>
|
||||
{error}
|
||||
</p>
|
||||
<div className='space-y-3'>
|
||||
<button
|
||||
onClick={() => window.location.href = '/'}
|
||||
className='w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg font-medium transition-colors'
|
||||
>
|
||||
返回首页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.href = '/login'}
|
||||
className='w-full px-6 py-3 bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg font-medium transition-colors'
|
||||
>
|
||||
重新登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -152,7 +152,18 @@ async function getUserPasswordV2(username: string): Promise<string | null> {
|
||||
const storage = (db as any).storage;
|
||||
if (!storage) return null;
|
||||
|
||||
// 直接调用hGetAll获取完整用户信息(包括密码)
|
||||
// 检查存储类型
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
|
||||
// D1 存储:使用 getUserPasswordHash 方法
|
||||
if (storageType === 'd1') {
|
||||
if (typeof storage.getUserPasswordHash === 'function') {
|
||||
return await storage.getUserPasswordHash(username);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Redis 存储:直接调用hGetAll获取完整用户信息(包括密码)
|
||||
const userInfoKey = `user:${username}:info`;
|
||||
|
||||
if (typeof storage.withRetry === 'function' && storage.client?.hgetall) {
|
||||
|
||||
@@ -104,6 +104,7 @@ export async function POST(req: NextRequest) {
|
||||
// 导入用户数据和user:info
|
||||
const userData = importData.data.userData;
|
||||
const storage = (db as any).storage;
|
||||
// 使用前面已声明的 storageType 变量
|
||||
const usersV2Map = new Map((importData.data.usersV2 || []).map((u: any) => [u.username, u]));
|
||||
|
||||
const userCount = Object.keys(userData).length;
|
||||
@@ -127,44 +128,69 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const createdAt = userV2?.created_at || Date.now();
|
||||
|
||||
// 直接设置用户信息(不经过createUserV2,避免密码被再次hash)
|
||||
const userInfoKey = `user:${username}:info`;
|
||||
const userInfo: Record<string, string> = {
|
||||
role,
|
||||
banned: String(userV2?.banned || false),
|
||||
password: user.passwordV2, // 已经是hash过的密码,直接使用
|
||||
created_at: createdAt.toString(),
|
||||
};
|
||||
// 根据存储类型使用不同的导入方法
|
||||
if (storageType === 'd1') {
|
||||
// D1 存储:使用 createUserWithHashedPassword 方法
|
||||
try {
|
||||
if (typeof storage.createUserWithHashedPassword === 'function') {
|
||||
await storage.createUserWithHashedPassword(
|
||||
username,
|
||||
user.passwordV2, // 已经是hash过的密码
|
||||
role,
|
||||
createdAt,
|
||||
userV2?.tags,
|
||||
userV2?.oidcSub,
|
||||
userV2?.enabledApis,
|
||||
userV2?.banned
|
||||
);
|
||||
importedCount++;
|
||||
console.log(`用户 ${username} 导入成功 (D1)`);
|
||||
} else {
|
||||
console.error(`D1 storage 缺少 createUserWithHashedPassword 方法`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`导入用户 ${username} 失败:`, err);
|
||||
}
|
||||
} else {
|
||||
// Redis 存储:直接设置用户信息
|
||||
const userInfoKey = `user:${username}:info`;
|
||||
const userInfo: Record<string, string> = {
|
||||
role,
|
||||
banned: String(userV2?.banned || false),
|
||||
password: user.passwordV2, // 已经是hash过的密码,直接使用
|
||||
created_at: createdAt.toString(),
|
||||
};
|
||||
|
||||
if (userV2?.tags && userV2.tags.length > 0) {
|
||||
userInfo.tags = JSON.stringify(userV2.tags);
|
||||
if (userV2?.tags && userV2.tags.length > 0) {
|
||||
userInfo.tags = JSON.stringify(userV2.tags);
|
||||
}
|
||||
|
||||
if (userV2?.oidcSub) {
|
||||
userInfo.oidcSub = userV2.oidcSub;
|
||||
}
|
||||
|
||||
if (userV2?.enabledApis && userV2.enabledApis.length > 0) {
|
||||
userInfo.enabledApis = JSON.stringify(userV2.enabledApis);
|
||||
}
|
||||
|
||||
// 使用storage.withRetry直接设置用户信息
|
||||
await storage.withRetry(() => storage.client.hSet(userInfoKey, userInfo));
|
||||
|
||||
// 添加到用户列表
|
||||
await storage.withRetry(() => storage.client.zAdd('user:list', {
|
||||
score: createdAt,
|
||||
value: username,
|
||||
}));
|
||||
|
||||
// 如果有oidcSub,创建映射
|
||||
if (userV2?.oidcSub) {
|
||||
const oidcSubKey = `oidc:sub:${userV2.oidcSub}`;
|
||||
await storage.withRetry(() => storage.client.set(oidcSubKey, username));
|
||||
}
|
||||
|
||||
importedCount++;
|
||||
console.log(`用户 ${username} 导入成功 (Redis)`);
|
||||
}
|
||||
|
||||
if (userV2?.oidcSub) {
|
||||
userInfo.oidcSub = userV2.oidcSub;
|
||||
}
|
||||
|
||||
if (userV2?.enabledApis && userV2.enabledApis.length > 0) {
|
||||
userInfo.enabledApis = JSON.stringify(userV2.enabledApis);
|
||||
}
|
||||
|
||||
// 使用storage.withRetry直接设置用户信息
|
||||
await storage.withRetry(() => storage.client.hSet(userInfoKey, userInfo));
|
||||
|
||||
// 添加到用户列表
|
||||
await storage.withRetry(() => storage.client.zAdd('user:list', {
|
||||
score: createdAt,
|
||||
value: username,
|
||||
}));
|
||||
|
||||
// 如果有oidcSub,创建映射
|
||||
if (userV2?.oidcSub) {
|
||||
const oidcSubKey = `oidc:sub:${userV2.oidcSub}`;
|
||||
await storage.withRetry(() => storage.client.set(oidcSubKey, username));
|
||||
}
|
||||
|
||||
importedCount++;
|
||||
console.log(`用户 ${username} 导入成功`);
|
||||
} else {
|
||||
console.log(`跳过用户 ${username}:没有passwordV2`);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { db } from '@/lib/db';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 支持的操作类型
|
||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete' | 'toggle_proxy_mode';
|
||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete' | 'toggle_proxy_mode' | 'update_weight';
|
||||
|
||||
interface BaseBody {
|
||||
action?: Action;
|
||||
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
||||
const username = authInfo.username;
|
||||
|
||||
// 基础校验
|
||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete', 'toggle_proxy_mode'];
|
||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete', 'toggle_proxy_mode', 'update_weight'];
|
||||
if (!username || !action || !ACTIONS.includes(action)) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
@@ -254,6 +254,20 @@ export async function POST(request: NextRequest) {
|
||||
entry.proxyMode = !entry.proxyMode;
|
||||
break;
|
||||
}
|
||||
case 'update_weight': {
|
||||
const { key, weight } = body as { key?: string; weight?: number };
|
||||
if (!key)
|
||||
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||
if (weight === undefined || weight === null)
|
||||
return NextResponse.json({ error: '缺少 weight 参数' }, { status: 400 });
|
||||
if (typeof weight !== 'number' || weight < 0 || weight > 100)
|
||||
return NextResponse.json({ error: '权重必须是 0-100 之间的数字' }, { status: 400 });
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
if (!entry)
|
||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||
entry.weight = weight;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ async function generateSignature(
|
||||
function getDeviceInfo(userAgent: string): string {
|
||||
const ua = userAgent.toLowerCase();
|
||||
|
||||
// 检查是否为 MoonTVPlus APP
|
||||
if (ua.includes('moontvplus')) {
|
||||
return 'MoonTVPlus APP';
|
||||
}
|
||||
|
||||
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
||||
if (ua.includes('android')) return 'Android Mobile';
|
||||
if (ua.includes('iphone')) return 'iPhone';
|
||||
|
||||
@@ -40,6 +40,11 @@ async function generateSignature(
|
||||
function getDeviceInfo(userAgent: string): string {
|
||||
const ua = userAgent.toLowerCase();
|
||||
|
||||
// 检查是否为 MoonTVPlus APP
|
||||
if (ua.includes('moontvplus')) {
|
||||
return 'MoonTVPlus APP';
|
||||
}
|
||||
|
||||
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
||||
if (ua.includes('android')) return 'Android Mobile';
|
||||
if (ua.includes('iphone')) return 'iPhone';
|
||||
|
||||
108
src/app/api/auth/refresh/route.ts
Normal file
108
src/app/api/auth/refresh/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/* eslint-disable no-console */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie, parseAuthInfo } from '@/lib/auth';
|
||||
import { refreshAccessToken } from '@/lib/middleware-auth';
|
||||
import { TOKEN_CONFIG } from '@/lib/refresh-token';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const STORAGE_TYPE =
|
||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||
| 'localstorage'
|
||||
| 'redis'
|
||||
| 'upstash'
|
||||
| 'kvrocks'
|
||||
| undefined) || 'localstorage';
|
||||
|
||||
function buildRefreshResponse(authToken?: string | null) {
|
||||
const body: Record<string, unknown> = { ok: true };
|
||||
|
||||
if (authToken) {
|
||||
body.token = authToken;
|
||||
const authInfo = parseAuthInfo(authToken);
|
||||
if (authInfo) {
|
||||
const { password, ...rest } = authInfo;
|
||||
body.auth = rest;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(body);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
|
||||
if (!authInfo) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
if (!authInfo.password || authInfo.password !== process.env.PASSWORD) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const authCookie = request.cookies.get('auth');
|
||||
if (!authCookie?.value) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = buildRefreshResponse(authCookie.value);
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 60);
|
||||
response.cookies.set('auth', authCookie.value, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
if (
|
||||
!authInfo.username ||
|
||||
!authInfo.role ||
|
||||
!authInfo.timestamp ||
|
||||
!authInfo.tokenId ||
|
||||
!authInfo.refreshToken ||
|
||||
!authInfo.refreshExpires
|
||||
) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// 只检查 Refresh Token 是否过期
|
||||
if (now >= authInfo.refreshExpires) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Refresh token expired' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 只要 Refresh Token 有效,就允许刷新(即使 Access Token 已过期)
|
||||
|
||||
const newAuthData = await refreshAccessToken(
|
||||
authInfo.username,
|
||||
authInfo.role,
|
||||
authInfo.tokenId,
|
||||
authInfo.refreshToken,
|
||||
authInfo.refreshExpires
|
||||
);
|
||||
|
||||
if (!newAuthData) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = buildRefreshResponse(newAuthData);
|
||||
const expires = new Date(authInfo.refreshExpires);
|
||||
response.cookies.set('auth', newAuthData, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -147,6 +147,11 @@ async function verifyTurnstileToken(token: string, secretKey: string): Promise<b
|
||||
function getDeviceInfo(request: NextRequest): string {
|
||||
const userAgent = request.headers.get('user-agent') || 'Unknown';
|
||||
|
||||
// 检查是否为 MoonTVPlus APP
|
||||
if (userAgent.toLowerCase().includes('moontvplus')) {
|
||||
return 'MoonTVPlus APP';
|
||||
}
|
||||
|
||||
// 简单解析 User-Agent
|
||||
let browser = 'Unknown Browser';
|
||||
let os = 'Unknown OS';
|
||||
|
||||
@@ -174,13 +174,9 @@ export async function POST(request: NextRequest) {
|
||||
await storage.createMovieRequest(newRequest);
|
||||
await storage.addUserMovieRequest(authInfo.username, newRequest.id);
|
||||
|
||||
// 更新频率限制 - 保存到用户信息的 hash 中
|
||||
if ('client' in storage && storage.client && typeof (storage.client as any).hSet === 'function') {
|
||||
await (storage.client as any).hSet(
|
||||
`user:${authInfo.username}:info`,
|
||||
'last_movie_request_time',
|
||||
Date.now().toString()
|
||||
);
|
||||
// 更新频率限制 - 使用抽象方法兼容所有存储类型
|
||||
if (storage.updateLastMovieRequestTime) {
|
||||
await storage.updateLastMovieRequestTime(authInfo.username, Date.now());
|
||||
|
||||
// 清除用户信息缓存,确保下次读取到最新数据
|
||||
const { userInfoCache } = await import('@/lib/user-cache');
|
||||
|
||||
@@ -42,10 +42,6 @@ export async function GET(request: Request) {
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
headers.set('Content-Length', contentLength);
|
||||
}
|
||||
|
||||
// 使用流式传输,避免占用内存
|
||||
const stream = new ReadableStream({
|
||||
|
||||
@@ -36,6 +36,12 @@ export async function GET(request: NextRequest) {
|
||||
const config = await getConfig();
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
|
||||
// 创建权重映射表
|
||||
const weightMap = new Map<string, number>();
|
||||
config.SourceConfig.forEach(source => {
|
||||
weightMap.set(source.key, source.weight ?? 0);
|
||||
});
|
||||
|
||||
// 检查是否配置了 OpenList
|
||||
const hasOpenList = !!(
|
||||
config.OpenListConfig?.Enabled &&
|
||||
@@ -191,6 +197,14 @@ export async function GET(request: NextRequest) {
|
||||
return !yellowWords.some((word: string) => typeName.includes(word));
|
||||
});
|
||||
}
|
||||
|
||||
// 按权重降序排序
|
||||
flattenedResults.sort((a, b) => {
|
||||
const weightA = weightMap.get(a.source) ?? 0;
|
||||
const weightB = weightMap.get(b.source) ?? 0;
|
||||
return weightB - weightA;
|
||||
});
|
||||
|
||||
const cacheTime = await getCacheTime();
|
||||
|
||||
if (flattenedResults.length === 0) {
|
||||
|
||||
@@ -33,6 +33,19 @@ export async function GET(request: NextRequest) {
|
||||
const config = await getConfig();
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
|
||||
// 创建权重映射表
|
||||
const weightMap = new Map<string, number>();
|
||||
config.SourceConfig.forEach(source => {
|
||||
weightMap.set(source.key, source.weight ?? 0);
|
||||
});
|
||||
|
||||
// 按权重降序排序 apiSites
|
||||
const sortedApiSites = [...apiSites].sort((a, b) => {
|
||||
const weightA = weightMap.get(a.key) ?? 0;
|
||||
const weightB = weightMap.get(b.key) ?? 0;
|
||||
return weightB - weightA;
|
||||
});
|
||||
|
||||
// 检查是否配置了 OpenList
|
||||
const hasOpenList = !!(
|
||||
config.OpenListConfig?.Enabled &&
|
||||
@@ -89,7 +102,7 @@ export async function GET(request: NextRequest) {
|
||||
const startEvent = `data: ${JSON.stringify({
|
||||
type: 'start',
|
||||
query,
|
||||
totalSources: apiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount,
|
||||
totalSources: sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount,
|
||||
timestamp: Date.now()
|
||||
})}\n\n`;
|
||||
|
||||
@@ -294,7 +307,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// 为每个源创建搜索 Promise
|
||||
const searchPromises = apiSites.map(async (site) => {
|
||||
const searchPromises = sortedApiSites.map(async (site) => {
|
||||
try {
|
||||
// 添加超时控制
|
||||
const searchPromise = Promise.race([
|
||||
@@ -363,7 +376,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// 检查是否所有源都已完成
|
||||
if (completedSources === apiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount) {
|
||||
if (completedSources === sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount) {
|
||||
if (!streamClosed) {
|
||||
// 发送最终完成事件
|
||||
const completeEvent = `data: ${JSON.stringify({
|
||||
|
||||
@@ -309,11 +309,6 @@ async function getDoubanBannerContent(): Promise<{ code: number; list: any[] }>
|
||||
|
||||
const detail = await fetchDoubanData<DoubanDetailResponse>(detailUrl);
|
||||
|
||||
// 获取预告片链接(取第一个)- 豆瓣是直链视频URL
|
||||
const trailerUrl = detail.trailers && detail.trailers.length > 0
|
||||
? detail.trailers[0].video_url
|
||||
: null;
|
||||
|
||||
// 获取横屏图片
|
||||
const backdropPath = detail.cover_url || movie.pic?.large || movie.pic?.normal || '';
|
||||
|
||||
@@ -345,7 +340,6 @@ async function getDoubanBannerContent(): Promise<{ code: number; list: any[] }>
|
||||
media_type: 'movie',
|
||||
genre_ids: [],
|
||||
genres: tags, // 使用从card_subtitle提取的标签
|
||||
trailer_url: trailerUrl, // 豆瓣预告片直链
|
||||
video_key: null, // 豆瓣不使用YouTube key
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -377,7 +371,6 @@ async function getDoubanBannerContent(): Promise<{ code: number; list: any[] }>
|
||||
media_type: 'movie',
|
||||
genre_ids: [],
|
||||
genres: tags, // 使用从card_subtitle提取的标签
|
||||
trailer_url: null,
|
||||
video_key: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getAvailableApiSites, getConfig } from '@/lib/config';
|
||||
import { getCachedLiveChannels } from '@/lib/live';
|
||||
|
||||
@@ -36,15 +35,11 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
const username = authInfo?.username;
|
||||
|
||||
// 获取配置
|
||||
const config = await getConfig();
|
||||
|
||||
// 获取视频源
|
||||
const apiSites = await getAvailableApiSites(username);
|
||||
// 获取视频源(TVBox客户端不会发送Cookie,直接获取所有启用的源)
|
||||
const apiSites = await getAvailableApiSites();
|
||||
|
||||
// 获取直播源
|
||||
const liveConfig = config.LiveConfig?.filter(live => !live.disabled) || [];
|
||||
@@ -160,6 +155,18 @@ export async function GET(request: NextRequest) {
|
||||
ads: [],
|
||||
};
|
||||
|
||||
// 获取屏蔽源列表并过滤
|
||||
const blockedSources = process.env.TVBOX_BLOCKED_SOURCES
|
||||
? process.env.TVBOX_BLOCKED_SOURCES.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (blockedSources.length > 0) {
|
||||
tvboxSubscription.sites = tvboxSubscription.sites.filter(
|
||||
site => !blockedSources.includes(site.key)
|
||||
);
|
||||
console.log('TVBOX 订阅已屏蔽源:', blockedSources);
|
||||
}
|
||||
|
||||
return NextResponse.json(tvboxSubscription, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { DownloadPanel } from '../components/DownloadPanel';
|
||||
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
|
||||
import { SiteProvider } from '../components/SiteProvider';
|
||||
import { ThemeProvider } from '../components/ThemeProvider';
|
||||
import { TokenRefreshManager } from '../components/TokenRefreshManager';
|
||||
import TopProgressBar from '../components/TopProgressBar';
|
||||
import ChatFloatingWindow from '../components/watch-room/ChatFloatingWindow';
|
||||
import { WatchRoomProvider } from '../components/WatchRoomProvider';
|
||||
@@ -222,6 +223,7 @@ export default async function RootLayout({
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<TopProgressBar />
|
||||
<TokenRefreshManager />
|
||||
<SiteProvider siteName={siteName} announcement={announcement} tmdbApiKey={tmdbApiKey}>
|
||||
<WatchRoomProvider>
|
||||
<DownloadProvider>
|
||||
|
||||
@@ -20,16 +20,18 @@ import { useLiveSync } from '@/hooks/useLiveSync';
|
||||
import EpgScrollableRow from '@/components/EpgScrollableRow';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
|
||||
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
||||
// 扩展 HTMLVideoElement 类型以支持 hls 和 flv 属性
|
||||
declare global {
|
||||
interface HTMLVideoElement {
|
||||
hls?: any;
|
||||
flv?: any;
|
||||
}
|
||||
}
|
||||
|
||||
// 动态导入浏览器专用库
|
||||
let Artplayer: any = null;
|
||||
let Hls: any = null;
|
||||
let flvjs: any = null;
|
||||
|
||||
// 直播频道接口
|
||||
interface LiveChannel {
|
||||
@@ -59,6 +61,7 @@ function LivePageClient() {
|
||||
if (typeof window !== 'undefined') {
|
||||
import('artplayer').then(mod => { Artplayer = mod.default; });
|
||||
import('hls.js').then(mod => { Hls = mod.default; });
|
||||
import('flv.js').then(mod => { flvjs = mod.default; });
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -1405,12 +1408,45 @@ function LivePageClient() {
|
||||
});
|
||||
}
|
||||
|
||||
function flvLoader(video: HTMLVideoElement, url: string) {
|
||||
if (!flvjs) {
|
||||
console.error('FLV.js 未加载');
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理之前的 FLV 实例
|
||||
if (video.flv) {
|
||||
try {
|
||||
if (video.flv.unload) {
|
||||
video.flv.unload();
|
||||
}
|
||||
video.flv.destroy();
|
||||
video.flv = null;
|
||||
} catch (err) {
|
||||
console.warn('清理 FLV 实例时出错:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const flvPlayer = flvjs.createPlayer({
|
||||
type: 'flv',
|
||||
url,
|
||||
isLive: true
|
||||
});
|
||||
flvPlayer.attachMediaElement(video);
|
||||
flvPlayer.on(flvjs.Events.ERROR, (errorType: string, errorDetail: string) => {
|
||||
console.error('FLV.js error:', errorType, errorDetail);
|
||||
});
|
||||
flvPlayer.load();
|
||||
video.flv = flvPlayer;
|
||||
}
|
||||
|
||||
// 播放器初始化
|
||||
useEffect(() => {
|
||||
const preload = async () => {
|
||||
if (
|
||||
!Artplayer ||
|
||||
!Hls ||
|
||||
!flvjs ||
|
||||
!videoUrl ||
|
||||
!artRef.current ||
|
||||
!currentChannel
|
||||
@@ -1449,8 +1485,8 @@ function LivePageClient() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果不是 m3u8 类型,设置不支持的类型并返回
|
||||
if (type !== 'm3u8') {
|
||||
// 如果不是 m3u8 或 flv 类型,设置不支持的类型并返回
|
||||
if (type !== 'm3u8' && type !== 'flv') {
|
||||
setUnsupportedType(type);
|
||||
setIsVideoLoading(false);
|
||||
return;
|
||||
@@ -1459,8 +1495,10 @@ function LivePageClient() {
|
||||
// 重置不支持的类型
|
||||
setUnsupportedType(null);
|
||||
|
||||
const customType = { m3u8: m3u8Loader };
|
||||
const targetUrl = `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`;
|
||||
const customType = { m3u8: m3u8Loader, flv: flvLoader };
|
||||
const targetUrl = type === 'flv'
|
||||
? videoUrl
|
||||
: `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`;
|
||||
try {
|
||||
// 创建新的播放器实例
|
||||
Artplayer.USE_RAF = true;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, CheckCircle, Eye, EyeOff } from 'lucide-react';
|
||||
import { AlertCircle, CheckCircle, Eye, EyeOff, User, Lock } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
@@ -256,7 +256,7 @@ function LoginPageClient() {
|
||||
<div className='absolute top-4 right-4'>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className='relative z-10 w-full max-w-md rounded-3xl bg-gradient-to-b from-white/90 via-white/70 to-white/40 dark:from-zinc-900/90 dark:via-zinc-900/70 dark:to-zinc-900/40 backdrop-blur-xl shadow-2xl p-10 dark:border dark:border-zinc-800'>
|
||||
<div className='relative z-10 w-full max-w-md rounded-3xl bg-gradient-to-b from-white/90 via-white/70 to-white/40 dark:from-zinc-900/90 dark:via-zinc-900/70 dark:to-zinc-900/40 shadow-2xl p-10 dark:border dark:border-zinc-800'>
|
||||
<h1 className='text-green-600 tracking-tight text-center text-3xl font-extrabold mb-8 bg-clip-text drop-shadow-sm'>
|
||||
{siteName}
|
||||
</h1>
|
||||
@@ -266,15 +266,20 @@ function LoginPageClient() {
|
||||
<label htmlFor='username' className='sr-only'>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id='username'
|
||||
type='text'
|
||||
autoComplete='username'
|
||||
className='block w-full rounded-lg border-0 py-3 px-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
|
||||
placeholder='输入用户名'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<div className='relative'>
|
||||
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
|
||||
<User className='h-5 w-5 text-gray-400 dark:text-gray-500' />
|
||||
</div>
|
||||
<input
|
||||
id='username'
|
||||
type='text'
|
||||
autoComplete='username'
|
||||
className='block w-full rounded-lg border-0 py-3 pl-10 pr-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60'
|
||||
placeholder='输入用户名'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -283,11 +288,14 @@ function LoginPageClient() {
|
||||
密码
|
||||
</label>
|
||||
<div className='relative'>
|
||||
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
|
||||
<Lock className='h-5 w-5 text-gray-400 dark:text-gray-500' />
|
||||
</div>
|
||||
<input
|
||||
id='password'
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete='current-password'
|
||||
className='block w-full rounded-lg border-0 py-3 px-4 pr-12 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
|
||||
className='block w-full rounded-lg border-0 py-3 pl-10 pr-12 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60'
|
||||
placeholder='输入访问密码'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -374,7 +382,7 @@ function LoginPageClient() {
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => window.location.href = '/api/auth/oidc/login'}
|
||||
className='mt-4 w-full inline-flex justify-center items-center rounded-lg border-2 border-gray-300 dark:border-gray-600 bg-white/60 dark:bg-zinc-800/60 backdrop-blur py-3 text-base font-semibold text-gray-700 dark:text-gray-200 shadow-sm transition-all duration-200 hover:bg-gray-50 dark:hover:bg-zinc-700/60'
|
||||
className='mt-4 w-full inline-flex justify-center items-center rounded-lg border-2 border-gray-300 dark:border-gray-600 bg-white/60 dark:bg-zinc-800/60 py-3 text-base font-semibold text-gray-700 dark:text-gray-200 shadow-sm transition-all duration-200 hover:bg-gray-50 dark:hover:bg-zinc-700/60'
|
||||
>
|
||||
<svg className='w-5 h-5 mr-2' fill='currentColor' viewBox='0 0 20 20'>
|
||||
<path fillRule='evenodd' d='M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z' clipRule='evenodd' />
|
||||
|
||||
146
src/app/page.tsx
146
src/app/page.tsx
@@ -2,8 +2,9 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { Bot, ChevronRight, ListVideo } from 'lucide-react';
|
||||
import { Bot, ChevronRight, Link as LinkIcon, ListVideo } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
import { getDoubanCategories } from '@/lib/douban.client';
|
||||
import { getTMDBImageUrl, TMDBItem } from '@/lib/tmdb.client';
|
||||
import { DoubanItem } from '@/lib/types';
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
import { base58Encode, processImageUrl } from '@/lib/utils';
|
||||
|
||||
import AIChatPanel from '@/components/AIChatPanel';
|
||||
import BannerCarousel from '@/components/BannerCarousel';
|
||||
@@ -45,6 +46,7 @@ function HomeClient() {
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { announcement } = useSite();
|
||||
const router = useRouter();
|
||||
|
||||
// 首页模块配置状态
|
||||
const [homeModules, setHomeModules] = useState<HomeModule[]>([
|
||||
@@ -55,6 +57,8 @@ function HomeClient() {
|
||||
{ id: 'hotVarietyShows', name: '热门综艺', enabled: true, order: 4 },
|
||||
{ id: 'upcomingContent', name: '即将上映', enabled: true, order: 5 },
|
||||
]);
|
||||
const [homeBannerEnabled, setHomeBannerEnabled] = useState(true);
|
||||
const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = useState(true);
|
||||
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||
const [showHttpWarning, setShowHttpWarning] = useState(true);
|
||||
@@ -62,34 +66,56 @@ function HomeClient() {
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?');
|
||||
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
|
||||
const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false);
|
||||
const [directPlayUrl, setDirectPlayUrl] = useState('');
|
||||
|
||||
const handleDirectPlay = () => {
|
||||
setDirectPlayUrl('');
|
||||
setShowDirectPlayDialog(true);
|
||||
};
|
||||
|
||||
const submitDirectPlay = () => {
|
||||
const trimmed = directPlayUrl.trim();
|
||||
if (!trimmed) return;
|
||||
const encoded = base58Encode(trimmed);
|
||||
if (!encoded) return;
|
||||
setShowDirectPlayDialog(false);
|
||||
setDirectPlayUrl('');
|
||||
router.push(`/play?source=directplay&id=${encodeURIComponent(encoded)}`);
|
||||
};
|
||||
|
||||
const loadHomeLayoutSettings = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const savedHomeModules = localStorage.getItem('homeModules');
|
||||
if (savedHomeModules) {
|
||||
try {
|
||||
setHomeModules(JSON.parse(savedHomeModules));
|
||||
} catch (error) {
|
||||
console.error('解析首页模块配置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const savedHomeBannerEnabled = localStorage.getItem('homeBannerEnabled');
|
||||
if (savedHomeBannerEnabled !== null) {
|
||||
setHomeBannerEnabled(savedHomeBannerEnabled === 'true');
|
||||
}
|
||||
|
||||
const savedHomeContinueWatchingEnabled = localStorage.getItem('homeContinueWatchingEnabled');
|
||||
if (savedHomeContinueWatchingEnabled !== null) {
|
||||
setHomeContinueWatchingEnabled(savedHomeContinueWatchingEnabled === 'true');
|
||||
}
|
||||
};
|
||||
|
||||
// 加载首页模块配置
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedHomeModules = localStorage.getItem('homeModules');
|
||||
if (savedHomeModules) {
|
||||
try {
|
||||
setHomeModules(JSON.parse(savedHomeModules));
|
||||
} catch (error) {
|
||||
console.error('解析首页模块配置失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
loadHomeLayoutSettings();
|
||||
}, []);
|
||||
|
||||
// 监听首页模块配置更新事件
|
||||
useEffect(() => {
|
||||
const handleHomeModulesUpdated = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedHomeModules = localStorage.getItem('homeModules');
|
||||
if (savedHomeModules) {
|
||||
try {
|
||||
setHomeModules(JSON.parse(savedHomeModules));
|
||||
} catch (error) {
|
||||
console.error('解析首页模块配置失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
loadHomeLayoutSettings();
|
||||
};
|
||||
|
||||
window.addEventListener('homeModulesUpdated', handleHomeModulesUpdated);
|
||||
@@ -547,16 +573,26 @@ function HomeClient() {
|
||||
<PageLayout>
|
||||
<FireworksCanvas />
|
||||
{/* TMDB 热门轮播图 */}
|
||||
<div className='w-full mb-4'>
|
||||
<BannerCarousel />
|
||||
</div>
|
||||
{homeBannerEnabled && (
|
||||
<div className='w-full mb-4'>
|
||||
<BannerCarousel delayLoad={true} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='px-2 sm:px-10 pb-4 sm:pb-8 overflow-visible'>
|
||||
<div className='max-w-[95%] mx-auto'>
|
||||
{/* 首页内容 */}
|
||||
<>
|
||||
{/* 源站寻片和AI问片入口 */}
|
||||
<div className='flex items-center justify-end gap-2 mb-4'>
|
||||
<div className={`flex items-center justify-end gap-2 mb-4 ${homeBannerEnabled ? '' : 'mt-[30px]'}`}>
|
||||
<button
|
||||
onClick={handleDirectPlay}
|
||||
className='p-1.5 rounded-lg text-blue-500 hover:text-blue-600 transition-colors'
|
||||
title='直链播放'
|
||||
>
|
||||
<LinkIcon size={18} />
|
||||
</button>
|
||||
|
||||
{/* 源站寻片入口 */}
|
||||
{sourceSearchEnabled && (
|
||||
<Link href='/source-search'>
|
||||
@@ -582,7 +618,7 @@ function HomeClient() {
|
||||
</div>
|
||||
|
||||
{/* 继续观看 */}
|
||||
<ContinueWatching />
|
||||
{homeContinueWatchingEnabled && <ContinueWatching />}
|
||||
|
||||
{/* 根据配置动态渲染首页模块 */}
|
||||
{homeModules
|
||||
@@ -626,6 +662,62 @@ function HomeClient() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDirectPlayDialog && (
|
||||
<div
|
||||
className='fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4'
|
||||
onClick={() => setShowDirectPlayDialog(false)}
|
||||
>
|
||||
<div
|
||||
className='bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-lg'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
|
||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||
直链播放
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowDirectPlayDialog(false)}
|
||||
className='p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors'
|
||||
aria-label='关闭'
|
||||
>
|
||||
<span className='text-gray-600 dark:text-gray-400'>×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className='p-4 space-y-4'>
|
||||
<div className='text-sm text-gray-600 dark:text-gray-300'>
|
||||
请输入可直接播放的视频链接。
|
||||
</div>
|
||||
<input
|
||||
value={directPlayUrl}
|
||||
onChange={(event) => setDirectPlayUrl(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
submitDirectPlay();
|
||||
}
|
||||
}}
|
||||
placeholder='https://example.com/video.m3u8'
|
||||
className='w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<button
|
||||
onClick={() => setShowDirectPlayDialog(false)}
|
||||
className='px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={submitDirectPlay}
|
||||
disabled={!directPlayUrl.trim()}
|
||||
className='px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
>
|
||||
开始播放
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
import { getDoubanDetail } from '@/lib/douban.client';
|
||||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||
import { DanmakuFilterConfig, EpisodeFilterConfig,SearchResult } from '@/lib/types';
|
||||
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||
import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||
import { useEnableComments } from '@/hooks/useEnableComments';
|
||||
import { usePlaySync } from '@/hooks/usePlaySync';
|
||||
|
||||
@@ -488,6 +488,7 @@ function PlayPageClient() {
|
||||
const [currentSource, setCurrentSource] = useState(searchParams.get('source') || '');
|
||||
const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
|
||||
const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名
|
||||
const isDirectPlay = currentSource === 'directplay';
|
||||
|
||||
// 解析 source 参数以获取 embyKey(仅用于 API 调用)
|
||||
const parseSourceForApi = (source: string): { source: string; embyKey?: string } => {
|
||||
@@ -601,6 +602,10 @@ function PlayPageClient() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDirectPlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否禁用了自动装填弹幕
|
||||
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
||||
if (disableAutoLoad) {
|
||||
@@ -938,11 +943,19 @@ function PlayPageClient() {
|
||||
};
|
||||
|
||||
loadDanmakuForCurrentEpisode();
|
||||
}, [currentEpisodeIndex, videoTitle, loading]);
|
||||
}, [currentEpisodeIndex, videoTitle, loading, isDirectPlay]);
|
||||
|
||||
// 获取豆瓣评分数据
|
||||
useEffect(() => {
|
||||
const fetchDoubanRating = async () => {
|
||||
if (isDirectPlay) {
|
||||
setDoubanRating(null);
|
||||
setDoubanCardSubtitle('');
|
||||
setDoubanAka([]);
|
||||
setDoubanYear('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!videoDoubanId || videoDoubanId === 0) {
|
||||
setDoubanRating(null);
|
||||
setDoubanCardSubtitle('');
|
||||
@@ -994,11 +1007,16 @@ function PlayPageClient() {
|
||||
};
|
||||
|
||||
fetchDoubanRating();
|
||||
}, [videoDoubanId]);
|
||||
}, [videoDoubanId, isDirectPlay]);
|
||||
|
||||
// 获取TMDB背景图
|
||||
useEffect(() => {
|
||||
const fetchTMDBBackdrop = async () => {
|
||||
if (isDirectPlay) {
|
||||
setTmdbBackdrop(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否禁用背景图
|
||||
if (typeof window !== 'undefined') {
|
||||
const disabled = localStorage.getItem('tmdb_backdrop_disabled');
|
||||
@@ -1146,7 +1164,7 @@ function PlayPageClient() {
|
||||
};
|
||||
|
||||
fetchTMDBBackdrop();
|
||||
}, [videoTitle, videoDoubanId]);
|
||||
}, [videoTitle, videoDoubanId, isDirectPlay]);
|
||||
|
||||
|
||||
// 视频播放地址
|
||||
@@ -1168,6 +1186,14 @@ function PlayPageClient() {
|
||||
|
||||
// 总集数
|
||||
const totalEpisodes = detail?.episodes?.length || 0;
|
||||
const directEpisodeLabel = detail?.episodes_titles?.[currentEpisodeIndex] || '直链';
|
||||
const shouldShowEpisodeLabel = totalEpisodes > 1 || isDirectPlay;
|
||||
const episodeLabel = isDirectPlay
|
||||
? directEpisodeLabel
|
||||
: detail?.episodes_titles?.[currentEpisodeIndex] || `第 ${currentEpisodeIndex + 1} 集`;
|
||||
const playerEpisodeLabel = isDirectPlay
|
||||
? directEpisodeLabel
|
||||
: `第${currentEpisodeIndex + 1}集`;
|
||||
|
||||
// 用于记录是否需要在播放器 ready 后跳转到指定进度
|
||||
const resumeTimeRef = useRef<number | null>(null);
|
||||
@@ -1296,6 +1322,22 @@ function PlayPageClient() {
|
||||
): Promise<SearchResult> => {
|
||||
if (sources.length === 1) return sources[0];
|
||||
|
||||
// 获取配置以获取权重信息
|
||||
let weightMap = new Map<string, number>();
|
||||
try {
|
||||
const configResponse = await fetch('/api/admin/config');
|
||||
if (configResponse.ok) {
|
||||
const configData = await configResponse.json();
|
||||
if (configData.Config?.SourceConfig) {
|
||||
configData.Config.SourceConfig.forEach((source: any) => {
|
||||
weightMap.set(source.key, source.weight ?? 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('获取配置失败,权重将使用默认值0:', error);
|
||||
}
|
||||
|
||||
// 将播放源均分为两批,并发测速各批,避免一次性过多请求
|
||||
const batchSize = Math.ceil(sources.length / 2);
|
||||
const allResults: Array<{
|
||||
@@ -1362,9 +1404,15 @@ function PlayPageClient() {
|
||||
|
||||
setPrecomputedVideoInfo(newVideoInfoMap);
|
||||
|
||||
// 如果所有测速都失败,仍然按权重排序返回
|
||||
if (successfulResults.length === 0) {
|
||||
console.warn('所有播放源测速都失败,使用第一个播放源');
|
||||
return sources[0];
|
||||
console.warn('所有播放源测速都失败,按权重排序');
|
||||
const sortedByWeight = [...sources].sort((a, b) => {
|
||||
const weightA = weightMap.get(a.source) ?? 0;
|
||||
const weightB = weightMap.get(b.source) ?? 0;
|
||||
return weightB - weightA;
|
||||
});
|
||||
return sortedByWeight[0];
|
||||
}
|
||||
|
||||
// 找出所有有效速度的最大值,用于线性映射
|
||||
@@ -1399,7 +1447,8 @@ function PlayPageClient() {
|
||||
result.testResult,
|
||||
maxSpeed,
|
||||
minPing,
|
||||
maxPing
|
||||
maxPing,
|
||||
weightMap.get(result.source.source) ?? 0
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -1429,7 +1478,8 @@ function PlayPageClient() {
|
||||
},
|
||||
maxSpeed: number,
|
||||
minPing: number,
|
||||
maxPing: number
|
||||
maxPing: number,
|
||||
weight: number = 0
|
||||
): number => {
|
||||
let score = 0;
|
||||
|
||||
@@ -1487,6 +1537,9 @@ function PlayPageClient() {
|
||||
})();
|
||||
score += pingScore * 0.2;
|
||||
|
||||
// 权重加分 - 直接加到总分上(0-100分)
|
||||
score += weight;
|
||||
|
||||
return Math.round(score * 100) / 100; // 保留两位小数
|
||||
};
|
||||
|
||||
@@ -2808,6 +2861,73 @@ function PlayPageClient() {
|
||||
};
|
||||
|
||||
const initAll = async () => {
|
||||
if (currentSource === 'directplay') {
|
||||
if (!currentId) {
|
||||
setError('缺少直链地址');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setLoadingStage('fetching');
|
||||
setLoadingMessage('🎬 正在准备直链播放...');
|
||||
|
||||
let directUrl = '';
|
||||
try {
|
||||
directUrl = base58Decode(currentId);
|
||||
} catch (decodeError) {
|
||||
console.error('直链地址解析失败:', decodeError);
|
||||
setError('直链地址解析失败');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const directDetail: SearchResult = {
|
||||
id: currentId,
|
||||
title: '直链播放',
|
||||
poster: '',
|
||||
episodes: [directUrl],
|
||||
episodes_titles: ['直链'],
|
||||
source: 'directplay',
|
||||
source_name: '直链',
|
||||
class: '',
|
||||
year: '',
|
||||
desc: '',
|
||||
type_name: '',
|
||||
douban_id: 0,
|
||||
};
|
||||
|
||||
setNeedPrefer(false);
|
||||
setCurrentSource('directplay');
|
||||
setCurrentId(currentId);
|
||||
setVideoTitle('直链播放');
|
||||
setVideoYear('');
|
||||
setVideoCover('');
|
||||
setVideoDoubanId(0);
|
||||
setCorrectedDesc('');
|
||||
setDetail(directDetail);
|
||||
setSourceProxyMode(false);
|
||||
setAvailableSources([directDetail]);
|
||||
setCurrentEpisodeIndex(0);
|
||||
setSourceSearchError(null);
|
||||
setSourceSearchLoading(false);
|
||||
setBackgroundSourcesLoading(false);
|
||||
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.set('source', 'directplay');
|
||||
newUrl.searchParams.set('id', currentId);
|
||||
newUrl.searchParams.delete('prefer');
|
||||
newUrl.searchParams.delete('fileName');
|
||||
window.history.replaceState({}, '', newUrl.toString());
|
||||
|
||||
setLoadingStage('ready');
|
||||
setLoadingMessage('✨ 准备就绪,即将开始播放...');
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSource && !currentId && !videoTitle && !searchTitle) {
|
||||
setError('缺少必要参数');
|
||||
setLoading(false);
|
||||
@@ -3698,6 +3818,7 @@ function PlayPageClient() {
|
||||
// 预加载下一集弹幕(完全复制 loadDanmakuForCurrentEpisode 的逻辑)
|
||||
const preloadNextEpisodeDanmaku = async () => {
|
||||
try {
|
||||
if (isDirectPlay) return;
|
||||
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
||||
if (disableAutoLoad) return;
|
||||
|
||||
@@ -4043,6 +4164,7 @@ function PlayPageClient() {
|
||||
|
||||
// 自动搜索并加载弹幕
|
||||
const autoSearchDanmaku = async () => {
|
||||
if (isDirectPlay) return;
|
||||
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
||||
if (disableAutoLoad) return;
|
||||
|
||||
@@ -4616,9 +4738,7 @@ function PlayPageClient() {
|
||||
// 非WebKit浏览器且播放器已存在,使用switch方法切换
|
||||
if (!isWebkit && artPlayerRef.current) {
|
||||
artPlayerRef.current.switch = videoUrl;
|
||||
artPlayerRef.current.title = `${videoTitle} - 第${
|
||||
currentEpisodeIndex + 1
|
||||
}集`;
|
||||
artPlayerRef.current.title = `${videoTitle} - ${playerEpisodeLabel}`;
|
||||
artPlayerRef.current.poster = videoCover;
|
||||
if (artPlayerRef.current?.video) {
|
||||
ensureVideoSource(
|
||||
@@ -6993,12 +7113,9 @@ function PlayPageClient() {
|
||||
<h1 className={`text-xl font-semibold flex items-center gap-2 flex-wrap ${tmdbBackdrop ? 'text-white' : 'text-gray-900 dark:text-gray-100'}`}>
|
||||
<span>
|
||||
{videoTitle || '影片标题'}
|
||||
{totalEpisodes > 1 && (
|
||||
{shouldShowEpisodeLabel && (
|
||||
<span className={tmdbBackdrop ? 'text-white opacity-80' : 'text-gray-500 dark:text-gray-400'}>
|
||||
{` > ${
|
||||
detail?.episodes_titles?.[currentEpisodeIndex] ||
|
||||
`第 ${currentEpisodeIndex + 1} 集`
|
||||
}`}
|
||||
{` > ${episodeLabel}`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -7302,6 +7419,37 @@ function PlayPageClient() {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* App打开 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// 获取当前浏览器URL去除域名部分,并去除开头的/
|
||||
const currentPath = (window.location.pathname + window.location.search).replace(/^\//, '');
|
||||
// 打开moontvplus协议
|
||||
window.open(`moontvplus://${currentPath}`, '_blank');
|
||||
}}
|
||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-blue-600 dark:border-blue-700 flex-shrink-0'
|
||||
title='App打开'
|
||||
>
|
||||
<svg
|
||||
className='w-4 h-4 flex-shrink-0 text-white'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z'
|
||||
/>
|
||||
</svg>
|
||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-white'>
|
||||
App打开
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* PotPlayer */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -7568,254 +7716,258 @@ function PlayPageClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详情展示 */}
|
||||
<div className='grid grid-cols-1 md:grid-cols-5 lg:grid-cols-6 gap-4'>
|
||||
{/* 文字区 */}
|
||||
<div className='md:col-span-4 lg:col-span-5'>
|
||||
<div className='p-6 flex flex-col min-h-0'>
|
||||
{/* 标题 */}
|
||||
<h1 className={`text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full flex-wrap gap-2 ${tmdbBackdrop ? 'text-white' : 'text-gray-900 dark:text-gray-100'}`}>
|
||||
<span className={doubanAka.length > 0 ? 'relative group cursor-help' : ''}>
|
||||
{videoTitle || '影片标题'}
|
||||
{/* aka 悬浮提示 */}
|
||||
{doubanAka.length > 0 && (
|
||||
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-sm rounded-lg shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 ease-out whitespace-nowrap z-[100] pointer-events-none'>
|
||||
<div className='font-semibold text-xs text-gray-400 mb-1'>又名:</div>
|
||||
{doubanAka.map((name, index) => (
|
||||
<div key={index} className='text-sm'>
|
||||
{name}
|
||||
{!isDirectPlay && (
|
||||
<>
|
||||
{/* 详情展示 */}
|
||||
<div className='grid grid-cols-1 md:grid-cols-5 lg:grid-cols-6 gap-4'>
|
||||
{/* 文字区 */}
|
||||
<div className='md:col-span-4 lg:col-span-5'>
|
||||
<div className='p-6 flex flex-col min-h-0'>
|
||||
{/* 标题 */}
|
||||
<h1 className={`text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full flex-wrap gap-2 ${tmdbBackdrop ? 'text-white' : 'text-gray-900 dark:text-gray-100'}`}>
|
||||
<span className={doubanAka.length > 0 ? 'relative group cursor-help' : ''}>
|
||||
{videoTitle || '影片标题'}
|
||||
{/* aka 悬浮提示 */}
|
||||
{doubanAka.length > 0 && (
|
||||
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-sm rounded-lg shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 ease-out whitespace-nowrap z-[100] pointer-events-none'>
|
||||
<div className='font-semibold text-xs text-gray-400 mb-1'>又名:</div>
|
||||
{doubanAka.map((name, index) => (
|
||||
<div key={index} className='text-sm'>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800 dark:border-t-gray-900'></div>
|
||||
</div>
|
||||
))}
|
||||
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800 dark:border-t-gray-900'></div>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleFavorite();
|
||||
}}
|
||||
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||
>
|
||||
<FavoriteIcon filled={favorited} />
|
||||
</button>
|
||||
{/* 网盘搜索按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPansouDialog(true);
|
||||
}}
|
||||
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||
title='搜索网盘资源'
|
||||
>
|
||||
<Cloud className='h-6 w-6 text-gray-700 dark:text-gray-300' />
|
||||
</button>
|
||||
{/* AI问片按钮 */}
|
||||
{aiEnabled && detail && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowAIChat(true);
|
||||
}}
|
||||
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||
title='AI问片'
|
||||
>
|
||||
<Sparkles className='h-6 w-6 text-gray-700 dark:text-gray-300' />
|
||||
</button>
|
||||
)}
|
||||
{/* 纠错按钮 - 仅小雅源显示 */}
|
||||
{detail && detail.source === 'xiaoya' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowCorrectDialog(true);
|
||||
}}
|
||||
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||
title='纠错'
|
||||
>
|
||||
<AlertCircle className='h-6 w-6 text-gray-700 dark:text-gray-300' />
|
||||
</button>
|
||||
)}
|
||||
{/* 豆瓣评分显示 */}
|
||||
{doubanRating && doubanRating.value > 0 && (
|
||||
<div className='flex items-center gap-2 text-base font-normal'>
|
||||
{/* 星级显示 */}
|
||||
<div className='flex items-center gap-1'>
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const starValue = doubanRating.value / 2; // 转换为5星制
|
||||
const isFullStar = star <= Math.floor(starValue);
|
||||
const isHalfStar = !isFullStar && star <= Math.ceil(starValue) && starValue % 1 >= 0.25;
|
||||
|
||||
return (
|
||||
<div key={star} className='relative w-5 h-5'>
|
||||
{isFullStar ? (
|
||||
// 全星
|
||||
<svg
|
||||
className='w-5 h-5 text-yellow-400 fill-yellow-400'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
|
||||
</svg>
|
||||
) : isHalfStar ? (
|
||||
// 半星
|
||||
<>
|
||||
{/* 空星背景 */}
|
||||
<svg
|
||||
className='absolute w-5 h-5 text-gray-300 dark:text-gray-600 fill-gray-300 dark:fill-gray-600'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
|
||||
</svg>
|
||||
{/* 半星遮罩 */}
|
||||
<svg
|
||||
className='absolute w-5 h-5 text-yellow-400 fill-yellow-400'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
style={{ clipPath: 'inset(0 50% 0 0)' }}
|
||||
>
|
||||
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
|
||||
</svg>
|
||||
</>
|
||||
) : (
|
||||
// 空星
|
||||
<svg
|
||||
className='w-5 h-5 text-gray-300 dark:text-gray-600 fill-gray-300 dark:fill-gray-600'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 评分数值 */}
|
||||
<span className='text-gray-700 dark:text-gray-300 font-semibold'>
|
||||
{doubanRating.value.toFixed(1)}
|
||||
)}
|
||||
</span>
|
||||
{/* 评分人数 */}
|
||||
<span className='text-gray-500 dark:text-gray-400 text-sm'>
|
||||
({doubanRating.count.toLocaleString()}人评价)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</h1>
|
||||
|
||||
{/* 关键信息行 */}
|
||||
<div className={`flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0 ${tmdbBackdrop ? 'text-white' : ''}`}>
|
||||
{detail?.class && (
|
||||
<span className='text-green-600 font-semibold'>
|
||||
{detail.class}
|
||||
</span>
|
||||
)}
|
||||
{/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */}
|
||||
{(doubanYear || detail?.year || videoYear) && (
|
||||
<span>{doubanYear || detail?.year || videoYear}</span>
|
||||
)}
|
||||
{detail?.source_name && (
|
||||
<span className={`border px-2 py-[1px] rounded ${
|
||||
detail.source === 'xiaoya' ? 'border-blue-500' : detail.source === 'openlist' || detail.source === 'emby' || detail.source?.startsWith('emby_') ? 'border-yellow-500' : 'border-gray-500/60'
|
||||
}`}>
|
||||
{detail.source_name}
|
||||
</span>
|
||||
)}
|
||||
{detail?.type_name && <span>{detail.type_name}</span>}
|
||||
</div>
|
||||
{/* 剧情简介 */}
|
||||
{(doubanCardSubtitle || correctedDesc || detail?.desc) && (
|
||||
<div
|
||||
className={`mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide ${tmdbBackdrop ? 'text-white' : ''}`}
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{/* card_subtitle 在前,desc 在后 */}
|
||||
{doubanCardSubtitle && (
|
||||
<div className='mb-3 pb-3 border-b border-gray-300 dark:border-gray-700'>
|
||||
{doubanCardSubtitle}
|
||||
</div>
|
||||
)}
|
||||
{correctedDesc || detail?.desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 封面展示 */}
|
||||
<div className='hidden md:block md:col-span-1 md:order-first'>
|
||||
<div className='pl-0 py-4 pr-6 max-w-sm mx-auto'>
|
||||
<div className='relative bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
|
||||
{videoCover ? (
|
||||
<>
|
||||
<img
|
||||
src={processImageUrl(videoCover)}
|
||||
alt={videoTitle}
|
||||
className='w-full h-full object-cover'
|
||||
/>
|
||||
|
||||
{/* 豆瓣链接按钮 */}
|
||||
{videoDoubanId !== 0 && (
|
||||
<a
|
||||
href={`https://movie.douban.com/subject/${videoDoubanId.toString()}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='absolute top-3 left-3'
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleFavorite();
|
||||
}}
|
||||
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||
>
|
||||
<FavoriteIcon filled={favorited} />
|
||||
</button>
|
||||
{/* 网盘搜索按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPansouDialog(true);
|
||||
}}
|
||||
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||
title='搜索网盘资源'
|
||||
>
|
||||
<Cloud className='h-6 w-6 text-gray-700 dark:text-gray-300' />
|
||||
</button>
|
||||
{/* AI问片按钮 */}
|
||||
{aiEnabled && detail && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowAIChat(true);
|
||||
}}
|
||||
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||
title='AI问片'
|
||||
>
|
||||
<div className='bg-green-500 text-white text-xs font-bold w-8 h-8 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
|
||||
<svg
|
||||
width='16'
|
||||
height='16'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<path d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'></path>
|
||||
<path d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
<Sparkles className='h-6 w-6 text-gray-700 dark:text-gray-300' />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className='text-gray-600 dark:text-gray-400'>
|
||||
封面图片
|
||||
</span>
|
||||
)}
|
||||
{/* 纠错按钮 - 仅小雅源显示 */}
|
||||
{detail && detail.source === 'xiaoya' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowCorrectDialog(true);
|
||||
}}
|
||||
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||
title='纠错'
|
||||
>
|
||||
<AlertCircle className='h-6 w-6 text-gray-700 dark:text-gray-300' />
|
||||
</button>
|
||||
)}
|
||||
{/* 豆瓣评分显示 */}
|
||||
{doubanRating && doubanRating.value > 0 && (
|
||||
<div className='flex items-center gap-2 text-base font-normal'>
|
||||
{/* 星级显示 */}
|
||||
<div className='flex items-center gap-1'>
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const starValue = doubanRating.value / 2; // 转换为5星制
|
||||
const isFullStar = star <= Math.floor(starValue);
|
||||
const isHalfStar = !isFullStar && star <= Math.ceil(starValue) && starValue % 1 >= 0.25;
|
||||
|
||||
return (
|
||||
<div key={star} className='relative w-5 h-5'>
|
||||
{isFullStar ? (
|
||||
// 全星
|
||||
<svg
|
||||
className='w-5 h-5 text-yellow-400 fill-yellow-400'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
|
||||
</svg>
|
||||
) : isHalfStar ? (
|
||||
// 半星
|
||||
<>
|
||||
{/* 空星背景 */}
|
||||
<svg
|
||||
className='absolute w-5 h-5 text-gray-300 dark:text-gray-600 fill-gray-300 dark:fill-gray-600'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
|
||||
</svg>
|
||||
{/* 半星遮罩 */}
|
||||
<svg
|
||||
className='absolute w-5 h-5 text-yellow-400 fill-yellow-400'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
style={{ clipPath: 'inset(0 50% 0 0)' }}
|
||||
>
|
||||
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
|
||||
</svg>
|
||||
</>
|
||||
) : (
|
||||
// 空星
|
||||
<svg
|
||||
className='w-5 h-5 text-gray-300 dark:text-gray-600 fill-gray-300 dark:fill-gray-600'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 评分数值 */}
|
||||
<span className='text-gray-700 dark:text-gray-300 font-semibold'>
|
||||
{doubanRating.value.toFixed(1)}
|
||||
</span>
|
||||
{/* 评分人数 */}
|
||||
<span className='text-gray-500 dark:text-gray-400 text-sm'>
|
||||
({doubanRating.count.toLocaleString()}人评价)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</h1>
|
||||
|
||||
{/* 关键信息行 */}
|
||||
<div className={`flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0 ${tmdbBackdrop ? 'text-white' : ''}`}>
|
||||
{detail?.class && (
|
||||
<span className='text-green-600 font-semibold'>
|
||||
{detail.class}
|
||||
</span>
|
||||
)}
|
||||
{/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */}
|
||||
{(doubanYear || detail?.year || videoYear) && (
|
||||
<span>{doubanYear || detail?.year || videoYear}</span>
|
||||
)}
|
||||
{detail?.source_name && (
|
||||
<span className={`border px-2 py-[1px] rounded ${
|
||||
detail.source === 'xiaoya' ? 'border-blue-500' : detail.source === 'openlist' || detail.source === 'emby' || detail.source?.startsWith('emby_') ? 'border-yellow-500' : 'border-gray-500/60'
|
||||
}`}>
|
||||
{detail.source_name}
|
||||
</span>
|
||||
)}
|
||||
{detail?.type_name && <span>{detail.type_name}</span>}
|
||||
</div>
|
||||
{/* 剧情简介 */}
|
||||
{(doubanCardSubtitle || correctedDesc || detail?.desc) && (
|
||||
<div
|
||||
className={`mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide ${tmdbBackdrop ? 'text-white' : ''}`}
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{/* card_subtitle 在前,desc 在后 */}
|
||||
{doubanCardSubtitle && (
|
||||
<div className='mb-3 pb-3 border-b border-gray-300 dark:border-gray-700'>
|
||||
{doubanCardSubtitle}
|
||||
</div>
|
||||
)}
|
||||
{correctedDesc || detail?.desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 封面展示 */}
|
||||
<div className='hidden md:block md:col-span-1 md:order-first'>
|
||||
<div className='pl-0 py-4 pr-6 max-w-sm mx-auto'>
|
||||
<div className='relative bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
|
||||
{videoCover ? (
|
||||
<>
|
||||
<img
|
||||
src={processImageUrl(videoCover)}
|
||||
alt={videoTitle}
|
||||
className='w-full h-full object-cover'
|
||||
/>
|
||||
|
||||
{/* 豆瓣链接按钮 */}
|
||||
{videoDoubanId !== 0 && (
|
||||
<a
|
||||
href={`https://movie.douban.com/subject/${videoDoubanId.toString()}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='absolute top-3 left-3'
|
||||
>
|
||||
<div className='bg-green-500 text-white text-xs font-bold w-8 h-8 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
|
||||
<svg
|
||||
width='16'
|
||||
height='16'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<path d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'></path>
|
||||
<path d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className='text-gray-600 dark:text-gray-400'>
|
||||
封面图片
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 推荐区域 */}
|
||||
<SmartRecommendations
|
||||
doubanId={videoDoubanId !== 0 ? videoDoubanId : undefined}
|
||||
videoTitle={videoTitle}
|
||||
/>
|
||||
{/* 推荐区域 */}
|
||||
<SmartRecommendations
|
||||
doubanId={videoDoubanId !== 0 ? videoDoubanId : undefined}
|
||||
videoTitle={videoTitle}
|
||||
/>
|
||||
|
||||
{/* 豆瓣评论区域 */}
|
||||
{videoDoubanId !== 0 && enableComments && (
|
||||
<div className='mt-6 -mx-3 md:mx-0 md:px-4'>
|
||||
<div className='bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50 overflow-hidden'>
|
||||
{/* 标题 */}
|
||||
<div className='px-3 md:px-6 py-4 border-b border-gray-200 dark:border-gray-700'>
|
||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2'>
|
||||
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 24 24'>
|
||||
<path d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z'/>
|
||||
</svg>
|
||||
豆瓣评论
|
||||
</h3>
|
||||
{/* 豆瓣评论区域 */}
|
||||
{videoDoubanId !== 0 && enableComments && (
|
||||
<div className='mt-6 -mx-3 md:mx-0 md:px-4'>
|
||||
<div className='bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50 overflow-hidden'>
|
||||
{/* 标题 */}
|
||||
<div className='px-3 md:px-6 py-4 border-b border-gray-200 dark:border-gray-700'>
|
||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2'>
|
||||
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 24 24'>
|
||||
<path d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z'/>
|
||||
</svg>
|
||||
豆瓣评论
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 评论内容 */}
|
||||
<div className='p-3 md:p-6'>
|
||||
<DoubanComments doubanId={videoDoubanId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 评论内容 */}
|
||||
<div className='p-3 md:p-6'>
|
||||
<DoubanComments doubanId={videoDoubanId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, CheckCircle, Eye, EyeOff } from 'lucide-react';
|
||||
import { AlertCircle, CheckCircle, Eye, EyeOff, User, Lock } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
@@ -250,7 +250,7 @@ function RegisterPageClient() {
|
||||
<div className='absolute top-4 right-4'>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className='relative z-10 w-full max-w-md rounded-3xl bg-gradient-to-b from-white/90 via-white/70 to-white/40 dark:from-zinc-900/90 dark:via-zinc-900/70 dark:to-zinc-900/40 backdrop-blur-xl shadow-2xl p-10 dark:border dark:border-zinc-800'>
|
||||
<div className='relative z-10 w-full max-w-md rounded-3xl bg-gradient-to-b from-white/90 via-white/70 to-white/40 dark:from-zinc-900/90 dark:via-zinc-900/70 dark:to-zinc-900/40 shadow-2xl p-10 dark:border dark:border-zinc-800'>
|
||||
<h1 className='text-green-600 tracking-tight text-center text-3xl font-extrabold mb-2 bg-clip-text drop-shadow-sm'>
|
||||
{siteName}
|
||||
</h1>
|
||||
@@ -262,15 +262,20 @@ function RegisterPageClient() {
|
||||
<label htmlFor='username' className='sr-only'>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id='username'
|
||||
type='text'
|
||||
autoComplete='username'
|
||||
className='block w-full rounded-lg border-0 py-3 px-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
|
||||
placeholder='输入用户名'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<div className='relative'>
|
||||
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
|
||||
<User className='h-5 w-5 text-gray-400 dark:text-gray-500' />
|
||||
</div>
|
||||
<input
|
||||
id='username'
|
||||
type='text'
|
||||
autoComplete='username'
|
||||
className='block w-full rounded-lg border-0 py-3 pl-10 pr-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60'
|
||||
placeholder='输入用户名'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -278,11 +283,14 @@ function RegisterPageClient() {
|
||||
密码
|
||||
</label>
|
||||
<div className='relative'>
|
||||
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
|
||||
<Lock className='h-5 w-5 text-gray-400 dark:text-gray-500' />
|
||||
</div>
|
||||
<input
|
||||
id='password'
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete='new-password'
|
||||
className='block w-full rounded-lg border-0 py-3 px-4 pr-12 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
|
||||
className='block w-full rounded-lg border-0 py-3 pl-10 pr-12 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60'
|
||||
placeholder='输入密码(至少6位)'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -306,11 +314,14 @@ function RegisterPageClient() {
|
||||
确认密码
|
||||
</label>
|
||||
<div className='relative'>
|
||||
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
|
||||
<Lock className='h-5 w-5 text-gray-400 dark:text-gray-500' />
|
||||
</div>
|
||||
<input
|
||||
id='confirmPassword'
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoComplete='new-password'
|
||||
className='block w-full rounded-lg border-0 py-3 px-4 pr-12 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
|
||||
className='block w-full rounded-lg border-0 py-3 pl-10 pr-12 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60'
|
||||
placeholder='再次输入密码'
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft, ChevronRight, Play } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Play, Volume2, VolumeX } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useRef,useState } from 'react';
|
||||
|
||||
import { type TMDBItem,getGenreNames, getTMDBImageUrl } from '@/lib/tmdb.client';
|
||||
import { getDoubanDetail } from '@/lib/douban.client';
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
interface BannerCarouselProps {
|
||||
autoPlayInterval?: number; // 自动播放间隔(毫秒)
|
||||
delayLoad?: boolean; // 是否延迟加载(等页面加载完毕后再加载)
|
||||
}
|
||||
|
||||
// 扩展TMDBItem类型以支持TX数据源的额外字段
|
||||
@@ -20,16 +22,21 @@ interface BannerItem extends TMDBItem {
|
||||
genres?: string[]; // 豆瓣数据源的类型标签
|
||||
}
|
||||
|
||||
export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarouselProps) {
|
||||
export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = false }: BannerCarouselProps) {
|
||||
const router = useRouter();
|
||||
const [items, setItems] = useState<BannerItem[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [shouldLoad, setShouldLoad] = useState(!delayLoad); // 是否应该开始加载数据
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [skipNextAutoPlay, setSkipNextAutoPlay] = useState(false); // 跳过下一次自动播放
|
||||
const [isYouTubeAccessible, setIsYouTubeAccessible] = useState(false); // YouTube连通性(默认false,检查后再决定)
|
||||
const [enableTrailers, setEnableTrailers] = useState(false); // 是否启用预告片(默认关闭)
|
||||
const [dataSource, setDataSource] = useState<string>(''); // 当前数据源
|
||||
const [trailersLoaded, setTrailersLoaded] = useState(false); // 预告片是否已加载
|
||||
const [isMuted, setIsMuted] = useState(true); // 视频是否静音(默认静音)
|
||||
const videoRef = useRef<HTMLVideoElement>(null); // 视频元素引用
|
||||
const videoRefs = useRef<Map<number, HTMLVideoElement>>(new Map()); // 所有视频元素的引用
|
||||
const touchStartX = useRef(0);
|
||||
const touchEndX = useRef(0);
|
||||
const isManualChange = useRef(false); // 标记是否为手动切换
|
||||
@@ -47,6 +54,19 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous
|
||||
router.push(`/play?title=${encodeURIComponent(title)}`);
|
||||
};
|
||||
|
||||
// 切换音量
|
||||
const toggleMute = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newMutedState = !isMuted;
|
||||
setIsMuted(newMutedState);
|
||||
|
||||
// 直接更新当前视频元素的静音状态
|
||||
const currentVideo = videoRefs.current.get(currentIndex);
|
||||
if (currentVideo) {
|
||||
currentVideo.muted = newMutedState;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取图片URL(处理TX完整URL和TMDB路径)
|
||||
const getImageUrl = (path: string | null) => {
|
||||
if (!path) return '';
|
||||
@@ -76,6 +96,22 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 延迟加载:等待页面加载完毕后再开始加载轮播图数据
|
||||
useEffect(() => {
|
||||
if (!delayLoad) return;
|
||||
|
||||
// 页面加载完毕后再开始加载
|
||||
if (document.readyState === 'complete') {
|
||||
setShouldLoad(true);
|
||||
} else {
|
||||
const handleLoad = () => {
|
||||
setShouldLoad(true);
|
||||
};
|
||||
window.addEventListener('load', handleLoad);
|
||||
return () => window.removeEventListener('load', handleLoad);
|
||||
}
|
||||
}, [delayLoad]);
|
||||
|
||||
// 检测YouTube连通性 - 仅在启用预告片且数据源为TMDB时检测
|
||||
useEffect(() => {
|
||||
// 如果未启用预告片或数据源不是TMDB,不进行检测
|
||||
@@ -110,13 +146,19 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous
|
||||
|
||||
// 获取热门内容
|
||||
useEffect(() => {
|
||||
// 如果不应该加载,直接返回
|
||||
if (!shouldLoad) return;
|
||||
|
||||
const fetchTrending = async () => {
|
||||
try {
|
||||
// 先尝试从所有可能的数据源缓存中读取
|
||||
// 先尝试从所有可能的数据源缓存中读取,找到最新的缓存
|
||||
const sources = ['TMDB', 'TX', 'Douban'];
|
||||
let cachedData = null;
|
||||
let validSource = null;
|
||||
let cacheExpired = false;
|
||||
let latestTimestamp = 0;
|
||||
|
||||
// 遍历所有数据源,找到最新的缓存
|
||||
for (const source of sources) {
|
||||
const cacheKey = getLocalStorageKey(source);
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
@@ -124,13 +166,13 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous
|
||||
if (cached) {
|
||||
try {
|
||||
const { data, timestamp } = JSON.parse(cached);
|
||||
const now = Date.now();
|
||||
|
||||
// 如果缓存未过期,使用缓存数据
|
||||
if (now - timestamp < LOCALSTORAGE_DURATION) {
|
||||
// 选择时间戳最新的缓存
|
||||
if (timestamp > latestTimestamp) {
|
||||
cachedData = data;
|
||||
validSource = source;
|
||||
break;
|
||||
latestTimestamp = timestamp;
|
||||
cacheExpired = Date.now() - timestamp > LOCALSTORAGE_DURATION;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析缓存数据失败:', e);
|
||||
@@ -138,34 +180,37 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有有效的缓存,直接使用,不请求API
|
||||
// 乐观缓存:如果有缓存(无论是否过期),先显示缓存数据
|
||||
if (cachedData) {
|
||||
setItems(cachedData);
|
||||
setDataSource(validSource || ''); // 设置数据源
|
||||
setIsLoading(false);
|
||||
return;
|
||||
setTrailersLoaded(false); // 重置预告片加载状态
|
||||
}
|
||||
|
||||
// 没有缓存或缓存过期,从 API 获取数据
|
||||
const response = await fetch('/api/tmdb/trending');
|
||||
const result = await response.json();
|
||||
// 如果缓存过期或没有缓存,后台更新数据
|
||||
if (!cachedData || cacheExpired) {
|
||||
const response = await fetch('/api/tmdb/trending');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 200 && result.list.length > 0) {
|
||||
const dataSource = result.source || 'TMDB'; // 获取数据源标识
|
||||
const cacheKey = getLocalStorageKey(dataSource);
|
||||
if (result.code === 200 && result.list.length > 0) {
|
||||
const newDataSource = result.source || 'TMDB'; // 获取数据源标识
|
||||
const cacheKey = getLocalStorageKey(newDataSource);
|
||||
|
||||
setItems(result.list);
|
||||
setDataSource(dataSource); // 设置数据源
|
||||
setItems(result.list);
|
||||
setDataSource(newDataSource); // 设置数据源
|
||||
setTrailersLoaded(false); // 重置预告片加载状态
|
||||
|
||||
// 保存到 localStorage(使用数据源特定的key)
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify({
|
||||
data: result.list,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
} catch (e) {
|
||||
// localStorage 可能已满,忽略错误
|
||||
console.error('保存到 localStorage 失败:', e);
|
||||
// 保存到 localStorage(使用数据源特定的key)
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify({
|
||||
data: result.list,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
} catch (e) {
|
||||
// localStorage 可能已满,忽略错误
|
||||
console.error('保存到 localStorage 失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -176,7 +221,71 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous
|
||||
};
|
||||
|
||||
fetchTrending();
|
||||
}, []);
|
||||
}, [shouldLoad]);
|
||||
|
||||
// 前端获取豆瓣预告片
|
||||
useEffect(() => {
|
||||
// 只有在启用预告片、数据源是豆瓣、有数据且未加载预告片时才执行
|
||||
if (!enableTrailers || dataSource !== 'Douban' || items.length === 0 || trailersLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchDoubanTrailers = async () => {
|
||||
try {
|
||||
// 为每个项目获取预告片
|
||||
const itemsWithTrailers = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
try {
|
||||
// 使用统一的豆瓣详情获取函数(会根据用户配置的代理设置自动选择请求方式)
|
||||
const detail = await getDoubanDetail(item.id.toString());
|
||||
|
||||
// 获取预告片链接(取第一个)
|
||||
const trailerUrl = detail.trailers && detail.trailers.length > 0
|
||||
? detail.trailers[0].video_url
|
||||
: null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
trailer_url: trailerUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`获取豆瓣电影 ${item.id} 预告片失败:`, error);
|
||||
return item;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setItems(itemsWithTrailers);
|
||||
setTrailersLoaded(true);
|
||||
} catch (error) {
|
||||
console.error('获取豆瓣预告片失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDoubanTrailers();
|
||||
}, [enableTrailers, dataSource, items.length, trailersLoaded]);
|
||||
|
||||
// 切换轮播图时重置静音状态
|
||||
useEffect(() => {
|
||||
setIsMuted(true);
|
||||
}, [currentIndex]);
|
||||
|
||||
// 控制视频播放/暂停和静音状态
|
||||
useEffect(() => {
|
||||
// 遍历所有视频元素
|
||||
videoRefs.current.forEach((video, index) => {
|
||||
if (index === currentIndex) {
|
||||
// 当前显示的视频:播放并设置静音状态
|
||||
video.muted = isMuted;
|
||||
video.play().catch(() => {
|
||||
// 忽略自动播放失败的错误
|
||||
});
|
||||
} else {
|
||||
// 非当前显示的视频:暂停
|
||||
video.pause();
|
||||
}
|
||||
});
|
||||
}, [currentIndex, isMuted]);
|
||||
|
||||
// 自动播放
|
||||
useEffect(() => {
|
||||
@@ -262,12 +371,17 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous
|
||||
touchEndX.current = 0;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading || !shouldLoad) {
|
||||
return (
|
||||
<div className="relative w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] bg-gradient-to-b from-gray-800 to-gray-900 overflow-hidden animate-pulse">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-16 h-16 border-4 border-gray-600 border-t-gray-400 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<div className="relative w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] bg-gradient-to-b from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900 overflow-hidden flex items-center justify-center">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="MoonTVPlus"
|
||||
width={120}
|
||||
height={120}
|
||||
className="opacity-50"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -306,10 +420,16 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous
|
||||
/* 显示豆瓣直链视频 */
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<video
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
videoRefs.current.set(index, el);
|
||||
} else {
|
||||
videoRefs.current.delete(index);
|
||||
}
|
||||
}}
|
||||
src={getVideoUrl(item.trailer_url) || undefined}
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 min-w-full min-h-full w-auto h-auto object-cover"
|
||||
autoPlay
|
||||
muted
|
||||
muted={isMuted}
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
@@ -426,6 +546,21 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous
|
||||
<ChevronRight className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
{/* 音量控制按钮 - 只在有豆瓣预告片时显示 */}
|
||||
{currentItem.trailer_url && enableTrailers && (
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="absolute top-2 right-2 md:top-4 md:right-4 w-8 h-8 md:w-10 md:h-10 bg-black/30 hover:bg-black/60 text-white rounded-full flex items-center justify-center transition-all duration-300 z-10"
|
||||
aria-label={isMuted ? "开启声音" : "关闭声音"}
|
||||
>
|
||||
{isMuted ? (
|
||||
<VolumeX className="w-4 h-4 md:w-5 md:h-5" />
|
||||
) : (
|
||||
<Volume2 className="w-4 h-4 md:w-5 md:h-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 指示器 */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
|
||||
{items.map((_, index) => (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
import { Settings } from 'lucide-react';
|
||||
import { Link as LinkIcon, Settings } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, {
|
||||
useCallback,
|
||||
@@ -811,8 +811,10 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
}`.trim()}
|
||||
>
|
||||
{/* 封面 */}
|
||||
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'>
|
||||
{source.poster && (
|
||||
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden flex items-center justify-center'>
|
||||
{source.source === 'directplay' ? (
|
||||
<LinkIcon className='w-6 h-6 text-blue-500' />
|
||||
) : source.poster ? (
|
||||
<img
|
||||
src={processImageUrl(source.poster)}
|
||||
alt={source.title}
|
||||
@@ -822,7 +824,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 信息区域 */}
|
||||
|
||||
218
src/components/TokenRefreshManager.tsx
Normal file
218
src/components/TokenRefreshManager.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { getAuthInfoFromBrowserCookie, clearAuthCookie } from '@/lib/auth';
|
||||
import { TOKEN_CONFIG } from '@/lib/refresh-token';
|
||||
|
||||
/**
|
||||
* Token 自动刷新管理器
|
||||
*
|
||||
* 功能:
|
||||
* 1. 拦截所有 fetch 请求
|
||||
* 2. 检测到 401 错误时自动刷新 Token 并重试
|
||||
* 3. 在请求前检查 Token 是否即将过期,主动刷新
|
||||
*
|
||||
* 策略:
|
||||
* - 响应拦截:401 错误 → 刷新 Token → 重试请求
|
||||
* - 请求拦截:剩余时间 < 10 分钟 → 主动刷新
|
||||
*/
|
||||
export function TokenRefreshManager() {
|
||||
useEffect(() => {
|
||||
// localStorage 模式不需要刷新
|
||||
const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 刷新状态管理
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
// Token 刷新函数
|
||||
const refreshToken = async (): Promise<boolean> => {
|
||||
// 如果正在刷新,返回现有的 Promise
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
// 使用原始 fetch 避免递归
|
||||
const response = await window.fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[Token] Refreshed successfully');
|
||||
return true;
|
||||
} else {
|
||||
console.error('[Token] Refresh failed:', response.status);
|
||||
|
||||
// 刷新失败,先登出再跳转登录
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// 如果在登录页面,跳过登出和跳转逻辑
|
||||
if (window.location.pathname === '/login') {
|
||||
console.log('[Token] On login page, skipping logout and redirect');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Token] Logout error:', error);
|
||||
// 登出失败时清除前端cookie
|
||||
clearAuthCookie();
|
||||
}
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Token] Refresh error:', error);
|
||||
return false;
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshPromise;
|
||||
};
|
||||
|
||||
// 检查 Token 是否需要刷新
|
||||
const shouldRefreshToken = (): boolean => {
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
if (!authInfo || !authInfo.timestamp || !authInfo.refreshExpires) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Refresh Token 已过期
|
||||
if (now >= authInfo.refreshExpires) {
|
||||
console.log('[Token] Refresh token expired, redirecting to login');
|
||||
// 先登出再跳转登录
|
||||
window.fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).catch(error => {
|
||||
console.error('[Token] Logout error:', error);
|
||||
// 登出失败时清除前端cookie
|
||||
clearAuthCookie();
|
||||
}).finally(() => {
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 计算 Access Token 剩余时间
|
||||
const ACCESS_TOKEN_AGE = TOKEN_CONFIG.ACCESS_TOKEN_AGE;
|
||||
const age = now - authInfo.timestamp;
|
||||
const remaining = ACCESS_TOKEN_AGE - age;
|
||||
|
||||
// 剩余时间 < 刷新阈值时需要刷新(包括已过期的情况)
|
||||
const REFRESH_THRESHOLD = TOKEN_CONFIG.RENEWAL_THRESHOLD;
|
||||
return remaining < REFRESH_THRESHOLD;
|
||||
};
|
||||
|
||||
// 保存原始 fetch
|
||||
const originalFetch = window.fetch;
|
||||
|
||||
// 拦截 fetch
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
// 跳过不需要 Token 刷新的 API
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
|
||||
// 跳过:刷新 API、登录、登出、注册等认证相关接口
|
||||
if (
|
||||
url.includes('/api/auth/refresh') ||
|
||||
url.includes('/api/login') ||
|
||||
url.includes('/api/logout') ||
|
||||
url.includes('/api/register') ||
|
||||
url.includes('/api/auth/oidc')
|
||||
) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
// 请求前检查:Token 即将过期时主动刷新
|
||||
if (shouldRefreshToken()) {
|
||||
console.log('[Token] Expiring soon, refreshing proactively...');
|
||||
await refreshToken();
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
let response = await originalFetch(input, init);
|
||||
|
||||
// 响应拦截:401 错误时刷新 Token 并重试(仅重试一次)
|
||||
if (response.status === 401) {
|
||||
// 如果在登录页面,跳过刷新逻辑
|
||||
if (window.location.pathname === '/login') {
|
||||
console.log('[Token] On login page, skipping refresh logic');
|
||||
return response;
|
||||
}
|
||||
|
||||
// 克隆响应以便读取响应体
|
||||
const clonedResponse = response.clone();
|
||||
|
||||
try {
|
||||
const responseText = await clonedResponse.text();
|
||||
|
||||
// 只有当响应体包含 "Unauthorized" 或 "Refresh token expired" 或 "Access token expired" 时才刷新
|
||||
if (responseText.includes('Unauthorized') || responseText.includes('Refresh token expired') || responseText.includes('Access token expired')) {
|
||||
console.log('[Token] Received 401 with auth error, attempting refresh and retry...');
|
||||
|
||||
const refreshed = await refreshToken();
|
||||
|
||||
if (refreshed) {
|
||||
// 刷新成功,重试原请求(仅此一次)
|
||||
response = await originalFetch(input, init);
|
||||
|
||||
// 如果重试后仍然是 401,说明有问题,先登出再跳转登录
|
||||
if (response.status === 401) {
|
||||
console.error('[Token] Still 401 after refresh, redirecting to login');
|
||||
|
||||
// 如果在登录页面,跳过登出和跳转逻辑
|
||||
if (window.location.pathname === '/login') {
|
||||
console.log('[Token] On login page, skipping logout and redirect');
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
await originalFetch('/api/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Token] Logout error:', error);
|
||||
// 登出失败时清除前端cookie
|
||||
clearAuthCookie();
|
||||
}
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[Token] Received 401 but not an auth error, skipping refresh');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Token] Failed to read response body:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
// 清理:恢复原始 fetch
|
||||
return () => {
|
||||
window.fetch = originalFetch;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 这是一个纯逻辑组件,不渲染任何内容
|
||||
return null;
|
||||
}
|
||||
@@ -174,6 +174,8 @@ export const UserMenu: React.FC = () => {
|
||||
];
|
||||
|
||||
const [homeModules, setHomeModules] = useState<HomeModule[]>(defaultHomeModules);
|
||||
const [homeBannerEnabled, setHomeBannerEnabled] = useState(true);
|
||||
const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = useState(true);
|
||||
|
||||
// 豆瓣数据源选项
|
||||
const doubanDataSourceOptions = [
|
||||
@@ -443,6 +445,16 @@ export const UserMenu: React.FC = () => {
|
||||
setDanmakuHeatmapDisabled(savedDanmakuHeatmapDisabled === 'true');
|
||||
}
|
||||
|
||||
const savedHomeBannerEnabled = localStorage.getItem('homeBannerEnabled');
|
||||
if (savedHomeBannerEnabled !== null) {
|
||||
setHomeBannerEnabled(savedHomeBannerEnabled === 'true');
|
||||
}
|
||||
|
||||
const savedHomeContinueWatchingEnabled = localStorage.getItem('homeContinueWatchingEnabled');
|
||||
if (savedHomeContinueWatchingEnabled !== null) {
|
||||
setHomeContinueWatchingEnabled(savedHomeContinueWatchingEnabled === 'true');
|
||||
}
|
||||
|
||||
// 加载首页模块配置
|
||||
const savedHomeModules = localStorage.getItem('homeModules');
|
||||
if (savedHomeModules !== null) {
|
||||
@@ -917,6 +929,22 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleHomeBannerToggle = (value: boolean) => {
|
||||
setHomeBannerEnabled(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('homeBannerEnabled', String(value));
|
||||
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleHomeContinueWatchingToggle = (value: boolean) => {
|
||||
setHomeContinueWatchingEnabled(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('homeContinueWatchingEnabled', String(value));
|
||||
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
||||
}
|
||||
};
|
||||
|
||||
// 首页模块配置处理函数
|
||||
const handleHomeModuleToggle = (id: string, enabled: boolean) => {
|
||||
const updatedModules = homeModules.map(module =>
|
||||
@@ -1010,6 +1038,8 @@ export const UserMenu: React.FC = () => {
|
||||
setNextEpisodePreCache(true);
|
||||
setNextEpisodeDanmakuPreload(true);
|
||||
setDisableAutoLoadDanmaku(false);
|
||||
setHomeBannerEnabled(true);
|
||||
setHomeContinueWatchingEnabled(true);
|
||||
setHomeModules(defaultHomeModules);
|
||||
setSearchTraditionalToSimplified(false);
|
||||
|
||||
@@ -1031,6 +1061,8 @@ export const UserMenu: React.FC = () => {
|
||||
localStorage.setItem('disableAutoLoadDanmaku', 'false');
|
||||
localStorage.setItem('danmakuMaxCount', '0');
|
||||
localStorage.setItem('danmaku_heatmap_disabled', 'false');
|
||||
localStorage.setItem('homeBannerEnabled', 'true');
|
||||
localStorage.setItem('homeContinueWatchingEnabled', 'true');
|
||||
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
|
||||
localStorage.setItem('searchTraditionalToSimplified', 'false');
|
||||
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
||||
@@ -2189,6 +2221,55 @@ export const UserMenu: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 首页顶部组件显示 */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
|
||||
<button
|
||||
onClick={() => handleHomeBannerToggle(!homeBannerEnabled)}
|
||||
className='flex-shrink-0'
|
||||
title={homeBannerEnabled ? '点击隐藏' : '点击显示'}
|
||||
>
|
||||
{homeBannerEnabled ? (
|
||||
<Eye className='w-5 h-5 text-green-600 dark:text-green-400' />
|
||||
) : (
|
||||
<EyeOff className='w-5 h-5 text-gray-400 dark:text-gray-500' />
|
||||
)}
|
||||
</button>
|
||||
<div className='flex-1'>
|
||||
<span className={`text-sm font-medium ${
|
||||
homeBannerEnabled
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
首页轮播图
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
|
||||
<button
|
||||
onClick={() => handleHomeContinueWatchingToggle(!homeContinueWatchingEnabled)}
|
||||
className='flex-shrink-0'
|
||||
title={homeContinueWatchingEnabled ? '点击隐藏' : '点击显示'}
|
||||
>
|
||||
{homeContinueWatchingEnabled ? (
|
||||
<Eye className='w-5 h-5 text-green-600 dark:text-green-400' />
|
||||
) : (
|
||||
<EyeOff className='w-5 h-5 text-gray-400 dark:text-gray-500' />
|
||||
)}
|
||||
</button>
|
||||
<div className='flex-1'>
|
||||
<span className={`text-sm font-medium ${
|
||||
homeContinueWatchingEnabled
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
继续观看
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模块列表 */}
|
||||
<div className='space-y-2'>
|
||||
{homeModules.map((module, index) => (
|
||||
@@ -2247,8 +2328,12 @@ export const UserMenu: React.FC = () => {
|
||||
<button
|
||||
onClick={() => {
|
||||
setHomeModules(defaultHomeModules);
|
||||
setHomeBannerEnabled(true);
|
||||
setHomeContinueWatchingEnabled(true);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
|
||||
localStorage.setItem('homeBannerEnabled', 'true');
|
||||
localStorage.setItem('homeContinueWatchingEnabled', 'true');
|
||||
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
||||
}
|
||||
}}
|
||||
@@ -2703,12 +2788,23 @@ export const UserMenu: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{devices.map((device) => {
|
||||
{devices
|
||||
.sort((a, b) => {
|
||||
// 当前设备置顶
|
||||
if (a.isCurrent && !b.isCurrent) return -1;
|
||||
if (!a.isCurrent && b.isCurrent) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map((device) => {
|
||||
const DeviceIcon = getDeviceIcon(device.deviceInfo);
|
||||
return (
|
||||
<div
|
||||
key={device.tokenId}
|
||||
className='p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'
|
||||
className={`p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border ${
|
||||
device.isCurrent
|
||||
? 'border-yellow-400 dark:border-yellow-500'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className='flex items-start justify-between'>
|
||||
<div className='flex-1'>
|
||||
|
||||
@@ -167,6 +167,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
const actualYear = year;
|
||||
const actualQuery = query || '';
|
||||
const actualSearchType = type;
|
||||
const isDirectPlaySource = actualSource === 'directplay';
|
||||
const displayYear = useMemo(() => {
|
||||
if (!actualYear) return '';
|
||||
const normalized = actualYear.trim();
|
||||
@@ -719,42 +720,47 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
}}
|
||||
>
|
||||
{/* 骨架屏 */}
|
||||
{!isLoading && <ImagePlaceholder aspectRatio={orientation === 'horizontal' ? 'aspect-[3/2]' : 'aspect-[2/3]'} />}
|
||||
{/* 图片 */}
|
||||
<Image
|
||||
src={processImageUrl(actualPoster)}
|
||||
alt={actualTitle}
|
||||
fill
|
||||
className={origin === 'live' ? 'object-contain' : orientation === 'horizontal' ? 'object-cover object-center' : 'object-cover'}
|
||||
referrerPolicy='no-referrer'
|
||||
loading='lazy'
|
||||
onLoadingComplete={() => setIsLoading(true)}
|
||||
onError={(e) => {
|
||||
// 图片加载失败时的重试机制
|
||||
const img = e.target as HTMLImageElement;
|
||||
if (!img.dataset.retried) {
|
||||
img.dataset.retried = 'true';
|
||||
setTimeout(() => {
|
||||
img.src = processImageUrl(actualPoster);
|
||||
}, 2000);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
// 禁用图片的默认长按效果
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
pointerEvents: 'none', // 图片不响应任何指针事件
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
{!isLoading && !isDirectPlaySource && <ImagePlaceholder aspectRatio={orientation === 'horizontal' ? 'aspect-[3/2]' : 'aspect-[2/3]'} />}
|
||||
{isDirectPlaySource ? (
|
||||
<div className='absolute inset-0 flex items-center justify-center bg-gray-200/80 dark:bg-gray-700/80'>
|
||||
<Link className='w-8 h-8 text-blue-500' />
|
||||
</div>
|
||||
) : (
|
||||
<Image
|
||||
src={processImageUrl(actualPoster)}
|
||||
alt={actualTitle}
|
||||
fill
|
||||
className={origin === 'live' ? 'object-contain' : orientation === 'horizontal' ? 'object-cover object-center' : 'object-cover'}
|
||||
referrerPolicy='no-referrer'
|
||||
loading='lazy'
|
||||
onLoadingComplete={() => setIsLoading(true)}
|
||||
onError={(e) => {
|
||||
// 图片加载失败时的重试机制
|
||||
const img = e.target as HTMLImageElement;
|
||||
if (!img.dataset.retried) {
|
||||
img.dataset.retried = 'true';
|
||||
setTimeout(() => {
|
||||
img.src = processImageUrl(actualPoster);
|
||||
}, 2000);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
// 禁用图片的默认长按效果
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
pointerEvents: 'none', // 图片不响应任何指针事件
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 悬浮遮罩 */}
|
||||
<div
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useWatchRoom } from '@/hooks/useWatchRoom';
|
||||
|
||||
import Toast, { ToastProps } from '@/components/Toast';
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
|
||||
import type { ChatMessage, Member, Room, WatchRoomConfig } from '@/types/watch-room';
|
||||
|
||||
// Import type from watch-room-socket
|
||||
@@ -79,6 +81,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||
const [reconnectFailed, setReconnectFailed] = useState(false);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
|
||||
// 处理房间删除的回调
|
||||
const handleRoomDeleted = useCallback((data?: { reason?: string }) => {
|
||||
@@ -116,6 +119,23 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
|
||||
const watchRoom = useWatchRoom(handleRoomDeleted, handleStateCleared);
|
||||
|
||||
// 检查登录状态
|
||||
useEffect(() => {
|
||||
const checkLoginStatus = () => {
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
const loggedIn = !!(authInfo && authInfo.username);
|
||||
setIsLoggedIn(loggedIn);
|
||||
};
|
||||
|
||||
// 初始检查
|
||||
checkLoginStatus();
|
||||
|
||||
// 定期检查登录状态(每秒检查一次)
|
||||
const interval = setInterval(checkLoginStatus, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 手动重连
|
||||
const manualReconnect = useCallback(async () => {
|
||||
console.log('[WatchRoomProvider] Manual reconnect initiated');
|
||||
@@ -164,20 +184,26 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
|
||||
// 如果使用外部服务器,需要获取认证信息(需要登录)
|
||||
if (watchRoomConfig.serverType === 'external' && watchRoomConfig.enabled) {
|
||||
try {
|
||||
const authResponse = await fetch('/api/watch-room-auth');
|
||||
if (authResponse.ok) {
|
||||
const authData = await authResponse.json();
|
||||
watchRoomConfig.externalServerAuth = authData.externalServerAuth;
|
||||
} else {
|
||||
console.error('[WatchRoom] Failed to load auth info:', authResponse.status);
|
||||
// 检查用户是否已登录
|
||||
if (!isLoggedIn) {
|
||||
console.log('[WatchRoom] User not logged in, skipping auth info request');
|
||||
// 用户未登录,不调用认证接口
|
||||
} else {
|
||||
try {
|
||||
const authResponse = await fetch('/api/watch-room-auth');
|
||||
if (authResponse.ok) {
|
||||
const authData = await authResponse.json();
|
||||
watchRoomConfig.externalServerAuth = authData.externalServerAuth;
|
||||
} else {
|
||||
console.error('[WatchRoom] Failed to load auth info:', authResponse.status);
|
||||
// 如果无法获取认证信息,禁用观影室
|
||||
watchRoomConfig.enabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WatchRoom] Error loading auth info:', error);
|
||||
// 如果无法获取认证信息,禁用观影室
|
||||
watchRoomConfig.enabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WatchRoom] Error loading auth info:', error);
|
||||
// 如果无法获取认证信息,禁用观影室
|
||||
watchRoomConfig.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +258,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
return () => {
|
||||
watchRoom.disconnect();
|
||||
};
|
||||
}, []);
|
||||
}, [isLoggedIn]); // 添加 isLoggedIn 作为依赖
|
||||
|
||||
const contextValue: WatchRoomContextType = {
|
||||
socket: watchRoom.socket,
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface AdminConfig {
|
||||
from: 'config' | 'custom';
|
||||
disabled?: boolean;
|
||||
proxyMode?: boolean; // 代理模式开关:启用后由服务器代理m3u8和ts分片
|
||||
weight?: number; // 权重:用于排序和优选评分,默认0,范围0-100
|
||||
}[];
|
||||
CustomCategories: {
|
||||
name?: string;
|
||||
|
||||
@@ -111,3 +111,19 @@ export function getAuthInfoFromBrowserCookie(): AuthInfo | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 清除浏览器中的认证cookie (客户端使用)
|
||||
export function clearAuthCookie(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 清除 auth cookie,设置过期时间为过去
|
||||
document.cookie = 'auth=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
|
||||
// 如果有其他域名或路径的cookie,也尝试清除
|
||||
document.cookie = 'auth=; path=/; domain=' + window.location.hostname + '; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to clear cookie:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,88 @@ export interface ChangelogEntry {
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
version: '211.0.0',
|
||||
date: '2026-02-01',
|
||||
added: [
|
||||
"cloudflare部署增加d1支持",
|
||||
"电视直播支持flv播放",
|
||||
"登录注册页面输入框增加图标"
|
||||
],
|
||||
changed: [
|
||||
"豆瓣预告片改为前端获取",
|
||||
"豆瓣预告片可播放声音",
|
||||
"轮播图缓存采用乐观更新策略",
|
||||
"管理面板无权限时显示错误提示"
|
||||
],
|
||||
fixed: [
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '210.2.0',
|
||||
date: '2026-01-29',
|
||||
added: [
|
||||
"外部播放器增加app打开",
|
||||
"新增tvbox屏蔽源配置",
|
||||
"新增视频源权重功能"
|
||||
],
|
||||
changed: [
|
||||
"设备管理当前设备置顶且显示黄色边框",
|
||||
"live分片代理移除content-length发送"
|
||||
],
|
||||
fixed: [
|
||||
"修复tmdbkey错误和未登录时获取外部观影室密钥无限重定向"
|
||||
]
|
||||
},{
|
||||
version: '210.1.3',
|
||||
date: '2026-01-28',
|
||||
added: [
|
||||
],
|
||||
changed: [
|
||||
],
|
||||
fixed: [
|
||||
"修复跳转登录不登出"
|
||||
]
|
||||
},{
|
||||
version: '210.1.2',
|
||||
date: '2026-01-27',
|
||||
added: [
|
||||
],
|
||||
changed: [
|
||||
],
|
||||
fixed: [
|
||||
"修复刷新token失效无限重定向"
|
||||
]
|
||||
},{
|
||||
version: '210.1.1',
|
||||
date: '2026-01-27',
|
||||
added: [
|
||||
],
|
||||
changed: [
|
||||
],
|
||||
fixed: [
|
||||
"修复token过期重定向无法续期的问题"
|
||||
]
|
||||
},{
|
||||
version: '210.1.0',
|
||||
date: '2026-01-27',
|
||||
added: [
|
||||
"增加直链播放",
|
||||
"直播兼容txt格式",
|
||||
],
|
||||
changed: [
|
||||
"首页轮播图和继续观看可隐藏",
|
||||
"刷新token改为前端进行防止redis数据库方式报错"
|
||||
],
|
||||
fixed: [
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '210.0.0',
|
||||
date: '2026-01-25',
|
||||
added: [
|
||||
"新增网络直播功能",
|
||||
"动漫磁力搜索增加蜜柑",
|
||||
"弹幕磁力搜索增加动漫花园",
|
||||
"动漫磁力搜索增加动漫花园",
|
||||
"pansou增加关键词过滤",
|
||||
"cloudflare workers部署支持",
|
||||
],
|
||||
|
||||
177
src/lib/d1-adapter.ts
Normal file
177
src/lib/d1-adapter.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* 统一的数据库适配器接口
|
||||
* 兼容 Cloudflare D1 和 better-sqlite3
|
||||
*
|
||||
* 注意:此模块仅在服务端使用,通过 webpack 配置排除客户端打包
|
||||
*/
|
||||
|
||||
// Cloudflare D1 Database 接口
|
||||
export interface D1Database {
|
||||
prepare(query: string): D1PreparedStatement;
|
||||
batch(statements: any[]): Promise<D1Result[]>;
|
||||
exec(query: string): Promise<D1Result>;
|
||||
}
|
||||
|
||||
// D1 PreparedStatement 接口
|
||||
export interface D1PreparedStatement {
|
||||
bind(...values: any[]): D1PreparedStatement;
|
||||
first<T = any>(colName?: string): Promise<T | null>;
|
||||
run<T = any>(): Promise<D1Result<T>>;
|
||||
all<T = any>(): Promise<D1Result<T>>;
|
||||
}
|
||||
|
||||
export interface D1Result<T = any> {
|
||||
results?: T[];
|
||||
success: boolean;
|
||||
meta?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 统一的数据库接口
|
||||
export interface DatabaseAdapter {
|
||||
prepare(query: string): D1PreparedStatement;
|
||||
batch?(statements: D1PreparedStatement[]): Promise<D1Result[]>;
|
||||
exec?(query: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare D1 适配器(生产环境)
|
||||
*/
|
||||
export class CloudflareD1Adapter implements DatabaseAdapter {
|
||||
constructor(private db: D1Database) {}
|
||||
|
||||
prepare(query: string): D1PreparedStatement {
|
||||
return this.db.prepare(query);
|
||||
}
|
||||
|
||||
async batch(statements: D1PreparedStatement[]): Promise<D1Result[]> {
|
||||
return this.db.batch(statements as any);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite 适配器(开发环境)
|
||||
* 包装 better-sqlite3 以兼容 D1 API
|
||||
*/
|
||||
export class SQLiteAdapter implements DatabaseAdapter {
|
||||
private db: any; // better-sqlite3 Database
|
||||
|
||||
constructor(db: any) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
prepare(query: string): D1PreparedStatement {
|
||||
const stmt = this.db.prepare(query);
|
||||
return new SQLitePreparedStatement(stmt);
|
||||
}
|
||||
|
||||
batch(statements: D1PreparedStatement[]): Promise<D1Result[]> {
|
||||
// SQLite 使用事务模拟 batch
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const results: D1Result[] = [];
|
||||
const transaction = this.db.transaction(() => {
|
||||
for (const stmt of statements) {
|
||||
const result = (stmt as any).runSync();
|
||||
results.push(result);
|
||||
}
|
||||
});
|
||||
transaction();
|
||||
resolve(results);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
exec(query: string): void {
|
||||
this.db.exec(query);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite PreparedStatement 包装器
|
||||
* 将 better-sqlite3 API 转换为 D1 兼容 API
|
||||
*/
|
||||
class SQLitePreparedStatement implements D1PreparedStatement {
|
||||
private stmt: any;
|
||||
private params: any[] = [];
|
||||
|
||||
constructor(stmt: any) {
|
||||
this.stmt = stmt;
|
||||
}
|
||||
|
||||
bind(...values: any[]): D1PreparedStatement {
|
||||
this.params = values;
|
||||
return this;
|
||||
}
|
||||
|
||||
async first<T = any>(colName?: string): Promise<T | null> {
|
||||
try {
|
||||
const result = this.stmt.get(...this.params);
|
||||
if (!result) return null;
|
||||
if (colName) return result[colName] ?? null;
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('SQLite first() error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async run<T = any>(): Promise<D1Result<T>> {
|
||||
try {
|
||||
const info = this.stmt.run(...this.params);
|
||||
return {
|
||||
success: true,
|
||||
meta: {
|
||||
changes: info.changes,
|
||||
last_row_id: info.lastInsertRowid,
|
||||
},
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error('SQLite run() error:', err);
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async all<T = any>(): Promise<D1Result<T>> {
|
||||
try {
|
||||
const results = this.stmt.all(...this.params);
|
||||
return {
|
||||
success: true,
|
||||
results: results || [],
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error('SQLite all() error:', err);
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 同步版本(用于 batch)
|
||||
runSync(): D1Result {
|
||||
try {
|
||||
const info = this.stmt.run(...this.params);
|
||||
return {
|
||||
success: true,
|
||||
meta: {
|
||||
changes: info.changes,
|
||||
last_row_id: info.lastInsertRowid,
|
||||
},
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
1589
src/lib/d1.db.ts
Normal file
1589
src/lib/d1.db.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@
|
||||
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
|
||||
*/
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from './auth';
|
||||
import { getAuthInfoFromBrowserCookie, clearAuthCookie } from './auth';
|
||||
import { DanmakuFilterConfig, EpisodeFilterConfig,SkipConfig } from './types';
|
||||
|
||||
// 全局错误触发函数
|
||||
@@ -521,27 +521,78 @@ async function fetchWithAuth(
|
||||
url: string,
|
||||
options?: RequestInit
|
||||
): Promise<Response> {
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) {
|
||||
// 如果是 401 未授权,跳转到登录页面
|
||||
if (res.status === 401) {
|
||||
// 调用 logout 接口
|
||||
try {
|
||||
await fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('注销请求失败:', error);
|
||||
let res = await fetch(url, options);
|
||||
|
||||
// 如果是 401 且是 token 过期,尝试刷新并重试
|
||||
if (res.status === 401) {
|
||||
const text = await res.clone().text();
|
||||
|
||||
// 只有当响应体包含 "Unauthorized" 或 "Refresh token expired" 或 "Access token expired" 时才处理
|
||||
if (text.includes('Unauthorized') || text.includes('Refresh token expired') || text.includes('Access token expired')) {
|
||||
// 如果在登录页面,跳过刷新逻辑
|
||||
if (typeof window !== 'undefined' && window.location.pathname === '/login') {
|
||||
console.log('[fetchWithAuth] On login page, skipping refresh logic');
|
||||
return res;
|
||||
}
|
||||
const currentUrl = window.location.pathname + window.location.search;
|
||||
const loginUrl = new URL('/login', window.location.origin);
|
||||
loginUrl.searchParams.set('redirect', currentUrl);
|
||||
window.location.href = loginUrl.toString();
|
||||
throw new Error('用户未授权,已跳转到登录页面');
|
||||
|
||||
// 检查是否是登录相关的接口,如果是则不刷新
|
||||
if (
|
||||
url.includes('/api/login') ||
|
||||
url.includes('/api/register') ||
|
||||
url.includes('/api/auth/oidc') ||
|
||||
url.includes('/api/auth/refresh')
|
||||
) {
|
||||
throw new Error('用户未授权');
|
||||
}
|
||||
|
||||
// 尝试刷新 token
|
||||
const refreshRes = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (refreshRes.ok) {
|
||||
// 刷新成功,重试原请求
|
||||
res = await fetch(url, options);
|
||||
}
|
||||
} else {
|
||||
// 不是认证错误的401,直接返回
|
||||
console.log('[fetchWithAuth] Received 401 but not an auth error, skipping refresh');
|
||||
return res;
|
||||
}
|
||||
|
||||
// 如果刷新后仍然是 401,或者是其他 401 错误,跳转登录
|
||||
if (res.status === 401) {
|
||||
const text2 = await res.clone().text();
|
||||
// 再次检查响应体
|
||||
if (text2.includes('Unauthorized') || text2.includes('Refresh token expired') || text2.includes('Access token expired')) {
|
||||
// 检查当前页面是否已经是登录页,避免重复跳转
|
||||
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
|
||||
// 调用 logout 接口
|
||||
try {
|
||||
await fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('注销请求失败:', error);
|
||||
// 登出失败时清除前端cookie
|
||||
clearAuthCookie();
|
||||
}
|
||||
const currentUrl = window.location.pathname + window.location.search;
|
||||
const loginUrl = new URL('/login', window.location.origin);
|
||||
loginUrl.searchParams.set('redirect', currentUrl);
|
||||
window.location.href = loginUrl.toString();
|
||||
}
|
||||
throw new Error('用户未授权,已跳转到登录页面');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`请求 ${url} 失败: ${res.status}`);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ import { RedisStorage } from './redis.db';
|
||||
import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
import { UpstashRedisStorage } from './upstash.db';
|
||||
|
||||
// storage type 常量: 'localstorage' | 'redis' | 'upstash',默认 'localstorage'
|
||||
// storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1',默认 'localstorage'
|
||||
const STORAGE_TYPE =
|
||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||
| 'localstorage'
|
||||
| 'redis'
|
||||
| 'upstash'
|
||||
| 'kvrocks'
|
||||
| 'd1'
|
||||
| undefined) || 'localstorage';
|
||||
|
||||
// 创建存储实例
|
||||
@@ -24,12 +25,78 @@ function createStorage(): IStorage {
|
||||
return new UpstashRedisStorage();
|
||||
case 'kvrocks':
|
||||
return new KvrocksStorage();
|
||||
case 'd1':
|
||||
// D1Storage 只能在服务端使用,客户端会报错
|
||||
if (typeof window !== 'undefined') {
|
||||
throw new Error('D1Storage can only be used on the server side');
|
||||
}
|
||||
const adapter = getD1Adapter();
|
||||
// 动态导入 D1Storage 以避免客户端打包
|
||||
const { D1Storage } = require('./d1.db');
|
||||
return new D1Storage(adapter);
|
||||
case 'localstorage':
|
||||
default:
|
||||
return null as unknown as IStorage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 D1 适配器
|
||||
* 开发环境:使用 better-sqlite3
|
||||
* 生产环境:使用 Cloudflare D1
|
||||
*/
|
||||
function getD1Adapter(): any {
|
||||
// 动态导入适配器以避免客户端打包
|
||||
const { CloudflareD1Adapter, SQLiteAdapter } = require('./d1-adapter');
|
||||
|
||||
// 检查是否为 Cloudflare 构建
|
||||
const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
|
||||
|
||||
// 生产环境:Cloudflare Workers/Pages
|
||||
if (isCloudflare) {
|
||||
// 创建一个懒加载的适配器,延迟到实际使用时才获取 D1 绑定
|
||||
let cachedAdapter: any = null;
|
||||
|
||||
return new Proxy({}, {
|
||||
get(target, prop) {
|
||||
// 懒加载:第一次访问时才获取真实的 D1 适配器
|
||||
if (!cachedAdapter) {
|
||||
try {
|
||||
const { getCloudflareContext } = require('@opennextjs/cloudflare');
|
||||
const { env } = getCloudflareContext();
|
||||
|
||||
if (!env.DB) {
|
||||
throw new Error('D1 database binding (DB) not found in Cloudflare environment');
|
||||
}
|
||||
|
||||
console.log('Using Cloudflare D1 database');
|
||||
cachedAdapter = new CloudflareD1Adapter(env.DB);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Cloudflare D1:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return cachedAdapter[prop];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 开发环境:better-sqlite3
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(process.cwd(), '.data', 'moontv.db');
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL'); // 启用 WAL 模式提升性能
|
||||
|
||||
console.log('Using SQLite database (development mode)');
|
||||
console.log('Database location:', dbPath);
|
||||
|
||||
return new SQLiteAdapter(db);
|
||||
}
|
||||
|
||||
// 单例存储实例
|
||||
let storageInstance: IStorage | null = null;
|
||||
|
||||
|
||||
@@ -70,7 +70,9 @@ export async function refreshLiveChannels(liveInfo: {
|
||||
},
|
||||
});
|
||||
const data = await response.text();
|
||||
const result = parseM3U(liveInfo.key, data);
|
||||
const result = isM3UContent(data)
|
||||
? parseM3U(liveInfo.key, data)
|
||||
: parseTxtLive(liveInfo.key, data);
|
||||
const epgUrl = liveInfo.epg || result.tvgUrl;
|
||||
const tvgIds = result.channels
|
||||
.map((channel) => channel.tvgId)
|
||||
@@ -367,6 +369,86 @@ function parseEpgLines(
|
||||
return result;
|
||||
}
|
||||
|
||||
function stripBom(value: string) {
|
||||
return value.replace(/^\uFEFF/, '');
|
||||
}
|
||||
|
||||
function isM3UContent(content: string) {
|
||||
const normalized = stripBom(content).trim();
|
||||
return normalized.includes('#EXTM3U') || normalized.includes('#EXTINF');
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string) {
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function parseTxtLive(
|
||||
sourceKey: string,
|
||||
txtContent: string
|
||||
): {
|
||||
tvgUrl: string;
|
||||
channels: {
|
||||
id: string;
|
||||
tvgId: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
group: string;
|
||||
url: string;
|
||||
}[];
|
||||
} {
|
||||
const channels: {
|
||||
id: string;
|
||||
tvgId: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
group: string;
|
||||
url: string;
|
||||
}[] = [];
|
||||
|
||||
const lines = txtContent
|
||||
.split('\n')
|
||||
.map((line) => stripBom(line).trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
let currentGroup = '无分组';
|
||||
let channelIndex = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const commaIndex = line.indexOf(',');
|
||||
if (commaIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = line.slice(0, commaIndex).trim();
|
||||
const value = line.slice(commaIndex + 1).trim();
|
||||
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === '#genre#') {
|
||||
currentGroup = name;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!value || !isHttpUrl(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
channels.push({
|
||||
id: `${sourceKey}-${channelIndex}`,
|
||||
tvgId: name,
|
||||
name,
|
||||
logo: '',
|
||||
group: currentGroup,
|
||||
url: value,
|
||||
});
|
||||
channelIndex++;
|
||||
}
|
||||
|
||||
return { tvgUrl: '', channels };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析M3U文件内容,提取频道信息
|
||||
* @param m3uContent M3U文件的内容字符串
|
||||
@@ -397,7 +479,7 @@ function parseM3U(
|
||||
|
||||
const lines = m3uContent
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.map((line) => stripBom(line).trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
let tvgUrl = '';
|
||||
|
||||
@@ -1275,6 +1275,16 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
);
|
||||
}
|
||||
|
||||
async updateLastMovieRequestTime(userName: string, timestamp: number): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(
|
||||
this.userInfoKey(userName),
|
||||
'last_movie_request_time',
|
||||
timestamp.toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 求片相关 ----------
|
||||
private movieRequestsKey() {
|
||||
return 'movie_requests:all';
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { getStorage } from './db';
|
||||
import { TOKEN_CONFIG } from './token-config';
|
||||
|
||||
// Token 配置
|
||||
export const TOKEN_CONFIG = {
|
||||
ACCESS_TOKEN_AGE: 4 * 60 * 60 * 1000, // 4 小时
|
||||
REFRESH_TOKEN_AGE: 60 * 24 * 60 * 60 * 1000, // 60 天
|
||||
RENEWAL_THRESHOLD: 10 * 60 * 1000, // 剩余 10 分钟时自动续期
|
||||
};
|
||||
// Re-export TOKEN_CONFIG for backward compatibility
|
||||
export { TOKEN_CONFIG };
|
||||
|
||||
// Lazy import to avoid Edge Runtime issues in middleware
|
||||
let getStorage: (() => any) | null = null;
|
||||
|
||||
async function loadStorage() {
|
||||
if (!getStorage) {
|
||||
const db = await import('./db');
|
||||
getStorage = db.getStorage;
|
||||
}
|
||||
return getStorage();
|
||||
}
|
||||
|
||||
interface TokenData {
|
||||
token: string;
|
||||
@@ -38,7 +45,7 @@ export async function storeRefreshToken(
|
||||
tokenData: TokenData
|
||||
): Promise<void> {
|
||||
const hashKey = `user_tokens:${username}`;
|
||||
const storage = getStorage();
|
||||
const storage = await loadStorage();
|
||||
|
||||
if (!storage || typeof (storage as any).adapter?.hSet !== 'function') {
|
||||
console.warn('Redis Hash not supported, skipping token storage');
|
||||
@@ -65,7 +72,7 @@ export async function verifyRefreshToken(
|
||||
refreshToken: string
|
||||
): Promise<boolean> {
|
||||
const hashKey = `user_tokens:${username}`;
|
||||
const storage = getStorage();
|
||||
const storage = await loadStorage();
|
||||
|
||||
if (!storage || typeof (storage as any).adapter?.hGet !== 'function') {
|
||||
console.warn('Redis Hash not supported');
|
||||
@@ -114,7 +121,7 @@ export async function revokeRefreshToken(
|
||||
tokenId: string
|
||||
): Promise<void> {
|
||||
const hashKey = `user_tokens:${username}`;
|
||||
const storage = getStorage();
|
||||
const storage = await loadStorage();
|
||||
|
||||
if (!storage || typeof (storage as any).adapter?.hDel !== 'function') {
|
||||
console.warn('Redis Hash not supported');
|
||||
@@ -138,7 +145,7 @@ export async function getUserDevices(username: string): Promise<Array<{
|
||||
expiresAt: number;
|
||||
}>> {
|
||||
const hashKey = `user_tokens:${username}`;
|
||||
const storage = getStorage();
|
||||
const storage = await loadStorage();
|
||||
|
||||
if (!storage || typeof (storage as any).adapter?.hGetAll !== 'function') {
|
||||
console.warn('Redis Hash not supported');
|
||||
@@ -188,7 +195,7 @@ export async function getUserDevices(username: string): Promise<Array<{
|
||||
// 撤销所有 Token
|
||||
export async function revokeAllRefreshTokens(username: string): Promise<void> {
|
||||
const hashKey = `user_tokens:${username}`;
|
||||
const storage = getStorage();
|
||||
const storage = await loadStorage();
|
||||
|
||||
if (!storage || typeof (storage as any).adapter?.del !== 'function') {
|
||||
console.warn('Redis Hash not supported');
|
||||
@@ -206,7 +213,7 @@ export async function revokeAllRefreshTokens(username: string): Promise<void> {
|
||||
// 清理过期的 Token(定期任务)
|
||||
export async function cleanupExpiredTokens(username: string): Promise<number> {
|
||||
const hashKey = `user_tokens:${username}`;
|
||||
const storage = getStorage();
|
||||
const storage = await loadStorage();
|
||||
|
||||
if (!storage || typeof (storage as any).adapter?.hGetAll !== 'function') {
|
||||
return 0;
|
||||
|
||||
8
src/lib/token-config.ts
Normal file
8
src/lib/token-config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Token 配置常量
|
||||
// 这个文件不依赖任何服务端模块,可以在客户端安全使用
|
||||
|
||||
export const TOKEN_CONFIG = {
|
||||
ACCESS_TOKEN_AGE: 4 * 60 * 60 * 1000, // 4 小时
|
||||
REFRESH_TOKEN_AGE: 60 * 24 * 60 * 60 * 1000, // 60 天
|
||||
RENEWAL_THRESHOLD: 10 * 60 * 1000, // 剩余 10 分钟时自动续期
|
||||
};
|
||||
@@ -118,6 +118,9 @@ export interface IStorage {
|
||||
getLastFavoriteCheckTime(userName: string): Promise<number>;
|
||||
setLastFavoriteCheckTime(userName: string, timestamp: number): Promise<void>;
|
||||
|
||||
// 求片冷却时间
|
||||
updateLastMovieRequestTime?(userName: string, timestamp: number): Promise<void>;
|
||||
|
||||
// 求片相关
|
||||
getAllMovieRequests(): Promise<MovieRequest[]>;
|
||||
getMovieRequest(requestId: string): Promise<MovieRequest | null>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const CURRENT_VERSION = '210.0.0';
|
||||
const CURRENT_VERSION = '211.0.0';
|
||||
|
||||
// 导出当前版本号供其他地方使用
|
||||
export { CURRENT_VERSION };
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { refreshAccessToken, shouldRenewToken } from '@/lib/middleware-auth';
|
||||
import { TOKEN_CONFIG } from '@/lib/refresh-token';
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
@@ -49,45 +48,29 @@ export async function middleware(request: NextRequest) {
|
||||
return handleAuthFailure(request, pathname);
|
||||
}
|
||||
|
||||
// 验证 Access Token 时间戳
|
||||
// 验证 Token 时间戳
|
||||
const ACCESS_TOKEN_AGE = TOKEN_CONFIG.ACCESS_TOKEN_AGE;
|
||||
const now = Date.now();
|
||||
const age = now - authInfo.timestamp;
|
||||
|
||||
// Access Token 已过期,尝试使用 Refresh Token 刷新
|
||||
if (age > ACCESS_TOKEN_AGE) {
|
||||
if (authInfo.refreshToken && authInfo.tokenId && authInfo.refreshExpires) {
|
||||
// 检查 Refresh Token 是否过期
|
||||
if (now < authInfo.refreshExpires) {
|
||||
// 尝试刷新 Access Token
|
||||
const newAuthData = await refreshAccessToken(
|
||||
authInfo.username,
|
||||
authInfo.role,
|
||||
authInfo.tokenId,
|
||||
authInfo.refreshToken,
|
||||
authInfo.refreshExpires
|
||||
);
|
||||
|
||||
if (newAuthData) {
|
||||
// 刷新成功,设置新 Cookie
|
||||
const response = NextResponse.next();
|
||||
const expires = new Date(authInfo.refreshExpires);
|
||||
response.cookies.set('auth', newAuthData, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh Token 也过期或刷新失败,需要重新登录
|
||||
// 先检查 Refresh Token 是否过期
|
||||
if (now >= authInfo.refreshExpires) {
|
||||
console.log(`Refresh token expired for ${authInfo.username}, redirecting to login`);
|
||||
return handleAuthFailure(request, pathname);
|
||||
}
|
||||
|
||||
// Access Token 已过期
|
||||
if (age > ACCESS_TOKEN_AGE) {
|
||||
console.log(`Access token expired for ${authInfo.username}`);
|
||||
// 对于 API 请求,返回 401,让前端拦截器刷新并重试
|
||||
if (pathname.startsWith('/api')) {
|
||||
return new NextResponse('Access token expired', { status: 401 });
|
||||
}
|
||||
// 对于页面请求,允许通过,让前端 TokenRefreshManager 在页面加载后刷新
|
||||
// 不能返回 401 或重定向,否则页面无法加载,前端代码无法运行
|
||||
console.log(`Allowing page request to pass, frontend will refresh token`);
|
||||
}
|
||||
|
||||
// Access Token 未过期,验证签名
|
||||
const isValidSignature = await verifySignature(
|
||||
authInfo.username,
|
||||
@@ -101,34 +84,8 @@ export async function middleware(request: NextRequest) {
|
||||
return handleAuthFailure(request, pathname);
|
||||
}
|
||||
|
||||
// 签名验证通过,检查是否需要续期
|
||||
if (shouldRenewToken(authInfo.timestamp)) {
|
||||
// 快过期了,自动续期
|
||||
if (authInfo.refreshToken && authInfo.tokenId && authInfo.refreshExpires) {
|
||||
const newAuthData = await refreshAccessToken(
|
||||
authInfo.username,
|
||||
authInfo.role,
|
||||
authInfo.tokenId,
|
||||
authInfo.refreshToken,
|
||||
authInfo.refreshExpires
|
||||
);
|
||||
|
||||
if (newAuthData) {
|
||||
const response = NextResponse.next();
|
||||
const expires = new Date(authInfo.refreshExpires);
|
||||
response.cookies.set('auth', newAuthData, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 正常通过
|
||||
// 签名验证通过
|
||||
// 注意:Token 续期由前端负责,Middleware 不再自动刷新
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
@@ -215,6 +172,6 @@ function shouldSkipAuth(pathname: string): boolean {
|
||||
// 配置middleware匹配规则
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/cron/|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css|api/openlist/cms-proxy|api/openlist/play|api/emby/cms-proxy|api/emby/play|api/emby/sources).*)',
|
||||
'/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/auth/refresh|api/cron/|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css|api/openlist/cms-proxy|api/openlist/play|api/emby/cms-proxy|api/emby/play|api/emby/sources).*)',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -9,5 +9,12 @@ main = ".open-next/worker.js"
|
||||
directory = ".open-next/assets"
|
||||
binding = "ASSETS"
|
||||
|
||||
[env.production]
|
||||
# 生产环境变量可以在 Cloudflare Dashboard 配置
|
||||
# D1 数据库绑定
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "moontvplus"
|
||||
database_id = "REPLACE_WITH_YOUR_D1_DATABASE_ID"
|
||||
|
||||
[vars]
|
||||
NODE_ENV = "production"
|
||||
BUILD_TARGET = "cloudflare"
|
||||
|
||||
Reference in New Issue
Block a user