Compare commits
27 Commits
Selene
...
Selene_Bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d778c988bd | ||
|
|
e754feeb68 | ||
|
|
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 |
47
.github/workflows/cloudflare-deploy.yml
vendored
47
.github/workflows/cloudflare-deploy.yml
vendored
@@ -67,6 +67,52 @@ jobs:
|
|||||||
echo "INIT_CONFIG=${{ secrets.INIT_CONFIG }}" >> $GITHUB_ENV
|
echo "INIT_CONFIG=${{ secrets.INIT_CONFIG }}" >> $GITHUB_ENV
|
||||||
echo "CONFIG_SUBSCRIPTION_URL=${{ secrets.CONFIG_SUBSCRIPTION_URL }}" >> $GITHUB_ENV
|
echo "CONFIG_SUBSCRIPTION_URL=${{ secrets.CONFIG_SUBSCRIPTION_URL }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- 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
|
- name: Build for Cloudflare
|
||||||
run: pnpm run build:cloudflare
|
run: pnpm run build:cloudflare
|
||||||
|
|
||||||
@@ -77,3 +123,4 @@ jobs:
|
|||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
command: deploy
|
command: deploy
|
||||||
packageManager: pnpm
|
packageManager: pnpm
|
||||||
|
wranglerVersion: "4.60.0"
|
||||||
|
|||||||
23
CHANGELOG
23
CHANGELOG
@@ -1,8 +1,29 @@
|
|||||||
|
## [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
|
## [210.0.0] - 2026-01-25
|
||||||
### Added
|
### Added
|
||||||
- 新增网络直播功能
|
- 新增网络直播功能
|
||||||
- 动漫磁力搜索增加蜜柑
|
- 动漫磁力搜索增加蜜柑
|
||||||
- 弹幕磁力搜索增加动漫花园
|
- 动漫磁力搜索增加动漫花园
|
||||||
- pansou增加关键词过滤
|
- pansou增加关键词过滤
|
||||||
- cloudflare workers部署支持
|
- cloudflare workers部署支持
|
||||||
|
|
||||||
|
|||||||
199
README.md
199
README.md
@@ -4,16 +4,17 @@
|
|||||||
<img src="public/logo.png" alt="MoonTVPlus Logo" width="120">
|
<img src="public/logo.png" alt="MoonTVPlus Logo" width="120">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
> 🎬 **MoonTVPlus** 是基于 [MoonTV v100](https://github.com/MoonTechLab/LunaTV) 二次开发的增强版影视聚合播放器。它在原版基础上新增了外部播放器支持、视频超分、弹幕系统、评论抓取等实用功能,提供更强大的观影体验。
|
> 🎬 **MoonTVPlus** 是基于 [MoonTV v100](https://github.com/MoonTechLab/LunaTV) 二次开发的增强版影视聚合播放器。它在原版基础上新增了外部播放器支持、视频超分、弹幕系统、评论抓取等实用功能,提供更强大的观影体验。
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
<img src="public/screenshot3.png" alt="项目截图" style="max-width:600px">
|
<img src="public/screenshot3.png" alt="项目截图" style="max-width:600px">
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
### 请不要在 B站、小红书、微信公众号、抖音、今日头条或其他中国大陆社交平台发布视频或文章宣传本项目,不授权任何“科技周刊/月刊”类项目或站点收录本项目。
|
### 请不要在 B站、小红书、微信公众号、抖音、今日头条或其他中国大陆社交平台发布视频或文章宣传本项目,不授权任何“科技周刊/月刊”类项目或站点收录本项目。
|
||||||
|
|
||||||
## 🗺 目录
|
## 🗺 目录
|
||||||
@@ -72,18 +74,18 @@
|
|||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 分类 | 主要依赖 |
|
| 分类 | 主要依赖 |
|
||||||
| --------- | ----------------------------------------------------------------------------------------------------- |
|
| --------- | ------------------------------------------------------------ |
|
||||||
| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router |
|
| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router |
|
||||||
| UI & 样式 | [Tailwind CSS 3](https://tailwindcss.com/) |
|
| UI & 样式 | [Tailwind CSS 3](https://tailwindcss.com/) |
|
||||||
| 语言 | TypeScript 4 |
|
| 语言 | TypeScript 4 |
|
||||||
| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) |
|
| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) |
|
||||||
| 代码质量 | ESLint · Prettier · Jest |
|
| 代码质量 | ESLint · Prettier · Jest |
|
||||||
| 部署 | Docker |
|
| 部署 | Docker |
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
本项目**支持 Docker 和Vercel平台** 部署。
|
本项目**支持 Docker、Vercel 和 Cloudflare Workers 平台** 部署。
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus)
|
[](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus)
|
||||||
|
|
||||||
@@ -91,7 +93,92 @@
|
|||||||
|
|
||||||
[](https://zeabur.com/templates/SCHCAY/deploy)
|
[](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.配置外部定时任务(可选)**
|
||||||
|
|
||||||
|
可使用外部定时请求/api/cron/mtvpls端点以触发定时任务,或新建一个workers请求触发,推荐每小时请求一次。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Docker 部署
|
||||||
|
|
||||||
|
#### Kvrocks 存储(推荐)
|
||||||
|
|
||||||
```yml
|
```yml
|
||||||
services:
|
services:
|
||||||
@@ -163,6 +250,7 @@ networks:
|
|||||||
1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。
|
1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。
|
||||||
2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN**
|
2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN**
|
||||||
3. 使用如下 docker compose
|
3. 使用如下 docker compose
|
||||||
|
|
||||||
```yml
|
```yml
|
||||||
services:
|
services:
|
||||||
moontv-core:
|
moontv-core:
|
||||||
@@ -234,47 +322,47 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
|||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
| 变量 | 说明 | 可选值 | 默认值 |
|
| 变量 | 说明 | 可选值 | 默认值 |
|
||||||
| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------- | ------------------------------------------------------------ | --------------------------- | ------------------------------------------------------------ |
|
||||||
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
||||||
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
||||||
| CRON_PASSWORD | 定时任务 API 访问密码(用于保护 /api/cron 端点) | 任意字符串 | mtvpls |
|
| CRON_PASSWORD | 定时任务 API 访问密码(用于保护 /api/cron 端点) | 任意字符串 | mtvpls |
|
||||||
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
|
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
|
||||||
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
||||||
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||||
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
|
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
|
||||||
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
|
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
|
||||||
| REDIS_URL | redis 连接 url | 连接 url | 空 |
|
| REDIS_URL | redis 连接 url | 连接 url | 空 |
|
||||||
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
|
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
|
||||||
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
|
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
|
||||||
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
||||||
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
|
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
|
||||||
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
|
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
|
||||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
|
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
|
||||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
|
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
|
||||||
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
|
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
|
||||||
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
|
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
|
||||||
| NEXT_PUBLIC_PROXY_M3U8_TOKEN | M3U8 代理 API 鉴权 Token(外部播放器跳转时的鉴权token,不填为无鉴权) | 任意字符串 | (空) |
|
| NEXT_PUBLIC_PROXY_M3U8_TOKEN | M3U8 代理 API 鉴权 Token(外部播放器跳转时的鉴权token,不填为无鉴权) | 任意字符串 | (空) |
|
||||||
| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) |
|
| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) |
|
||||||
| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false |
|
| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false |
|
||||||
| TVBOX_SUBSCRIBE_TOKEN | TVBOX 订阅 API 访问 Token,如启用TVBOX功能必须设置该项 | 任意字符串 | (空) |
|
| TVBOX_SUBSCRIBE_TOKEN | TVBOX 订阅 API 访问 Token,如启用TVBOX功能必须设置该项 | 任意字符串 | (空) |
|
||||||
| WATCH_ROOM_ENABLED | 是否启用观影室功能(vercel部署不支持该功能,可使用外部服务器) | true/false | false |
|
| WATCH_ROOM_ENABLED | 是否启用观影室功能(vercel部署不支持该功能,可使用外部服务器) | true/false | false |
|
||||||
| WATCH_ROOM_SERVER_TYPE | 观影室服务器类型 | internal/external | internal |
|
| WATCH_ROOM_SERVER_TYPE | 观影室服务器类型 | internal/external | internal |
|
||||||
| WATCH_ROOM_EXTERNAL_SERVER_URL | 外部观影室服务器地址(当 SERVER_TYPE 为 external 时必填) | WebSocket URL | (空) |
|
| WATCH_ROOM_EXTERNAL_SERVER_URL | 外部观影室服务器地址(当 SERVER_TYPE 为 external 时必填) | WebSocket URL | (空) |
|
||||||
| WATCH_ROOM_EXTERNAL_SERVER_AUTH | 外部观影室服务器认证令牌(当 SERVER_TYPE 为 external 时必填) | 任意字符串 | (空) |
|
| WATCH_ROOM_EXTERNAL_SERVER_AUTH | 外部观影室服务器认证令牌(当 SERVER_TYPE 为 external 时必填) | 任意字符串 | (空) |
|
||||||
| NEXT_PUBLIC_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback |
|
| NEXT_PUBLIC_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback |
|
||||||
| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false |
|
| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false |
|
||||||
| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data |
|
| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data |
|
||||||
| VIDEOINFO_CACHE_MINUTES | 私人影库视频信息在内存中的缓存时长(分钟) | 正整数 | 1440(1天) |
|
| VIDEOINFO_CACHE_MINUTES | 私人影库视频信息在内存中的缓存时长(分钟) | 正整数 | 1440(1天) |
|
||||||
| NEXT_PUBLIC_ENABLE_SOURCE_SEARCH | 是否开启源站寻片功能 | true/false | true |
|
| NEXT_PUBLIC_ENABLE_SOURCE_SEARCH | 是否开启源站寻片功能 | true/false | true |
|
||||||
| MAX_PLAY_RECORDS_PER_USER | 单个用户播放记录清理阈值(超过此数量将自动清理旧记录) | 正整数 | 100 |
|
| MAX_PLAY_RECORDS_PER_USER | 单个用户播放记录清理阈值(超过此数量将自动清理旧记录) | 正整数 | 100 |
|
||||||
| INIT_CONFIG | 初始配置(JSON 格式,包含 api_site、custom_category、lives 等) | JSON 字符串 | (空) |
|
| INIT_CONFIG | 初始配置(JSON 格式,包含 api_site、custom_category、lives 等) | JSON 字符串 | (空) |
|
||||||
| CONFIG_SUBSCRIPTION_URL | 配置订阅 URL(Base58 编码的配置文件地址,优先级高于 INIT_CONFIG) | URL | (空) |
|
| CONFIG_SUBSCRIPTION_URL | 配置订阅 URL(Base58 编码的配置文件地址,优先级高于 INIT_CONFIG) | URL | (空) |
|
||||||
| TMDB_API_KEY | TMDB API 密钥 | 任意字符串 | (空) |
|
| TMDB_API_KEY | TMDB API 密钥 | 任意字符串 | (空) |
|
||||||
| TMDB_PROXY | TMDB 代理地址 | URL | (空) |
|
| TMDB_PROXY | TMDB 代理地址 | URL | (空) |
|
||||||
| TMDB_REVERSE_PROXY | TMDB 反向代理地址 | URL | (空) |
|
| TMDB_REVERSE_PROXY | TMDB 反向代理地址 | URL | (空) |
|
||||||
| DANMAKU_API_BASE | 弹幕 API 地址 | URL | http://localhost:9321 |
|
| DANMAKU_API_BASE | 弹幕 API 地址 | URL | http://localhost:9321 |
|
||||||
| DANMAKU_API_TOKEN | 弹幕 API Token | 任意字符串 | 87654321 |
|
| DANMAKU_API_TOKEN | 弹幕 API Token | 任意字符串 | 87654321 |
|
||||||
|
|
||||||
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
||||||
|
|
||||||
@@ -307,13 +395,16 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
|
|||||||
**配置步骤:**
|
**配置步骤:**
|
||||||
|
|
||||||
1. 按照 [watch-room-server](https://github.com/tgs9915/watch-room-server) 的文档部署外部服务器
|
1. 按照 [watch-room-server](https://github.com/tgs9915/watch-room-server) 的文档部署外部服务器
|
||||||
|
|
||||||
2. 在 MoonTVPlus 中设置以下环境变量:
|
2. 在 MoonTVPlus 中设置以下环境变量:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
WATCH_ROOM_ENABLED=true
|
WATCH_ROOM_ENABLED=true
|
||||||
WATCH_ROOM_SERVER_TYPE=external
|
WATCH_ROOM_SERVER_TYPE=external
|
||||||
WATCH_ROOM_EXTERNAL_SERVER_URL=wss://your-watch-room-server.com
|
WATCH_ROOM_EXTERNAL_SERVER_URL=wss://your-watch-room-server.com
|
||||||
WATCH_ROOM_EXTERNAL_SERVER_AUTH=your_secure_token
|
WATCH_ROOM_EXTERNAL_SERVER_AUTH=your_secure_token
|
||||||
```
|
```
|
||||||
|
|
||||||
3. 重启应用即可使用外部观影室服务器
|
3. 重启应用即可使用外部观影室服务器
|
||||||
|
|
||||||
|
|
||||||
@@ -332,6 +423,7 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
|
|||||||
|
|
||||||
|
|
||||||
## 超分功能说明
|
## 超分功能说明
|
||||||
|
|
||||||
超分功能需要浏览器支持webgpu并且你的浏览器环境不能是http(如非要在http中使用,需要在浏览器端设置允许不安全的内容)
|
超分功能需要浏览器支持webgpu并且你的浏览器环境不能是http(如非要在http中使用,需要在浏览器端设置允许不安全的内容)
|
||||||
|
|
||||||
|
|
||||||
@@ -350,13 +442,14 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
|
|||||||
### 配置步骤
|
### 配置步骤
|
||||||
|
|
||||||
1. 在环境变量中设置以下配置:
|
1. 在环境变量中设置以下配置:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# 启用 TVBOX 订阅功能
|
# 启用 TVBOX 订阅功能
|
||||||
ENABLE_TVBOX_SUBSCRIBE=true
|
ENABLE_TVBOX_SUBSCRIBE=true
|
||||||
# 设置订阅访问 Token(请使用强密码)
|
# 设置订阅访问 Token(请使用强密码)
|
||||||
TVBOX_SUBSCRIBE_TOKEN=your_secure_random_token
|
TVBOX_SUBSCRIBE_TOKEN=your_secure_random_token
|
||||||
```
|
```
|
||||||
|
|
||||||
2. 重启应用后,登录网站,点击用户菜单中的"订阅"按钮
|
2. 重启应用后,登录网站,点击用户菜单中的"订阅"按钮
|
||||||
|
|
||||||
3. 复制生成的订阅链接到 TVBOX 应用中使用
|
3. 复制生成的订阅链接到 TVBOX 应用中使用
|
||||||
@@ -398,4 +491,4 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
|
|||||||
|
|
||||||
## Star History
|
## 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
|
210.1.3
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { DownloadPanel } from '../components/DownloadPanel';
|
|||||||
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
|
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
|
||||||
import { SiteProvider } from '../components/SiteProvider';
|
import { SiteProvider } from '../components/SiteProvider';
|
||||||
import { ThemeProvider } from '../components/ThemeProvider';
|
import { ThemeProvider } from '../components/ThemeProvider';
|
||||||
|
import { TokenRefreshManager } from '../components/TokenRefreshManager';
|
||||||
import TopProgressBar from '../components/TopProgressBar';
|
import TopProgressBar from '../components/TopProgressBar';
|
||||||
import ChatFloatingWindow from '../components/watch-room/ChatFloatingWindow';
|
import ChatFloatingWindow from '../components/watch-room/ChatFloatingWindow';
|
||||||
import { WatchRoomProvider } from '../components/WatchRoomProvider';
|
import { WatchRoomProvider } from '../components/WatchRoomProvider';
|
||||||
@@ -222,6 +223,7 @@ export default async function RootLayout({
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<TopProgressBar />
|
<TopProgressBar />
|
||||||
|
<TokenRefreshManager />
|
||||||
<SiteProvider siteName={siteName} announcement={announcement} tmdbApiKey={tmdbApiKey}>
|
<SiteProvider siteName={siteName} announcement={announcement} tmdbApiKey={tmdbApiKey}>
|
||||||
<WatchRoomProvider>
|
<WatchRoomProvider>
|
||||||
<DownloadProvider>
|
<DownloadProvider>
|
||||||
|
|||||||
146
src/app/page.tsx
146
src/app/page.tsx
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
'use client';
|
'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 Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { Suspense, useEffect, useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
import { getDoubanCategories } from '@/lib/douban.client';
|
import { getDoubanCategories } from '@/lib/douban.client';
|
||||||
import { getTMDBImageUrl, TMDBItem } from '@/lib/tmdb.client';
|
import { getTMDBImageUrl, TMDBItem } from '@/lib/tmdb.client';
|
||||||
import { DoubanItem } from '@/lib/types';
|
import { DoubanItem } from '@/lib/types';
|
||||||
import { processImageUrl } from '@/lib/utils';
|
import { base58Encode, processImageUrl } from '@/lib/utils';
|
||||||
|
|
||||||
import AIChatPanel from '@/components/AIChatPanel';
|
import AIChatPanel from '@/components/AIChatPanel';
|
||||||
import BannerCarousel from '@/components/BannerCarousel';
|
import BannerCarousel from '@/components/BannerCarousel';
|
||||||
@@ -45,6 +46,7 @@ function HomeClient() {
|
|||||||
>([]);
|
>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { announcement } = useSite();
|
const { announcement } = useSite();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// 首页模块配置状态
|
// 首页模块配置状态
|
||||||
const [homeModules, setHomeModules] = useState<HomeModule[]>([
|
const [homeModules, setHomeModules] = useState<HomeModule[]>([
|
||||||
@@ -55,6 +57,8 @@ function HomeClient() {
|
|||||||
{ id: 'hotVarietyShows', name: '热门综艺', enabled: true, order: 4 },
|
{ id: 'hotVarietyShows', name: '热门综艺', enabled: true, order: 4 },
|
||||||
{ id: 'upcomingContent', name: '即将上映', enabled: true, order: 5 },
|
{ id: 'upcomingContent', name: '即将上映', enabled: true, order: 5 },
|
||||||
]);
|
]);
|
||||||
|
const [homeBannerEnabled, setHomeBannerEnabled] = useState(true);
|
||||||
|
const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = useState(true);
|
||||||
|
|
||||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||||
const [showHttpWarning, setShowHttpWarning] = useState(true);
|
const [showHttpWarning, setShowHttpWarning] = useState(true);
|
||||||
@@ -62,34 +66,56 @@ function HomeClient() {
|
|||||||
const [aiEnabled, setAiEnabled] = useState(false);
|
const [aiEnabled, setAiEnabled] = useState(false);
|
||||||
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?');
|
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?');
|
||||||
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
loadHomeLayoutSettings();
|
||||||
const savedHomeModules = localStorage.getItem('homeModules');
|
|
||||||
if (savedHomeModules) {
|
|
||||||
try {
|
|
||||||
setHomeModules(JSON.parse(savedHomeModules));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('解析首页模块配置失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 监听首页模块配置更新事件
|
// 监听首页模块配置更新事件
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleHomeModulesUpdated = () => {
|
const handleHomeModulesUpdated = () => {
|
||||||
if (typeof window !== 'undefined') {
|
loadHomeLayoutSettings();
|
||||||
const savedHomeModules = localStorage.getItem('homeModules');
|
|
||||||
if (savedHomeModules) {
|
|
||||||
try {
|
|
||||||
setHomeModules(JSON.parse(savedHomeModules));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('解析首页模块配置失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('homeModulesUpdated', handleHomeModulesUpdated);
|
window.addEventListener('homeModulesUpdated', handleHomeModulesUpdated);
|
||||||
@@ -547,16 +573,26 @@ function HomeClient() {
|
|||||||
<PageLayout>
|
<PageLayout>
|
||||||
<FireworksCanvas />
|
<FireworksCanvas />
|
||||||
{/* TMDB 热门轮播图 */}
|
{/* TMDB 热门轮播图 */}
|
||||||
<div className='w-full mb-4'>
|
{homeBannerEnabled && (
|
||||||
<BannerCarousel />
|
<div className='w-full mb-4'>
|
||||||
</div>
|
<BannerCarousel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className='px-2 sm:px-10 pb-4 sm:pb-8 overflow-visible'>
|
<div className='px-2 sm:px-10 pb-4 sm:pb-8 overflow-visible'>
|
||||||
<div className='max-w-[95%] mx-auto'>
|
<div className='max-w-[95%] mx-auto'>
|
||||||
{/* 首页内容 */}
|
{/* 首页内容 */}
|
||||||
<>
|
<>
|
||||||
{/* 源站寻片和AI问片入口 */}
|
{/* 源站寻片和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 && (
|
{sourceSearchEnabled && (
|
||||||
<Link href='/source-search'>
|
<Link href='/source-search'>
|
||||||
@@ -582,7 +618,7 @@ function HomeClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 继续观看 */}
|
{/* 继续观看 */}
|
||||||
<ContinueWatching />
|
{homeContinueWatchingEnabled && <ContinueWatching />}
|
||||||
|
|
||||||
{/* 根据配置动态渲染首页模块 */}
|
{/* 根据配置动态渲染首页模块 */}
|
||||||
{homeModules
|
{homeModules
|
||||||
@@ -626,6 +662,62 @@ function HomeClient() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import {
|
|||||||
import { getDoubanDetail } from '@/lib/douban.client';
|
import { getDoubanDetail } from '@/lib/douban.client';
|
||||||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||||
import { DanmakuFilterConfig, EpisodeFilterConfig,SearchResult } from '@/lib/types';
|
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 { useEnableComments } from '@/hooks/useEnableComments';
|
||||||
import { usePlaySync } from '@/hooks/usePlaySync';
|
import { usePlaySync } from '@/hooks/usePlaySync';
|
||||||
|
|
||||||
@@ -488,6 +488,7 @@ function PlayPageClient() {
|
|||||||
const [currentSource, setCurrentSource] = useState(searchParams.get('source') || '');
|
const [currentSource, setCurrentSource] = useState(searchParams.get('source') || '');
|
||||||
const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
|
const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
|
||||||
const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名
|
const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名
|
||||||
|
const isDirectPlay = currentSource === 'directplay';
|
||||||
|
|
||||||
// 解析 source 参数以获取 embyKey(仅用于 API 调用)
|
// 解析 source 参数以获取 embyKey(仅用于 API 调用)
|
||||||
const parseSourceForApi = (source: string): { source: string; embyKey?: string } => {
|
const parseSourceForApi = (source: string): { source: string; embyKey?: string } => {
|
||||||
@@ -601,6 +602,10 @@ function PlayPageClient() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDirectPlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否禁用了自动装填弹幕
|
// 检查是否禁用了自动装填弹幕
|
||||||
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
||||||
if (disableAutoLoad) {
|
if (disableAutoLoad) {
|
||||||
@@ -938,11 +943,19 @@ function PlayPageClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadDanmakuForCurrentEpisode();
|
loadDanmakuForCurrentEpisode();
|
||||||
}, [currentEpisodeIndex, videoTitle, loading]);
|
}, [currentEpisodeIndex, videoTitle, loading, isDirectPlay]);
|
||||||
|
|
||||||
// 获取豆瓣评分数据
|
// 获取豆瓣评分数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDoubanRating = async () => {
|
const fetchDoubanRating = async () => {
|
||||||
|
if (isDirectPlay) {
|
||||||
|
setDoubanRating(null);
|
||||||
|
setDoubanCardSubtitle('');
|
||||||
|
setDoubanAka([]);
|
||||||
|
setDoubanYear('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!videoDoubanId || videoDoubanId === 0) {
|
if (!videoDoubanId || videoDoubanId === 0) {
|
||||||
setDoubanRating(null);
|
setDoubanRating(null);
|
||||||
setDoubanCardSubtitle('');
|
setDoubanCardSubtitle('');
|
||||||
@@ -994,11 +1007,16 @@ function PlayPageClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchDoubanRating();
|
fetchDoubanRating();
|
||||||
}, [videoDoubanId]);
|
}, [videoDoubanId, isDirectPlay]);
|
||||||
|
|
||||||
// 获取TMDB背景图
|
// 获取TMDB背景图
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTMDBBackdrop = async () => {
|
const fetchTMDBBackdrop = async () => {
|
||||||
|
if (isDirectPlay) {
|
||||||
|
setTmdbBackdrop(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否禁用背景图
|
// 检查是否禁用背景图
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const disabled = localStorage.getItem('tmdb_backdrop_disabled');
|
const disabled = localStorage.getItem('tmdb_backdrop_disabled');
|
||||||
@@ -1146,7 +1164,7 @@ function PlayPageClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchTMDBBackdrop();
|
fetchTMDBBackdrop();
|
||||||
}, [videoTitle, videoDoubanId]);
|
}, [videoTitle, videoDoubanId, isDirectPlay]);
|
||||||
|
|
||||||
|
|
||||||
// 视频播放地址
|
// 视频播放地址
|
||||||
@@ -1168,6 +1186,14 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
// 总集数
|
// 总集数
|
||||||
const totalEpisodes = detail?.episodes?.length || 0;
|
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 后跳转到指定进度
|
// 用于记录是否需要在播放器 ready 后跳转到指定进度
|
||||||
const resumeTimeRef = useRef<number | null>(null);
|
const resumeTimeRef = useRef<number | null>(null);
|
||||||
@@ -2808,6 +2834,73 @@ function PlayPageClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initAll = async () => {
|
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) {
|
if (!currentSource && !currentId && !videoTitle && !searchTitle) {
|
||||||
setError('缺少必要参数');
|
setError('缺少必要参数');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -3698,6 +3791,7 @@ function PlayPageClient() {
|
|||||||
// 预加载下一集弹幕(完全复制 loadDanmakuForCurrentEpisode 的逻辑)
|
// 预加载下一集弹幕(完全复制 loadDanmakuForCurrentEpisode 的逻辑)
|
||||||
const preloadNextEpisodeDanmaku = async () => {
|
const preloadNextEpisodeDanmaku = async () => {
|
||||||
try {
|
try {
|
||||||
|
if (isDirectPlay) return;
|
||||||
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
||||||
if (disableAutoLoad) return;
|
if (disableAutoLoad) return;
|
||||||
|
|
||||||
@@ -4043,6 +4137,7 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
// 自动搜索并加载弹幕
|
// 自动搜索并加载弹幕
|
||||||
const autoSearchDanmaku = async () => {
|
const autoSearchDanmaku = async () => {
|
||||||
|
if (isDirectPlay) return;
|
||||||
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
||||||
if (disableAutoLoad) return;
|
if (disableAutoLoad) return;
|
||||||
|
|
||||||
@@ -4616,9 +4711,7 @@ function PlayPageClient() {
|
|||||||
// 非WebKit浏览器且播放器已存在,使用switch方法切换
|
// 非WebKit浏览器且播放器已存在,使用switch方法切换
|
||||||
if (!isWebkit && artPlayerRef.current) {
|
if (!isWebkit && artPlayerRef.current) {
|
||||||
artPlayerRef.current.switch = videoUrl;
|
artPlayerRef.current.switch = videoUrl;
|
||||||
artPlayerRef.current.title = `${videoTitle} - 第${
|
artPlayerRef.current.title = `${videoTitle} - ${playerEpisodeLabel}`;
|
||||||
currentEpisodeIndex + 1
|
|
||||||
}集`;
|
|
||||||
artPlayerRef.current.poster = videoCover;
|
artPlayerRef.current.poster = videoCover;
|
||||||
if (artPlayerRef.current?.video) {
|
if (artPlayerRef.current?.video) {
|
||||||
ensureVideoSource(
|
ensureVideoSource(
|
||||||
@@ -6993,12 +7086,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'}`}>
|
<h1 className={`text-xl font-semibold flex items-center gap-2 flex-wrap ${tmdbBackdrop ? 'text-white' : 'text-gray-900 dark:text-gray-100'}`}>
|
||||||
<span>
|
<span>
|
||||||
{videoTitle || '影片标题'}
|
{videoTitle || '影片标题'}
|
||||||
{totalEpisodes > 1 && (
|
{shouldShowEpisodeLabel && (
|
||||||
<span className={tmdbBackdrop ? 'text-white opacity-80' : 'text-gray-500 dark:text-gray-400'}>
|
<span className={tmdbBackdrop ? 'text-white opacity-80' : 'text-gray-500 dark:text-gray-400'}>
|
||||||
{` > ${
|
{` > ${episodeLabel}`}
|
||||||
detail?.episodes_titles?.[currentEpisodeIndex] ||
|
|
||||||
`第 ${currentEpisodeIndex + 1} 集`
|
|
||||||
}`}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -7568,254 +7658,258 @@ function PlayPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 详情展示 */}
|
{!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='grid grid-cols-1 md:grid-cols-5 lg:grid-cols-6 gap-4'>
|
||||||
<div className='p-6 flex flex-col min-h-0'>
|
{/* 文字区 */}
|
||||||
{/* 标题 */}
|
<div className='md:col-span-4 lg:col-span-5'>
|
||||||
<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'}`}>
|
<div className='p-6 flex flex-col min-h-0'>
|
||||||
<span className={doubanAka.length > 0 ? 'relative group cursor-help' : ''}>
|
{/* 标题 */}
|
||||||
{videoTitle || '影片标题'}
|
<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'}`}>
|
||||||
{/* aka 悬浮提示 */}
|
<span className={doubanAka.length > 0 ? 'relative group cursor-help' : ''}>
|
||||||
{doubanAka.length > 0 && (
|
{videoTitle || '影片标题'}
|
||||||
<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'>
|
{/* aka 悬浮提示 */}
|
||||||
<div className='font-semibold text-xs text-gray-400 mb-1'>又名:</div>
|
{doubanAka.length > 0 && (
|
||||||
{doubanAka.map((name, index) => (
|
<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 key={index} className='text-sm'>
|
<div className='font-semibold text-xs text-gray-400 mb-1'>又名:</div>
|
||||||
{name}
|
{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>
|
||||||
))}
|
)}
|
||||||
<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>
|
||||||
{/* 评分人数 */}
|
<button
|
||||||
<span className='text-gray-500 dark:text-gray-400 text-sm'>
|
onClick={(e) => {
|
||||||
({doubanRating.count.toLocaleString()}人评价)
|
e.stopPropagation();
|
||||||
</span>
|
handleToggleFavorite();
|
||||||
</div>
|
}}
|
||||||
)}
|
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||||
</h1>
|
>
|
||||||
|
<FavoriteIcon filled={favorited} />
|
||||||
{/* 关键信息行 */}
|
</button>
|
||||||
<div className={`flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0 ${tmdbBackdrop ? 'text-white' : ''}`}>
|
{/* 网盘搜索按钮 */}
|
||||||
{detail?.class && (
|
<button
|
||||||
<span className='text-green-600 font-semibold'>
|
onClick={(e) => {
|
||||||
{detail.class}
|
e.stopPropagation();
|
||||||
</span>
|
setShowPansouDialog(true);
|
||||||
)}
|
}}
|
||||||
{/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */}
|
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||||
{(doubanYear || detail?.year || videoYear) && (
|
title='搜索网盘资源'
|
||||||
<span>{doubanYear || detail?.year || videoYear}</span>
|
>
|
||||||
)}
|
<Cloud className='h-6 w-6 text-gray-700 dark:text-gray-300' />
|
||||||
{detail?.source_name && (
|
</button>
|
||||||
<span className={`border px-2 py-[1px] rounded ${
|
{/* AI问片按钮 */}
|
||||||
detail.source === 'xiaoya' ? 'border-blue-500' : detail.source === 'openlist' || detail.source === 'emby' || detail.source?.startsWith('emby_') ? 'border-yellow-500' : 'border-gray-500/60'
|
{aiEnabled && detail && (
|
||||||
}`}>
|
<button
|
||||||
{detail.source_name}
|
onClick={(e) => {
|
||||||
</span>
|
e.stopPropagation();
|
||||||
)}
|
setShowAIChat(true);
|
||||||
{detail?.type_name && <span>{detail.type_name}</span>}
|
}}
|
||||||
</div>
|
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||||
{/* 剧情简介 */}
|
title='AI问片'
|
||||||
{(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'>
|
<Sparkles className='h-6 w-6 text-gray-700 dark:text-gray-300' />
|
||||||
<svg
|
</button>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
{/* 纠错按钮 - 仅小雅源显示 */}
|
||||||
) : (
|
{detail && detail.source === 'xiaoya' && (
|
||||||
<span className='text-gray-600 dark:text-gray-400'>
|
<button
|
||||||
封面图片
|
onClick={(e) => {
|
||||||
</span>
|
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>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 推荐区域 */}
|
{/* 推荐区域 */}
|
||||||
<SmartRecommendations
|
<SmartRecommendations
|
||||||
doubanId={videoDoubanId !== 0 ? videoDoubanId : undefined}
|
doubanId={videoDoubanId !== 0 ? videoDoubanId : undefined}
|
||||||
videoTitle={videoTitle}
|
videoTitle={videoTitle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 豆瓣评论区域 */}
|
{/* 豆瓣评论区域 */}
|
||||||
{videoDoubanId !== 0 && enableComments && (
|
{videoDoubanId !== 0 && enableComments && (
|
||||||
<div className='mt-6 -mx-3 md:mx-0 md:px-4'>
|
<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='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'>
|
<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'>
|
<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'>
|
<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'/>
|
<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>
|
</svg>
|
||||||
豆瓣评论
|
豆瓣评论
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 评论内容 */}
|
||||||
|
<div className='p-3 md:p-6'>
|
||||||
|
<DoubanComments doubanId={videoDoubanId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* 评论内容 */}
|
</>
|
||||||
<div className='p-3 md:p-6'>
|
|
||||||
<DoubanComments doubanId={videoDoubanId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* 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 { useRouter } from 'next/navigation';
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -811,8 +811,10 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
}`.trim()}
|
}`.trim()}
|
||||||
>
|
>
|
||||||
{/* 封面 */}
|
{/* 封面 */}
|
||||||
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'>
|
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden flex items-center justify-center'>
|
||||||
{source.poster && (
|
{source.source === 'directplay' ? (
|
||||||
|
<LinkIcon className='w-6 h-6 text-blue-500' />
|
||||||
|
) : source.poster ? (
|
||||||
<img
|
<img
|
||||||
src={processImageUrl(source.poster)}
|
src={processImageUrl(source.poster)}
|
||||||
alt={source.title}
|
alt={source.title}
|
||||||
@@ -822,7 +824,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
target.style.display = 'none';
|
target.style.display = 'none';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 信息区域 */}
|
{/* 信息区域 */}
|
||||||
|
|||||||
184
src/components/TokenRefreshManager.tsx
Normal file
184
src/components/TokenRefreshManager.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { getAuthInfoFromBrowserCookie, clearAuthCookie } from '@/lib/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
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 = 4 * 60 * 60 * 1000; // 4 小时
|
||||||
|
const age = now - authInfo.timestamp;
|
||||||
|
const remaining = ACCESS_TOKEN_AGE - age;
|
||||||
|
|
||||||
|
// 剩余时间 < 10 分钟时需要刷新(包括已过期的情况)
|
||||||
|
const REFRESH_THRESHOLD = 10 * 60 * 1000; // 10 分钟
|
||||||
|
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() && !isRefreshing) {
|
||||||
|
console.log('[Token] Expiring soon, refreshing proactively...');
|
||||||
|
await refreshToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
let response = await originalFetch(input, init);
|
||||||
|
|
||||||
|
// 响应拦截:401 错误时刷新 Token 并重试(仅重试一次)
|
||||||
|
if (response.status === 401 && !isRefreshing) {
|
||||||
|
console.log('[Token] Received 401, 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');
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [homeModules, setHomeModules] = useState<HomeModule[]>(defaultHomeModules);
|
||||||
|
const [homeBannerEnabled, setHomeBannerEnabled] = useState(true);
|
||||||
|
const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = useState(true);
|
||||||
|
|
||||||
// 豆瓣数据源选项
|
// 豆瓣数据源选项
|
||||||
const doubanDataSourceOptions = [
|
const doubanDataSourceOptions = [
|
||||||
@@ -443,6 +445,16 @@ export const UserMenu: React.FC = () => {
|
|||||||
setDanmakuHeatmapDisabled(savedDanmakuHeatmapDisabled === 'true');
|
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');
|
const savedHomeModules = localStorage.getItem('homeModules');
|
||||||
if (savedHomeModules !== null) {
|
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 handleHomeModuleToggle = (id: string, enabled: boolean) => {
|
||||||
const updatedModules = homeModules.map(module =>
|
const updatedModules = homeModules.map(module =>
|
||||||
@@ -1010,6 +1038,8 @@ export const UserMenu: React.FC = () => {
|
|||||||
setNextEpisodePreCache(true);
|
setNextEpisodePreCache(true);
|
||||||
setNextEpisodeDanmakuPreload(true);
|
setNextEpisodeDanmakuPreload(true);
|
||||||
setDisableAutoLoadDanmaku(false);
|
setDisableAutoLoadDanmaku(false);
|
||||||
|
setHomeBannerEnabled(true);
|
||||||
|
setHomeContinueWatchingEnabled(true);
|
||||||
setHomeModules(defaultHomeModules);
|
setHomeModules(defaultHomeModules);
|
||||||
setSearchTraditionalToSimplified(false);
|
setSearchTraditionalToSimplified(false);
|
||||||
|
|
||||||
@@ -1031,6 +1061,8 @@ export const UserMenu: React.FC = () => {
|
|||||||
localStorage.setItem('disableAutoLoadDanmaku', 'false');
|
localStorage.setItem('disableAutoLoadDanmaku', 'false');
|
||||||
localStorage.setItem('danmakuMaxCount', '0');
|
localStorage.setItem('danmakuMaxCount', '0');
|
||||||
localStorage.setItem('danmaku_heatmap_disabled', 'false');
|
localStorage.setItem('danmaku_heatmap_disabled', 'false');
|
||||||
|
localStorage.setItem('homeBannerEnabled', 'true');
|
||||||
|
localStorage.setItem('homeContinueWatchingEnabled', 'true');
|
||||||
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
|
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
|
||||||
localStorage.setItem('searchTraditionalToSimplified', 'false');
|
localStorage.setItem('searchTraditionalToSimplified', 'false');
|
||||||
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
||||||
@@ -2189,6 +2221,55 @@ export const UserMenu: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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'>
|
<div className='space-y-2'>
|
||||||
{homeModules.map((module, index) => (
|
{homeModules.map((module, index) => (
|
||||||
@@ -2247,8 +2328,12 @@ export const UserMenu: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setHomeModules(defaultHomeModules);
|
setHomeModules(defaultHomeModules);
|
||||||
|
setHomeBannerEnabled(true);
|
||||||
|
setHomeContinueWatchingEnabled(true);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
|
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
|
||||||
|
localStorage.setItem('homeBannerEnabled', 'true');
|
||||||
|
localStorage.setItem('homeContinueWatchingEnabled', 'true');
|
||||||
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
const actualYear = year;
|
const actualYear = year;
|
||||||
const actualQuery = query || '';
|
const actualQuery = query || '';
|
||||||
const actualSearchType = type;
|
const actualSearchType = type;
|
||||||
|
const isDirectPlaySource = actualSource === 'directplay';
|
||||||
const displayYear = useMemo(() => {
|
const displayYear = useMemo(() => {
|
||||||
if (!actualYear) return '';
|
if (!actualYear) return '';
|
||||||
const normalized = actualYear.trim();
|
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]'} />}
|
{!isLoading && !isDirectPlaySource && <ImagePlaceholder aspectRatio={orientation === 'horizontal' ? 'aspect-[3/2]' : 'aspect-[2/3]'} />}
|
||||||
{/* 图片 */}
|
{isDirectPlaySource ? (
|
||||||
<Image
|
<div className='absolute inset-0 flex items-center justify-center bg-gray-200/80 dark:bg-gray-700/80'>
|
||||||
src={processImageUrl(actualPoster)}
|
<Link className='w-8 h-8 text-blue-500' />
|
||||||
alt={actualTitle}
|
</div>
|
||||||
fill
|
) : (
|
||||||
className={origin === 'live' ? 'object-contain' : orientation === 'horizontal' ? 'object-cover object-center' : 'object-cover'}
|
<Image
|
||||||
referrerPolicy='no-referrer'
|
src={processImageUrl(actualPoster)}
|
||||||
loading='lazy'
|
alt={actualTitle}
|
||||||
onLoadingComplete={() => setIsLoading(true)}
|
fill
|
||||||
onError={(e) => {
|
className={origin === 'live' ? 'object-contain' : orientation === 'horizontal' ? 'object-cover object-center' : 'object-cover'}
|
||||||
// 图片加载失败时的重试机制
|
referrerPolicy='no-referrer'
|
||||||
const img = e.target as HTMLImageElement;
|
loading='lazy'
|
||||||
if (!img.dataset.retried) {
|
onLoadingComplete={() => setIsLoading(true)}
|
||||||
img.dataset.retried = 'true';
|
onError={(e) => {
|
||||||
setTimeout(() => {
|
// 图片加载失败时的重试机制
|
||||||
img.src = processImageUrl(actualPoster);
|
const img = e.target as HTMLImageElement;
|
||||||
}, 2000);
|
if (!img.dataset.retried) {
|
||||||
}
|
img.dataset.retried = 'true';
|
||||||
}}
|
setTimeout(() => {
|
||||||
style={{
|
img.src = processImageUrl(actualPoster);
|
||||||
// 禁用图片的默认长按效果
|
}, 2000);
|
||||||
WebkitUserSelect: 'none',
|
}
|
||||||
userSelect: 'none',
|
}}
|
||||||
WebkitTouchCallout: 'none',
|
style={{
|
||||||
pointerEvents: 'none', // 图片不响应任何指针事件
|
// 禁用图片的默认长按效果
|
||||||
} as React.CSSProperties}
|
WebkitUserSelect: 'none',
|
||||||
onContextMenu={(e) => {
|
userSelect: 'none',
|
||||||
e.preventDefault();
|
WebkitTouchCallout: 'none',
|
||||||
return false;
|
pointerEvents: 'none', // 图片不响应任何指针事件
|
||||||
}}
|
} as React.CSSProperties}
|
||||||
onDragStart={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
/>
|
onDragStart={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 悬浮遮罩 */}
|
{/* 悬浮遮罩 */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -111,3 +111,19 @@ export function getAuthInfoFromBrowserCookie(): AuthInfo | null {
|
|||||||
return 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,56 @@ export interface ChangelogEntry {
|
|||||||
|
|
||||||
export const changelog: ChangelogEntry[] = [
|
export const changelog: ChangelogEntry[] = [
|
||||||
{
|
{
|
||||||
|
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',
|
version: '210.0.0',
|
||||||
date: '2026-01-25',
|
date: '2026-01-25',
|
||||||
added: [
|
added: [
|
||||||
"新增网络直播功能",
|
"新增网络直播功能",
|
||||||
"动漫磁力搜索增加蜜柑",
|
"动漫磁力搜索增加蜜柑",
|
||||||
"弹幕磁力搜索增加动漫花园",
|
"动漫磁力搜索增加动漫花园",
|
||||||
"pansou增加关键词过滤",
|
"pansou增加关键词过滤",
|
||||||
"cloudflare workers部署支持",
|
"cloudflare workers部署支持",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
|
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getAuthInfoFromBrowserCookie } from './auth';
|
import { getAuthInfoFromBrowserCookie, clearAuthCookie } from './auth';
|
||||||
import { DanmakuFilterConfig, EpisodeFilterConfig,SkipConfig } from './types';
|
import { DanmakuFilterConfig, EpisodeFilterConfig,SkipConfig } from './types';
|
||||||
|
|
||||||
// 全局错误触发函数
|
// 全局错误触发函数
|
||||||
@@ -521,27 +521,62 @@ async function fetchWithAuth(
|
|||||||
url: string,
|
url: string,
|
||||||
options?: RequestInit
|
options?: RequestInit
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const res = await fetch(url, options);
|
let res = await fetch(url, options);
|
||||||
if (!res.ok) {
|
|
||||||
// 如果是 401 未授权,跳转到登录页面
|
// 如果是 401 且是 token 过期,尝试刷新并重试
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
// 调用 logout 接口
|
const text = await res.clone().text();
|
||||||
try {
|
if (text === 'Access token expired') {
|
||||||
await fetch('/api/logout', {
|
// 检查是否是登录相关的接口,如果是则不刷新
|
||||||
method: 'POST',
|
if (
|
||||||
headers: { 'Content-Type': 'application/json' },
|
url.includes('/api/login') ||
|
||||||
});
|
url.includes('/api/register') ||
|
||||||
} catch (error) {
|
url.includes('/api/auth/oidc') ||
|
||||||
console.error('注销请求失败:', error);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果刷新后仍然是 401,或者是其他 401 错误,跳转登录
|
||||||
|
if (res.status === 401) {
|
||||||
|
// 检查当前页面是否已经是登录页,避免重复跳转
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
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('用户未授权,已跳转到登录页面');
|
throw new Error('用户未授权,已跳转到登录页面');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
throw new Error(`请求 ${url} 失败: ${res.status}`);
|
throw new Error(`请求 ${url} 失败: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ export async function refreshLiveChannels(liveInfo: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const data = await response.text();
|
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 epgUrl = liveInfo.epg || result.tvgUrl;
|
||||||
const tvgIds = result.channels
|
const tvgIds = result.channels
|
||||||
.map((channel) => channel.tvgId)
|
.map((channel) => channel.tvgId)
|
||||||
@@ -367,6 +369,86 @@ function parseEpgLines(
|
|||||||
return result;
|
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文件内容,提取频道信息
|
* 解析M3U文件内容,提取频道信息
|
||||||
* @param m3uContent M3U文件的内容字符串
|
* @param m3uContent M3U文件的内容字符串
|
||||||
@@ -397,7 +479,7 @@ function parseM3U(
|
|||||||
|
|
||||||
const lines = m3uContent
|
const lines = m3uContent
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line) => line.trim())
|
.map((line) => stripBom(line).trim())
|
||||||
.filter((line) => line.length > 0);
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
let tvgUrl = '';
|
let tvgUrl = '';
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
import { getStorage } from './db';
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
// Token 配置
|
// Token 配置
|
||||||
export const TOKEN_CONFIG = {
|
export const TOKEN_CONFIG = {
|
||||||
@@ -38,7 +47,7 @@ export async function storeRefreshToken(
|
|||||||
tokenData: TokenData
|
tokenData: TokenData
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const hashKey = `user_tokens:${username}`;
|
const hashKey = `user_tokens:${username}`;
|
||||||
const storage = getStorage();
|
const storage = await loadStorage();
|
||||||
|
|
||||||
if (!storage || typeof (storage as any).adapter?.hSet !== 'function') {
|
if (!storage || typeof (storage as any).adapter?.hSet !== 'function') {
|
||||||
console.warn('Redis Hash not supported, skipping token storage');
|
console.warn('Redis Hash not supported, skipping token storage');
|
||||||
@@ -65,7 +74,7 @@ export async function verifyRefreshToken(
|
|||||||
refreshToken: string
|
refreshToken: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const hashKey = `user_tokens:${username}`;
|
const hashKey = `user_tokens:${username}`;
|
||||||
const storage = getStorage();
|
const storage = await loadStorage();
|
||||||
|
|
||||||
if (!storage || typeof (storage as any).adapter?.hGet !== 'function') {
|
if (!storage || typeof (storage as any).adapter?.hGet !== 'function') {
|
||||||
console.warn('Redis Hash not supported');
|
console.warn('Redis Hash not supported');
|
||||||
@@ -114,7 +123,7 @@ export async function revokeRefreshToken(
|
|||||||
tokenId: string
|
tokenId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const hashKey = `user_tokens:${username}`;
|
const hashKey = `user_tokens:${username}`;
|
||||||
const storage = getStorage();
|
const storage = await loadStorage();
|
||||||
|
|
||||||
if (!storage || typeof (storage as any).adapter?.hDel !== 'function') {
|
if (!storage || typeof (storage as any).adapter?.hDel !== 'function') {
|
||||||
console.warn('Redis Hash not supported');
|
console.warn('Redis Hash not supported');
|
||||||
@@ -138,7 +147,7 @@ export async function getUserDevices(username: string): Promise<Array<{
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}>> {
|
}>> {
|
||||||
const hashKey = `user_tokens:${username}`;
|
const hashKey = `user_tokens:${username}`;
|
||||||
const storage = getStorage();
|
const storage = await loadStorage();
|
||||||
|
|
||||||
if (!storage || typeof (storage as any).adapter?.hGetAll !== 'function') {
|
if (!storage || typeof (storage as any).adapter?.hGetAll !== 'function') {
|
||||||
console.warn('Redis Hash not supported');
|
console.warn('Redis Hash not supported');
|
||||||
@@ -188,7 +197,7 @@ export async function getUserDevices(username: string): Promise<Array<{
|
|||||||
// 撤销所有 Token
|
// 撤销所有 Token
|
||||||
export async function revokeAllRefreshTokens(username: string): Promise<void> {
|
export async function revokeAllRefreshTokens(username: string): Promise<void> {
|
||||||
const hashKey = `user_tokens:${username}`;
|
const hashKey = `user_tokens:${username}`;
|
||||||
const storage = getStorage();
|
const storage = await loadStorage();
|
||||||
|
|
||||||
if (!storage || typeof (storage as any).adapter?.del !== 'function') {
|
if (!storage || typeof (storage as any).adapter?.del !== 'function') {
|
||||||
console.warn('Redis Hash not supported');
|
console.warn('Redis Hash not supported');
|
||||||
@@ -206,7 +215,7 @@ export async function revokeAllRefreshTokens(username: string): Promise<void> {
|
|||||||
// 清理过期的 Token(定期任务)
|
// 清理过期的 Token(定期任务)
|
||||||
export async function cleanupExpiredTokens(username: string): Promise<number> {
|
export async function cleanupExpiredTokens(username: string): Promise<number> {
|
||||||
const hashKey = `user_tokens:${username}`;
|
const hashKey = `user_tokens:${username}`;
|
||||||
const storage = getStorage();
|
const storage = await loadStorage();
|
||||||
|
|
||||||
if (!storage || typeof (storage as any).adapter?.hGetAll !== 'function') {
|
if (!storage || typeof (storage as any).adapter?.hGetAll !== 'function') {
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
const CURRENT_VERSION = '210.0.0';
|
const CURRENT_VERSION = '210.1.3';
|
||||||
|
|
||||||
// 导出当前版本号供其他地方使用
|
// 导出当前版本号供其他地方使用
|
||||||
export { CURRENT_VERSION };
|
export { CURRENT_VERSION };
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { refreshAccessToken, shouldRenewToken } from '@/lib/middleware-auth';
|
|
||||||
import { TOKEN_CONFIG } from '@/lib/refresh-token';
|
import { TOKEN_CONFIG } from '@/lib/refresh-token';
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
@@ -49,45 +48,29 @@ export async function middleware(request: NextRequest) {
|
|||||||
return handleAuthFailure(request, pathname);
|
return handleAuthFailure(request, pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证 Access Token 时间戳
|
// 验证 Token 时间戳
|
||||||
const ACCESS_TOKEN_AGE = TOKEN_CONFIG.ACCESS_TOKEN_AGE;
|
const ACCESS_TOKEN_AGE = TOKEN_CONFIG.ACCESS_TOKEN_AGE;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const age = now - authInfo.timestamp;
|
const age = now - authInfo.timestamp;
|
||||||
|
|
||||||
// Access Token 已过期,尝试使用 Refresh Token 刷新
|
// 先检查 Refresh Token 是否过期
|
||||||
if (age > ACCESS_TOKEN_AGE) {
|
if (now >= authInfo.refreshExpires) {
|
||||||
if (authInfo.refreshToken && authInfo.tokenId && authInfo.refreshExpires) {
|
console.log(`Refresh token expired for ${authInfo.username}, redirecting to login`);
|
||||||
// 检查 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 也过期或刷新失败,需要重新登录
|
|
||||||
return handleAuthFailure(request, pathname);
|
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 未过期,验证签名
|
// Access Token 未过期,验证签名
|
||||||
const isValidSignature = await verifySignature(
|
const isValidSignature = await verifySignature(
|
||||||
authInfo.username,
|
authInfo.username,
|
||||||
@@ -101,34 +84,8 @@ export async function middleware(request: NextRequest) {
|
|||||||
return handleAuthFailure(request, pathname);
|
return handleAuthFailure(request, pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 签名验证通过,检查是否需要续期
|
// 签名验证通过
|
||||||
if (shouldRenewToken(authInfo.timestamp)) {
|
// 注意:Token 续期由前端负责,Middleware 不再自动刷新
|
||||||
// 快过期了,自动续期
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 正常通过
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +172,6 @@ function shouldSkipAuth(pathname: string): boolean {
|
|||||||
// 配置middleware匹配规则
|
// 配置middleware匹配规则
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
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,6 @@ main = ".open-next/worker.js"
|
|||||||
directory = ".open-next/assets"
|
directory = ".open-next/assets"
|
||||||
binding = "ASSETS"
|
binding = "ASSETS"
|
||||||
|
|
||||||
[env.production]
|
[vars]
|
||||||
# 生产环境变量可以在 Cloudflare Dashboard 配置
|
NODE_ENV = "production"
|
||||||
|
BUILD_TARGET = "cloudflare"
|
||||||
|
|||||||
Reference in New Issue
Block a user