Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18611ce49a | ||
|
|
70aacf6de6 |
79
.github/workflows/cloudflare-deploy.yml
vendored
79
.github/workflows/cloudflare-deploy.yml
vendored
@@ -1,79 +0,0 @@
|
|||||||
name: Deploy to Cloudflare
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
deployments: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Deploy to Cloudflare Pages
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout source code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Set environment variables
|
|
||||||
run: |
|
|
||||||
echo "BUILD_TARGET=cloudflare" >> $GITHUB_ENV
|
|
||||||
echo "NODE_ENV=production" >> $GITHUB_ENV
|
|
||||||
echo "NEXT_PUBLIC_STORAGE_TYPE=${{ secrets.NEXT_PUBLIC_STORAGE_TYPE }}" >> $GITHUB_ENV
|
|
||||||
echo "NEXT_PUBLIC_SITE_NAME=${{ secrets.NEXT_PUBLIC_SITE_NAME }}" >> $GITHUB_ENV
|
|
||||||
echo "NEXT_PUBLIC_SEARCH_MAX_PAGE=${{ secrets.NEXT_PUBLIC_SEARCH_MAX_PAGE }}" >> $GITHUB_ENV
|
|
||||||
echo "NEXT_PUBLIC_DOUBAN_PROXY_TYPE=${{ secrets.NEXT_PUBLIC_DOUBAN_PROXY_TYPE }}" >> $GITHUB_ENV
|
|
||||||
echo "NEXT_PUBLIC_DOUBAN_PROXY=${{ secrets.NEXT_PUBLIC_DOUBAN_PROXY }}" >> $GITHUB_ENV
|
|
||||||
echo "NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE=${{ secrets.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE }}" >> $GITHUB_ENV
|
|
||||||
echo "NEXT_PUBLIC_DOUBAN_IMAGE_PROXY=${{ secrets.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY }}" >> $GITHUB_ENV
|
|
||||||
echo "NEXT_PUBLIC_DISABLE_YELLOW_FILTER=${{ secrets.NEXT_PUBLIC_DISABLE_YELLOW_FILTER }}" >> $GITHUB_ENV
|
|
||||||
echo "NEXT_PUBLIC_FLUID_SEARCH=${{ secrets.NEXT_PUBLIC_FLUID_SEARCH }}" >> $GITHUB_ENV
|
|
||||||
echo "NEXT_PUBLIC_PROXY_M3U8_TOKEN=${{ secrets.NEXT_PUBLIC_PROXY_M3U8_TOKEN }}" >> $GITHUB_ENV
|
|
||||||
echo "NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES=${{ secrets.NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES }}" >> $GITHUB_ENV
|
|
||||||
echo "NEXT_PUBLIC_VOICE_CHAT_STRATEGY=${{ secrets.NEXT_PUBLIC_VOICE_CHAT_STRATEGY }}" >> $GITHUB_ENV
|
|
||||||
echo "NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD=${{ secrets.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD }}" >> $GITHUB_ENV
|
|
||||||
echo "NEXT_PUBLIC_ENABLE_SOURCE_SEARCH=${{ secrets.NEXT_PUBLIC_ENABLE_SOURCE_SEARCH }}" >> $GITHUB_ENV
|
|
||||||
echo "USERNAME=${{ secrets.USERNAME }}" >> $GITHUB_ENV
|
|
||||||
echo "PASSWORD=${{ secrets.PASSWORD }}" >> $GITHUB_ENV
|
|
||||||
echo "UPSTASH_URL=${{ secrets.UPSTASH_URL }}" >> $GITHUB_ENV
|
|
||||||
echo "UPSTASH_TOKEN=${{ secrets.UPSTASH_TOKEN }}" >> $GITHUB_ENV
|
|
||||||
echo "TMDB_API_KEY=${{ secrets.TMDB_API_KEY }}" >> $GITHUB_ENV
|
|
||||||
echo "TMDB_PROXY=${{ secrets.TMDB_PROXY }}" >> $GITHUB_ENV
|
|
||||||
echo "TMDB_REVERSE_PROXY=${{ secrets.TMDB_REVERSE_PROXY }}" >> $GITHUB_ENV
|
|
||||||
echo "DANMAKU_API_BASE=${{ secrets.DANMAKU_API_BASE }}" >> $GITHUB_ENV
|
|
||||||
echo "DANMAKU_API_TOKEN=${{ secrets.DANMAKU_API_TOKEN }}" >> $GITHUB_ENV
|
|
||||||
echo "ANNOUNCEMENT=${{ secrets.ANNOUNCEMENT }}" >> $GITHUB_ENV
|
|
||||||
echo "CRON_PASSWORD=${{ secrets.CRON_PASSWORD }}" >> $GITHUB_ENV
|
|
||||||
echo "SITE_BASE=${{ secrets.SITE_BASE }}" >> $GITHUB_ENV
|
|
||||||
echo "REDIS_URL=${{ secrets.REDIS_URL }}" >> $GITHUB_ENV
|
|
||||||
echo "KVROCKS_URL=${{ secrets.KVROCKS_URL }}" >> $GITHUB_ENV
|
|
||||||
echo "INIT_CONFIG=${{ secrets.INIT_CONFIG }}" >> $GITHUB_ENV
|
|
||||||
echo "CONFIG_SUBSCRIPTION_URL=${{ secrets.CONFIG_SUBSCRIPTION_URL }}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Build for Cloudflare
|
|
||||||
run: pnpm run build:cloudflare
|
|
||||||
|
|
||||||
- name: Deploy to Cloudflare
|
|
||||||
uses: cloudflare/wrangler-action@v3
|
|
||||||
with:
|
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
command: deploy
|
|
||||||
packageManager: pnpm
|
|
||||||
10
.github/workflows/docker-image-dev.yml
vendored
10
.github/workflows/docker-image-dev.yml
vendored
@@ -33,6 +33,8 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- platform: linux/amd64
|
- platform: linux/amd64
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
os: ubuntu-24.04-arm
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
@@ -50,10 +52,10 @@ jobs:
|
|||||||
if [[ "${{ github.ref_name }}" == "main" ]]; then
|
if [[ "${{ github.ref_name }}" == "main" ]]; then
|
||||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus" >> $GITHUB_ENV
|
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus" >> $GITHUB_ENV
|
||||||
elif [[ "${{ github.ref_name }}" == "dev" ]]; then
|
elif [[ "${{ github.ref_name }}" == "dev" ]]; then
|
||||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev2" >> $GITHUB_ENV
|
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev" >> $GITHUB_ENV
|
||||||
else
|
else
|
||||||
# 为其他分支或手动触发设置一个默认值(可以根据需要调整)
|
# 为其他分支或手动触发设置一个默认值(可以根据需要调整)
|
||||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev2" >> $GITHUB_ENV
|
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -129,9 +131,9 @@ jobs:
|
|||||||
if [[ "${{ github.ref_name }}" == "main" ]]; then
|
if [[ "${{ github.ref_name }}" == "main" ]]; then
|
||||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus" >> $GITHUB_ENV
|
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus" >> $GITHUB_ENV
|
||||||
elif [[ "${{ github.ref_name }}" == "dev" ]]; then
|
elif [[ "${{ github.ref_name }}" == "dev" ]]; then
|
||||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev2" >> $GITHUB_ENV
|
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev" >> $GITHUB_ENV
|
||||||
else
|
else
|
||||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev2" >> $GITHUB_ENV
|
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 新增步骤:在 merge 任务中也需要生成同样的标签列表
|
# 新增步骤:在 merge 任务中也需要生成同样的标签列表
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -33,11 +33,6 @@ yarn-error.log*
|
|||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# cloudflare
|
|
||||||
.open-next/
|
|
||||||
.wrangler/
|
|
||||||
wrangler.toml.bak
|
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
24
|
|
||||||
177
CHANGELOG
177
CHANGELOG
@@ -1,180 +1,3 @@
|
|||||||
## [210.0.0] - 2026-01-25
|
|
||||||
### Added
|
|
||||||
- 新增网络直播功能
|
|
||||||
- 动漫磁力搜索增加蜜柑
|
|
||||||
- 弹幕磁力搜索增加动漫花园
|
|
||||||
- pansou增加关键词过滤
|
|
||||||
- cloudflare workers部署支持
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 去广告增强
|
|
||||||
- 登录验证机制改为Token+Refresh Token形式
|
|
||||||
- 移除旧版用户操作残留
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 修复缓存弹幕限制后不显示原始数量
|
|
||||||
- 修复测速缓存导致的错误测速数据
|
|
||||||
- 修复videocard,2000年只显示0年
|
|
||||||
|
|
||||||
## [209.1.0] - 2026-01-21
|
|
||||||
### Added
|
|
||||||
- 增加禁用自动匹配弹幕
|
|
||||||
- 增加弹幕上限设置
|
|
||||||
- 增加禁用弹幕热力图开关
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 恢复无数据库支持
|
|
||||||
- 反爬增强
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 修复订阅去广告开关不正确修改链接问题
|
|
||||||
|
|
||||||
## [209.0.0] - 2026-01-18
|
|
||||||
### Added
|
|
||||||
- 新增收藏更新邮件发送
|
|
||||||
- 新增测速超时设置
|
|
||||||
- tmdb详情支持查看季度信息,集数信息
|
|
||||||
- tmdb支持设置反代
|
|
||||||
- tmdb图片支持自定义设置url
|
|
||||||
- 豆瓣图片增加百度图片代理
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 统一upstash和redis数据层
|
|
||||||
- 优化docker镜像大小
|
|
||||||
- Cloudflare Turnstile开启前检测sitekey和Secretkey不能为空
|
|
||||||
- 豆瓣反爬增强
|
|
||||||
- 竖向videocard的source name调整到海报右下角
|
|
||||||
- 取消小雅获取剧集强制刷新
|
|
||||||
- 本地设置选项卡增加图标
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 修复详情不应用豆瓣图片策略
|
|
||||||
|
|
||||||
## [208.0.0] - 2026-01-16
|
|
||||||
### Added
|
|
||||||
- ai问片增加非流式响应
|
|
||||||
- 新增下集弹幕预加载
|
|
||||||
- openlist和小雅增加禁用预览方式播放
|
|
||||||
- 管理配置增加主动重载
|
|
||||||
- emby增加排序功能
|
|
||||||
- 播放页面标题自动跟随影片标题
|
|
||||||
- emby数据增加导入导出
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 小雅现在会自动刷新播放链接
|
|
||||||
- 缓冲策略从下拉框改为滑块
|
|
||||||
- openlist扫描前增加tmdbkey配置检测
|
|
||||||
- 优化firefox下的弹幕热力图
|
|
||||||
- 小雅获取剧集时刷新文件夹
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 修复redis方式数据导入报错
|
|
||||||
- 修复小雅不过滤空url
|
|
||||||
|
|
||||||
|
|
||||||
## [207.0.0] - 2026-01-13
|
|
||||||
### Added
|
|
||||||
- 私人影库增加小雅支持
|
|
||||||
- 测速增加码率测算
|
|
||||||
- 私人影库增加求片功能
|
|
||||||
- 搜索增加简繁转换器
|
|
||||||
- openlist多目录扫描支持
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 视频源保留关键字增加xiaoya,emby
|
|
||||||
- 年代筛选的年代改为动态生成
|
|
||||||
- openlist支持视频预览方式播放
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 修复upstash方式数据导入报错
|
|
||||||
- 修复新加载弹幕不显示弹幕数量
|
|
||||||
- 修复生产环境emby分类返回空数组
|
|
||||||
|
|
||||||
## [206.3.0] - 2026-01-10
|
|
||||||
### Added
|
|
||||||
- emby支持配置多源
|
|
||||||
- emby支持配置高级参数(转码mp4,代理等)
|
|
||||||
- 新增自定义ai问片默认消息
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 优化搜索逻辑和聚合逻辑
|
|
||||||
- 加载缓存弹幕时显示元信息
|
|
||||||
- 服务器代理豆瓣图片反防盗链增强
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 修复emby分类切换时加载旧数据
|
|
||||||
|
|
||||||
## [206.2.1] - 2026-01-08
|
|
||||||
### Fixed
|
|
||||||
- 修正豆瓣图片源
|
|
||||||
|
|
||||||
## [206.2.0] - 2026-01-07
|
|
||||||
### Added
|
|
||||||
- 轮播图数据源增加豆瓣
|
|
||||||
- 弹幕开关状态持久化
|
|
||||||
- play页面新增复制视频链接
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 搜索聚合规则增强
|
|
||||||
- emby剧集弹幕匹配优化
|
|
||||||
- 搜索来源筛选提升私人影库权重
|
|
||||||
- 私人影库未配置openlist时自动跳转emby
|
|
||||||
- 优化弹幕加载逻辑
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 修复私人影库报错导致搜索无结果
|
|
||||||
- 修复无法保存更多推荐数据源
|
|
||||||
|
|
||||||
## [206.1.0] - 2026-01-05
|
|
||||||
### Added
|
|
||||||
- 新增手动上传弹幕功能
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 修改页面进度条逻辑
|
|
||||||
- openlist和emby源改为异步搜索
|
|
||||||
- 提高移动端选集面板高度
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 修复live页面500报错
|
|
||||||
|
|
||||||
|
|
||||||
## [206.0.0] - 2026-01-04
|
|
||||||
### Added
|
|
||||||
- 私人影库增加emby支持
|
|
||||||
- 清空增加确认框
|
|
||||||
- 增加页面切换进度条
|
|
||||||
- 增加首页模块配置
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 私人影库支持解析ova集数
|
|
||||||
- 优化弹幕匹配逻辑
|
|
||||||
- 修改本地设置面板可折叠
|
|
||||||
- 关闭预告片后不再检测连通性,检测连通性时增加query保证无缓存检测
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 修正视频源代理模式外部播放器链接
|
|
||||||
- 修复删除配置文件源提示成功的问题
|
|
||||||
|
|
||||||
## [205.1.0] - 2026-01-02
|
|
||||||
### Added
|
|
||||||
- 播放器显示截屏按钮
|
|
||||||
- 播放器在移动端增加快进快退控件
|
|
||||||
- 私人影库增加扫描模式
|
|
||||||
- 定时任务接口增加鉴权
|
|
||||||
- tmdb轮播图支持预告片显示
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- 调整竖向videocard样式
|
|
||||||
- 源站寻片应用黄色过滤器
|
|
||||||
- 调整播放页大屏幕下封面图大小
|
|
||||||
- 热力图不再使用artplayer内置方式,改为自定义实现
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- 修复私人影库分页问题
|
|
||||||
- 修复同一视频频繁弹窗弹幕选择
|
|
||||||
- 彻底移除旧版用户导入导出并修复导入密码二次hash的问题
|
|
||||||
|
|
||||||
|
|
||||||
## [205.0.1] - 2026-01-01
|
## [205.0.1] - 2026-01-01
|
||||||
### Changed
|
### Changed
|
||||||
- 迁移跳过配置到新数据结构
|
- 迁移跳过配置到新数据结构
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ ENV DOCKER_ENV=true
|
|||||||
# 生成生产构建
|
# 生成生产构建
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# 使用 pnpm deploy 提取生产依赖到独立目录
|
|
||||||
RUN pnpm deploy --filter=. --prod --legacy /tmp/prod-deps
|
|
||||||
|
|
||||||
# ---- 第 3 阶段:生成运行时镜像 ----
|
# ---- 第 3 阶段:生成运行时镜像 ----
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
|
|
||||||
@@ -58,8 +55,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/server.js ./server.js
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
# 从构建器中复制生产依赖(包含 Socket.IO)
|
# 安装 Socket.IO 相关依赖(standalone 模式不会自动包含)
|
||||||
COPY --from=builder --chown=nextjs:nodejs /tmp/prod-deps/node_modules ./node_modules
|
RUN pnpm add socket.io@^4.8.1 socket.io-client@^4.8.1 --prod
|
||||||
|
|
||||||
# 切换到非特权用户
|
# 切换到非特权用户
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -29,7 +29,7 @@
|
|||||||
- 🎭 **观影室**:支持多人同步观影、实时聊天、语音通话等功能(实验性)。
|
- 🎭 **观影室**:支持多人同步观影、实时聊天、语音通话等功能(实验性)。
|
||||||
- 📥 **M3U8完整下载**:通过合并m3u8片段实现完整视频下载。
|
- 📥 **M3U8完整下载**:通过合并m3u8片段实现完整视频下载。
|
||||||
- 💾 **服务器离线下载**:支持在服务器端下载视频文件,支持断点续传,提前下载到家秒加载 。
|
- 💾 **服务器离线下载**:支持在服务器端下载视频文件,支持断点续传,提前下载到家秒加载 。
|
||||||
- 📚 **私人影库**:接入 OpenList或Emby,可打造专属私人影库,亦可观看网盘资源。
|
- 📚 **私人影库**:接入 OpenList,可打造专属私人影库,亦可观看网盘资源。
|
||||||
|
|
||||||
## ✨ 功能特性
|
## ✨ 功能特性
|
||||||
|
|
||||||
@@ -238,7 +238,6 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
|||||||
| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
||||||
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
||||||
| 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 | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||||
@@ -268,13 +267,6 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
|||||||
| 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 字符串 | (空) |
|
|
||||||
| CONFIG_SUBSCRIPTION_URL | 配置订阅 URL(Base58 编码的配置文件地址,优先级高于 INIT_CONFIG) | URL | (空) |
|
|
||||||
| TMDB_API_KEY | TMDB API 密钥 | 任意字符串 | (空) |
|
|
||||||
| TMDB_PROXY | TMDB 代理地址 | URL | (空) |
|
|
||||||
| TMDB_REVERSE_PROXY | TMDB 反向代理地址 | URL | (空) |
|
|
||||||
| DANMAKU_API_BASE | 弹幕 API 地址 | URL | http://localhost:9321 |
|
|
||||||
| DANMAKU_API_TOKEN | 弹幕 API Token | 任意字符串 | 87654321 |
|
|
||||||
|
|
||||||
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
210.0.0
|
205.0.1
|
||||||
|
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
|
|
||||||
// 检测是否为 Cloudflare Pages 构建
|
|
||||||
const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
|
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
// Cloudflare Pages 不支持 standalone,使用默认输出
|
output: 'standalone',
|
||||||
output: isCloudflare ? undefined : 'standalone',
|
|
||||||
eslint: {
|
eslint: {
|
||||||
dirs: ['src'],
|
dirs: ['src'],
|
||||||
// 在生产构建时忽略 ESLint 错误
|
// 在生产构建时忽略 ESLint 错误
|
||||||
@@ -17,7 +13,7 @@ const nextConfig = {
|
|||||||
swcMinify: true,
|
swcMinify: true,
|
||||||
|
|
||||||
experimental: {
|
experimental: {
|
||||||
instrumentationHook: process.env.NODE_ENV === 'production' && !isCloudflare,
|
instrumentationHook: process.env.NODE_ENV === 'production',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Uncoment to add domain whitelist
|
// Uncoment to add domain whitelist
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import type { OpenNextConfig } from '@opennextjs/cloudflare';
|
|
||||||
|
|
||||||
const config: OpenNextConfig = {
|
|
||||||
default: {
|
|
||||||
override: {
|
|
||||||
wrapper: 'cloudflare-node',
|
|
||||||
converter: 'edge',
|
|
||||||
proxyExternalRequest: 'fetch',
|
|
||||||
incrementalCache: 'dummy',
|
|
||||||
tagCache: 'dummy',
|
|
||||||
queue: 'dummy',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
edgeExternals: [
|
|
||||||
'node:crypto',
|
|
||||||
'node:async_hooks',
|
|
||||||
'node:buffer',
|
|
||||||
'node:stream',
|
|
||||||
'node:util',
|
|
||||||
'node:events',
|
|
||||||
'node:path',
|
|
||||||
'node:url',
|
|
||||||
'node:fs',
|
|
||||||
'node:http',
|
|
||||||
'node:https',
|
|
||||||
'node:net',
|
|
||||||
'node:tls',
|
|
||||||
'node:dns',
|
|
||||||
'node:os',
|
|
||||||
'node:zlib',
|
|
||||||
'node:vm',
|
|
||||||
'node:child_process',
|
|
||||||
'node:string_decoder',
|
|
||||||
'node:tty',
|
|
||||||
'node:assert',
|
|
||||||
'node:punycode',
|
|
||||||
'node:timers',
|
|
||||||
'node:worker_threads',
|
|
||||||
],
|
|
||||||
middleware: {
|
|
||||||
external: true,
|
|
||||||
override: {
|
|
||||||
wrapper: 'cloudflare-edge',
|
|
||||||
converter: 'edge',
|
|
||||||
proxyExternalRequest: 'fetch',
|
|
||||||
incrementalCache: 'dummy',
|
|
||||||
tagCache: 'dummy',
|
|
||||||
queue: 'dummy',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
12
package.json
12
package.json
@@ -5,10 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm gen:manifest && node server.js",
|
"dev": "pnpm gen:manifest && node server.js",
|
||||||
"build": "pnpm gen:manifest && next build",
|
"build": "pnpm gen:manifest && next build",
|
||||||
"build:cloudflare": "BUILD_TARGET=cloudflare pnpm gen:manifest && npx @opennextjs/cloudflare build",
|
|
||||||
"start": "NODE_ENV=production node server.js",
|
"start": "NODE_ENV=production node server.js",
|
||||||
"preview:cloudflare": "wrangler dev",
|
|
||||||
"deploy:cloudflare": "wrangler deploy",
|
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"lint:fix": "eslint src --fix && pnpm format",
|
"lint:fix": "eslint src --fix && pnpm format",
|
||||||
"lint:strict": "eslint --max-warnings=0 src",
|
"lint:strict": "eslint --max-warnings=0 src",
|
||||||
@@ -30,7 +27,6 @@
|
|||||||
"@headlessui/react": "^2.2.4",
|
"@headlessui/react": "^2.2.4",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/nprogress": "^0.2.3",
|
|
||||||
"@upstash/redis": "^1.25.0",
|
"@upstash/redis": "^1.25.0",
|
||||||
"@vidstack/react": "^1.12.13",
|
"@vidstack/react": "^1.12.13",
|
||||||
"anime4k-webgpu": "^1.0.0",
|
"anime4k-webgpu": "^1.0.0",
|
||||||
@@ -40,7 +36,6 @@
|
|||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"flv.js": "^1.6.2",
|
|
||||||
"framer-motion": "^12.18.1",
|
"framer-motion": "^12.18.1",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
"hls.js": "^1.6.10",
|
"hls.js": "^1.6.10",
|
||||||
@@ -48,14 +43,10 @@
|
|||||||
"lucide-react": "^0.438.0",
|
"lucide-react": "^0.438.0",
|
||||||
"media-icons": "^1.1.5",
|
"media-icons": "^1.1.5",
|
||||||
"mux.js": "^6.3.0",
|
"mux.js": "^6.3.0",
|
||||||
"nanoid": "^5.1.6",
|
|
||||||
"next": "^14.2.33",
|
"next": "^14.2.33",
|
||||||
"next-pwa": "^5.6.0",
|
"next-pwa": "^5.6.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^7.0.12",
|
|
||||||
"nprogress": "^0.2.0",
|
|
||||||
"opencc-js": "^1.0.5",
|
|
||||||
"parse-torrent-name": "^0.5.4",
|
"parse-torrent-name": "^0.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -75,7 +66,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^16.3.0",
|
"@commitlint/cli": "^16.3.0",
|
||||||
"@commitlint/config-conventional": "^16.2.4",
|
"@commitlint/config-conventional": "^16.2.4",
|
||||||
"@opennextjs/cloudflare": "^1.15.1",
|
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
@@ -85,7 +75,6 @@
|
|||||||
"@types/he": "^1.2.3",
|
"@types/he": "^1.2.3",
|
||||||
"@types/node": "24.0.3",
|
"@types/node": "24.0.3",
|
||||||
"@types/node-fetch": "^2.6.13",
|
"@types/node-fetch": "^2.6.13",
|
||||||
"@types/nodemailer": "^7.0.5",
|
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@types/react-window": "^2.0.0",
|
"@types/react-window": "^2.0.0",
|
||||||
@@ -108,7 +97,6 @@
|
|||||||
"prettier-plugin-tailwindcss": "^0.5.0",
|
"prettier-plugin-tailwindcss": "^0.5.0",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
"vercel": "^50.4.10",
|
|
||||||
"webpack-obfuscator": "^3.5.1"
|
"webpack-obfuscator": "^3.5.1"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
9455
pnpm-lock.yaml
generated
9455
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
22
render.yaml
Normal file
22
render.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
- type: web
|
||||||
|
name: moontvplus
|
||||||
|
runtime: image
|
||||||
|
plan: free
|
||||||
|
image:
|
||||||
|
url: ghcr.io/mtvpls/moontvplus:latest
|
||||||
|
envVars:
|
||||||
|
- key: USERNAME
|
||||||
|
generateValue: true
|
||||||
|
- key: PASSWORD
|
||||||
|
generateValue: true
|
||||||
|
- key: NEXT_PUBLIC_STORAGE_TYPE
|
||||||
|
value: upstash
|
||||||
|
- key: UPSTASH_URL
|
||||||
|
sync: false
|
||||||
|
- key: UPSTASH_TOKEN
|
||||||
|
sync: false
|
||||||
|
- key: NEXT_PUBLIC_SITE_NAME
|
||||||
|
value: MoonTVPlus
|
||||||
|
- key: CRON_PASSWORD
|
||||||
|
value: mtvpls
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,134 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { parseStringPromise } from 'xml2js';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/acg/dmhy
|
|
||||||
* 搜索 动漫花园 (share.dmhy.org) RSS(仅管理员和站长可用)
|
|
||||||
* - http://share.dmhy.org/topics/rss/rss.xml?keyword=xxx
|
|
||||||
* - RSS 不支持分页(page>1 返回空 items)
|
|
||||||
*/
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const authInfo = getAuthInfoFromCookie(req);
|
|
||||||
if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '无权限访问' },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { keyword, page = 1 } = await req.json();
|
|
||||||
|
|
||||||
if (!keyword || typeof keyword !== 'string') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '搜索关键词不能为空' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedKeyword = keyword.trim();
|
|
||||||
if (!trimmedKeyword) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '搜索关键词不能为空' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageNum = parseInt(String(page), 10);
|
|
||||||
if (isNaN(pageNum) || pageNum < 1) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '页码必须是大于0的整数' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageNum > 1) {
|
|
||||||
return NextResponse.json({
|
|
||||||
keyword: trimmedKeyword,
|
|
||||||
page: pageNum,
|
|
||||||
total: 0,
|
|
||||||
items: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = 'http://share.dmhy.org/topics/rss/rss.xml';
|
|
||||||
const params = new URLSearchParams({ keyword: trimmedKeyword });
|
|
||||||
const searchUrl = `${baseUrl}?${params.toString()}`;
|
|
||||||
|
|
||||||
const response = await fetch(searchUrl, {
|
|
||||||
headers: {
|
|
||||||
'User-Agent':
|
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`DMHY API 请求失败: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const xmlData = await response.text();
|
|
||||||
const parsed = await parseStringPromise(xmlData);
|
|
||||||
|
|
||||||
if (!parsed?.rss?.channel?.[0]?.item) {
|
|
||||||
return NextResponse.json({
|
|
||||||
keyword: trimmedKeyword,
|
|
||||||
page: pageNum,
|
|
||||||
total: 0,
|
|
||||||
items: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = parsed.rss.channel[0].item;
|
|
||||||
|
|
||||||
const results = items.map((item: any) => {
|
|
||||||
const title = item.title?.[0] || '';
|
|
||||||
const link = item.link?.[0] || '';
|
|
||||||
const guid = item.guid?.[0] || link || `${title}-${item.pubDate?.[0] || ''}`;
|
|
||||||
const pubDate = item.pubDate?.[0] || '';
|
|
||||||
const description = item.description?.[0] || '';
|
|
||||||
const torrentUrl = item.enclosure?.[0]?.$?.url || '';
|
|
||||||
|
|
||||||
// 提取描述中的图片(如果有)
|
|
||||||
let images: string[] = [];
|
|
||||||
if (description) {
|
|
||||||
const imgMatches = description.match(/src="([^"]+)"/g);
|
|
||||||
if (imgMatches) {
|
|
||||||
images = imgMatches
|
|
||||||
.map((match: string) => {
|
|
||||||
const urlMatch = match.match(/src="([^"]+)"/);
|
|
||||||
return urlMatch ? urlMatch[1] : '';
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
link,
|
|
||||||
guid,
|
|
||||||
pubDate,
|
|
||||||
torrentUrl,
|
|
||||||
description,
|
|
||||||
images,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
keyword: trimmedKeyword,
|
|
||||||
page: pageNum,
|
|
||||||
total: results.length,
|
|
||||||
items: results,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('DMHY 搜索失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || '搜索失败' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { parseStringPromise } from 'xml2js';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
const pickText = (value: any): string => {
|
|
||||||
if (value === undefined || value === null) return '';
|
|
||||||
if (Array.isArray(value)) return String(value[0] ?? '');
|
|
||||||
return String(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/acg/mikan
|
|
||||||
* 搜索 Mikan RSS(仅管理员和站长可用,不支持分页)
|
|
||||||
*/
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
// 检查权限
|
|
||||||
const authInfo = getAuthInfoFromCookie(req);
|
|
||||||
if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '无权限访问' },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { keyword, page = 1 } = await req.json();
|
|
||||||
|
|
||||||
if (!keyword || typeof keyword !== 'string') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '搜索关键词不能为空' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedKeyword = keyword.trim();
|
|
||||||
if (!trimmedKeyword) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '搜索关键词不能为空' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证页码(Mikan RSS 不支持分页,这里仍接收 page 以保持接口一致)
|
|
||||||
const pageNum = parseInt(String(page), 10);
|
|
||||||
if (isNaN(pageNum) || pageNum < 1) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '页码必须是大于0的整数' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageNum > 1) {
|
|
||||||
return NextResponse.json({
|
|
||||||
keyword: trimmedKeyword,
|
|
||||||
page: pageNum,
|
|
||||||
total: 0,
|
|
||||||
items: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchUrl = `https://mikanani.me/RSS/Search?searchstr=${encodeURIComponent(trimmedKeyword)}`;
|
|
||||||
|
|
||||||
const response = await fetch(searchUrl, {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Mikan API 请求失败: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const xmlData = await response.text();
|
|
||||||
const parsed = await parseStringPromise(xmlData);
|
|
||||||
|
|
||||||
if (!parsed?.rss?.channel?.[0]?.item) {
|
|
||||||
return NextResponse.json({
|
|
||||||
keyword: trimmedKeyword,
|
|
||||||
page: pageNum,
|
|
||||||
total: 0,
|
|
||||||
items: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = parsed.rss.channel[0].item;
|
|
||||||
|
|
||||||
const results = items.map((item: any) => {
|
|
||||||
const title = pickText(item.title);
|
|
||||||
const link = pickText(item.link);
|
|
||||||
const guid = pickText(item.guid) || link || `${title}-${pickText(item.torrent?.[0]?.pubDate)}`;
|
|
||||||
const pubDate =
|
|
||||||
pickText(item.pubDate) ||
|
|
||||||
pickText(item.torrent?.[0]?.pubDate) ||
|
|
||||||
pickText(item['dc:date']);
|
|
||||||
|
|
||||||
const description =
|
|
||||||
pickText(item.description) ||
|
|
||||||
pickText(item['content:encoded']) ||
|
|
||||||
'';
|
|
||||||
|
|
||||||
const torrentUrl =
|
|
||||||
pickText(item.enclosure?.[0]?.$?.url) ||
|
|
||||||
pickText(item.enclosure?.[0]?.$?.href) ||
|
|
||||||
'';
|
|
||||||
|
|
||||||
// 提取描述中的图片(如果有)
|
|
||||||
let images: string[] = [];
|
|
||||||
if (description) {
|
|
||||||
const imgMatches = description.match(/src="([^"]+)"/g);
|
|
||||||
if (imgMatches) {
|
|
||||||
images = imgMatches.map((match: string) => {
|
|
||||||
const urlMatch = match.match(/src="([^"]+)"/);
|
|
||||||
return urlMatch ? urlMatch[1] : '';
|
|
||||||
}).filter(Boolean);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
link,
|
|
||||||
guid,
|
|
||||||
pubDate,
|
|
||||||
torrentUrl,
|
|
||||||
description,
|
|
||||||
images,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
keyword: trimmedKeyword,
|
|
||||||
page: pageNum,
|
|
||||||
total: results.length,
|
|
||||||
items: results,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Mikan 搜索失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || '搜索失败' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -7,8 +7,8 @@ import { getAuthInfoFromCookie } from '@/lib/auth';
|
|||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/acg/acgrip
|
* POST /api/acg/search
|
||||||
* 搜索 ACG.RIP 磁力资源(仅管理员和站长可用,支持分页)
|
* 搜索 ACG 磁力资源(仅管理员和站长可用)
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -47,10 +47,10 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求 acg.rip RSS
|
// 请求 acg.rip API
|
||||||
const searchUrl = `https://acg.rip/page/${pageNum}.xml?term=${encodeURIComponent(trimmedKeyword)}`;
|
const acgUrl = `https://acg.rip/page/${pageNum}.xml?term=${encodeURIComponent(trimmedKeyword)}`;
|
||||||
|
|
||||||
const response = await fetch(searchUrl, {
|
const response = await fetch(acgUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||||
},
|
},
|
||||||
@@ -78,12 +78,10 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// 转换为标准格式
|
// 转换为标准格式
|
||||||
const results = items.map((item: any) => {
|
const results = items.map((item: any) => {
|
||||||
const description = item.description?.[0] || '';
|
|
||||||
|
|
||||||
// 提取描述中的图片(如果有)
|
// 提取描述中的图片(如果有)
|
||||||
let images: string[] = [];
|
let images: string[] = [];
|
||||||
if (description) {
|
if (item.description?.[0]) {
|
||||||
const imgMatches = description.match(/src="([^"]+)"/g);
|
const imgMatches = item.description[0].match(/src="([^"]+)"/g);
|
||||||
if (imgMatches) {
|
if (imgMatches) {
|
||||||
images = imgMatches.map((match: string) => {
|
images = imgMatches.map((match: string) => {
|
||||||
const urlMatch = match.match(/src="([^"]+)"/);
|
const urlMatch = match.match(/src="([^"]+)"/);
|
||||||
@@ -92,19 +90,13 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = item.title?.[0] || '';
|
|
||||||
const link = item.link?.[0] || '';
|
|
||||||
const guid = item.guid?.[0] || link || `${title}-${item.pubDate?.[0] || ''}`;
|
|
||||||
const pubDate = item.pubDate?.[0] || '';
|
|
||||||
const torrentUrl = item.enclosure?.[0]?.$?.url || '';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title: item.title?.[0] || '',
|
||||||
link,
|
link: item.link?.[0] || '',
|
||||||
guid,
|
guid: item.guid?.[0] || '',
|
||||||
pubDate,
|
pubDate: item.pubDate?.[0] || '',
|
||||||
torrentUrl,
|
torrentUrl: item.enclosure?.[0]?.$?.url || '',
|
||||||
description,
|
description: item.description?.[0] || '',
|
||||||
images,
|
images,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -115,12 +107,12 @@ export async function POST(req: NextRequest) {
|
|||||||
total: results.length,
|
total: results.length,
|
||||||
items: results,
|
items: results,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('ACG.RIP 搜索失败:', error);
|
console.error('ACG 搜索失败:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message || '搜索失败' },
|
{ error: error.message || '搜索失败' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,7 +3,6 @@ import { NextResponse } from 'next/server';
|
|||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic'; // 禁用缓存
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/ad-filter
|
* GET /api/ad-filter
|
||||||
|
|||||||
@@ -61,9 +61,6 @@ export async function POST(request: NextRequest) {
|
|||||||
Temperature,
|
Temperature,
|
||||||
MaxTokens,
|
MaxTokens,
|
||||||
SystemPrompt,
|
SystemPrompt,
|
||||||
EnableStreaming,
|
|
||||||
DefaultMessageNoVideo,
|
|
||||||
DefaultMessageWithVideo,
|
|
||||||
} = body as {
|
} = body as {
|
||||||
Enabled: boolean;
|
Enabled: boolean;
|
||||||
Provider: 'openai' | 'claude' | 'custom';
|
Provider: 'openai' | 'claude' | 'custom';
|
||||||
@@ -97,9 +94,6 @@ export async function POST(request: NextRequest) {
|
|||||||
Temperature?: number;
|
Temperature?: number;
|
||||||
MaxTokens?: number;
|
MaxTokens?: number;
|
||||||
SystemPrompt?: string;
|
SystemPrompt?: string;
|
||||||
EnableStreaming?: boolean;
|
|
||||||
DefaultMessageNoVideo?: string;
|
|
||||||
DefaultMessageWithVideo?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 参数校验
|
// 参数校验
|
||||||
@@ -135,8 +129,7 @@ export async function POST(request: NextRequest) {
|
|||||||
typeof AllowRegularUsers !== 'boolean' ||
|
typeof AllowRegularUsers !== 'boolean' ||
|
||||||
(Temperature !== undefined && typeof Temperature !== 'number') ||
|
(Temperature !== undefined && typeof Temperature !== 'number') ||
|
||||||
(MaxTokens !== undefined && typeof MaxTokens !== 'number') ||
|
(MaxTokens !== undefined && typeof MaxTokens !== 'number') ||
|
||||||
(SystemPrompt !== undefined && typeof SystemPrompt !== 'string') ||
|
(SystemPrompt !== undefined && typeof SystemPrompt !== 'string')
|
||||||
(EnableStreaming !== undefined && typeof EnableStreaming !== 'boolean')
|
|
||||||
) {
|
) {
|
||||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -185,9 +178,6 @@ export async function POST(request: NextRequest) {
|
|||||||
Temperature,
|
Temperature,
|
||||||
MaxTokens,
|
MaxTokens,
|
||||||
SystemPrompt,
|
SystemPrompt,
|
||||||
EnableStreaming,
|
|
||||||
DefaultMessageNoVideo,
|
|
||||||
DefaultMessageWithVideo,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 写入数据库
|
// 写入数据库
|
||||||
|
|||||||
@@ -34,17 +34,31 @@ export async function GET(request: NextRequest) {
|
|||||||
if (username === process.env.USERNAME) {
|
if (username === process.env.USERNAME) {
|
||||||
result.Role = 'owner';
|
result.Role = 'owner';
|
||||||
} else {
|
} else {
|
||||||
// 从新版数据库获取用户信息
|
// 优先从新版本获取用户信息
|
||||||
const { db } = await import('@/lib/db');
|
const { db } = await import('@/lib/db');
|
||||||
const userInfoV2 = await db.getUserInfoV2(username);
|
const userInfoV2 = await db.getUserInfoV2(username);
|
||||||
|
|
||||||
if (userInfoV2 && userInfoV2.role === 'admin' && !userInfoV2.banned) {
|
if (userInfoV2) {
|
||||||
result.Role = 'admin';
|
// 使用新版本用户信息
|
||||||
|
if (userInfoV2.role === 'admin' && !userInfoV2.banned) {
|
||||||
|
result.Role = 'admin';
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '你是管理员吗你就访问?' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json(
|
// 回退到配置中查找
|
||||||
{ error: '你是管理员吗你就访问?' },
|
const user = config.UserConfig.Users.find((u) => u.username === username);
|
||||||
{ status: 401 }
|
if (user && user.role === 'admin' && !user.banned) {
|
||||||
);
|
result.Role = 'admin';
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '你是管理员吗你就访问?' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,54 +78,3 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
|
||||||
if (storageType === 'localstorage') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '不支持本地存储进行管理员配置' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
const username = authInfo.username;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newConfig = await request.json();
|
|
||||||
|
|
||||||
// 权限检查
|
|
||||||
if (username !== process.env.USERNAME) {
|
|
||||||
const { db } = await import('@/lib/db');
|
|
||||||
const userInfoV2 = await db.getUserInfoV2(username);
|
|
||||||
|
|
||||||
if (!userInfoV2 || (userInfoV2.role !== 'admin' && userInfoV2.role !== 'owner') || userInfoV2.banned) {
|
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存配置
|
|
||||||
const { db } = await import('@/lib/db');
|
|
||||||
const { configSelfCheck, setCachedConfig } = await import('@/lib/config');
|
|
||||||
|
|
||||||
// 自检配置
|
|
||||||
const checkedConfig = configSelfCheck(newConfig);
|
|
||||||
|
|
||||||
// 保存到数据库
|
|
||||||
await db.saveAdminConfig(checkedConfig);
|
|
||||||
|
|
||||||
// 更新缓存
|
|
||||||
await setCachedConfig(checkedConfig);
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, message: '配置已保存' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('保存配置失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '保存配置失败: ' + (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -61,39 +61,23 @@ export async function POST(req: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 获取所有V2用户
|
// 获取所有V2用户
|
||||||
const usersV2Result = await db.getUserListV2(0, 1000000, process.env.USERNAME);
|
const usersV2Result = await db.getUserListV2(0, 10000, process.env.USERNAME);
|
||||||
exportData.data.usersV2 = usersV2Result.users;
|
exportData.data.usersV2 = usersV2Result.users;
|
||||||
console.log(`从getUserListV2获取到 ${usersV2Result.users.length} 个用户`);
|
|
||||||
|
|
||||||
// 获取所有用户(getAllUsers返回的是V2用户)
|
// 获取所有用户(包括旧版用户)
|
||||||
let allUsers = await db.getAllUsers();
|
let allUsers = await db.getAllUsers();
|
||||||
allUsers.push(process.env.USERNAME); // 添加站长
|
// 添加站长用户
|
||||||
// 添加V2用户列表中的用户
|
allUsers.push(process.env.USERNAME);
|
||||||
|
// 添加V2用户
|
||||||
usersV2Result.users.forEach(user => {
|
usersV2Result.users.forEach(user => {
|
||||||
if (!allUsers.includes(user.username)) {
|
if (!allUsers.includes(user.username)) {
|
||||||
allUsers.push(user.username);
|
allUsers.push(user.username);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
allUsers = Array.from(new Set(allUsers));
|
allUsers = Array.from(new Set(allUsers));
|
||||||
console.log(`准备导出 ${allUsers.length} 个V2用户(包括站长)`);
|
|
||||||
|
|
||||||
// 为每个用户收集数据(只导出V2用户)
|
// 为每个用户收集数据
|
||||||
let exportedCount = 0;
|
|
||||||
for (const username of allUsers) {
|
for (const username of allUsers) {
|
||||||
// 站长特殊处理:使用环境变量密码
|
|
||||||
let finalPasswordV2 = username === process.env.USERNAME ? process.env.PASSWORD : null;
|
|
||||||
|
|
||||||
// 如果不是站长,获取V2密码
|
|
||||||
if (!finalPasswordV2) {
|
|
||||||
finalPasswordV2 = await getUserPasswordV2(username);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跳过没有V2密码的用户
|
|
||||||
if (!finalPasswordV2) {
|
|
||||||
console.log(`跳过用户 ${username}:没有V2密码`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userData = {
|
const userData = {
|
||||||
// 播放记录
|
// 播放记录
|
||||||
playRecords: await db.getAllPlayRecords(username),
|
playRecords: await db.getAllPlayRecords(username),
|
||||||
@@ -103,15 +87,17 @@ export async function POST(req: NextRequest) {
|
|||||||
searchHistory: await db.getSearchHistory(username),
|
searchHistory: await db.getSearchHistory(username),
|
||||||
// 跳过片头片尾配置
|
// 跳过片头片尾配置
|
||||||
skipConfigs: await db.getAllSkipConfigs(username),
|
skipConfigs: await db.getAllSkipConfigs(username),
|
||||||
|
// 用户密码(通过验证空密码来检查用户是否存在,然后获取密码)
|
||||||
|
password: await getUserPassword(username),
|
||||||
// V2用户的加密密码
|
// V2用户的加密密码
|
||||||
passwordV2: finalPasswordV2
|
passwordV2: await getUserPasswordV2(username)
|
||||||
};
|
};
|
||||||
|
|
||||||
exportData.data.userData[username] = userData;
|
exportData.data.userData[username] = userData;
|
||||||
exportedCount++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`成功导出 ${exportedCount} 个用户的数据`);
|
// 覆盖站长密码
|
||||||
|
exportData.data.userData[process.env.USERNAME].password = process.env.PASSWORD;
|
||||||
|
|
||||||
// 将数据转换为JSON字符串
|
// 将数据转换为JSON字符串
|
||||||
const jsonData = JSON.stringify(exportData);
|
const jsonData = JSON.stringify(exportData);
|
||||||
@@ -146,22 +132,32 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 辅助函数:获取用户密码(通过数据库直接访问)
|
||||||
|
async function getUserPassword(username: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// 使用 Redis 存储的直接访问方法
|
||||||
|
const storage = (db as any).storage;
|
||||||
|
if (storage && typeof storage.client?.get === 'function') {
|
||||||
|
const passwordKey = `u:${username}:pwd`;
|
||||||
|
const password = await storage.client.get(passwordKey);
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`获取用户 ${username} 密码失败:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 辅助函数:获取V2用户的加密密码
|
// 辅助函数:获取V2用户的加密密码
|
||||||
async function getUserPasswordV2(username: string): Promise<string | null> {
|
async function getUserPasswordV2(username: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const storage = (db as any).storage;
|
const storage = (db as any).storage;
|
||||||
if (!storage) return null;
|
if (storage && typeof storage.client?.hget === 'function') {
|
||||||
|
const userInfoKey = `user:${username}:info`;
|
||||||
// 直接调用hGetAll获取完整用户信息(包括密码)
|
const password = await storage.client.hget(userInfoKey, 'password');
|
||||||
const userInfoKey = `user:${username}:info`;
|
return password;
|
||||||
|
|
||||||
if (typeof storage.withRetry === 'function' && storage.client?.hgetall) {
|
|
||||||
const userInfo = await storage.withRetry(() => storage.client.hgetall(userInfoKey));
|
|
||||||
if (userInfo && userInfo.password) {
|
|
||||||
return userInfo.password;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`获取用户 ${username} V2密码失败:`, error);
|
console.error(`获取用户 ${username} V2密码失败:`, error);
|
||||||
|
|||||||
@@ -80,13 +80,6 @@ export async function POST(req: NextRequest) {
|
|||||||
// 开始导入数据 - 先清空现有数据
|
// 开始导入数据 - 先清空现有数据
|
||||||
await db.clearAllData();
|
await db.clearAllData();
|
||||||
|
|
||||||
// 额外清除所有V2用户(clearAllData可能只清除旧版用户)
|
|
||||||
const existingUsers = await db.getUserListV2(0, 1000000, process.env.USERNAME);
|
|
||||||
for (const user of existingUsers.users) {
|
|
||||||
await db.deleteUserV2(user.username);
|
|
||||||
}
|
|
||||||
console.log(`已清除 ${existingUsers.users.length} 个现有V2用户`);
|
|
||||||
|
|
||||||
// 导入管理员配置
|
// 导入管理员配置
|
||||||
importData.data.adminConfig = configSelfCheck(importData.data.adminConfig);
|
importData.data.adminConfig = configSelfCheck(importData.data.adminConfig);
|
||||||
await db.saveAdminConfig(importData.data.adminConfig);
|
await db.saveAdminConfig(importData.data.adminConfig);
|
||||||
@@ -101,73 +94,79 @@ export async function POST(req: NextRequest) {
|
|||||||
// 不影响主流程,继续执行
|
// 不影响主流程,继续执行
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导入用户数据和user:info
|
// 导入V2用户信息
|
||||||
|
if (importData.data.usersV2 && Array.isArray(importData.data.usersV2)) {
|
||||||
|
for (const userV2 of importData.data.usersV2) {
|
||||||
|
try {
|
||||||
|
// 跳过环境变量中的站长(站长使用环境变量认证)
|
||||||
|
if (userV2.username === process.env.USERNAME) {
|
||||||
|
console.log(`跳过站长 ${userV2.username} 的导入`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户的加密密码
|
||||||
|
const userData = importData.data.userData[userV2.username];
|
||||||
|
const passwordV2 = userData?.passwordV2;
|
||||||
|
|
||||||
|
if (passwordV2) {
|
||||||
|
// 将站长角色转换为普通角色
|
||||||
|
const importedRole = userV2.role === 'owner' ? 'user' : userV2.role;
|
||||||
|
if (userV2.role === 'owner') {
|
||||||
|
console.log(`用户 ${userV2.username} 的角色从 owner 转换为 user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接使用加密后的密码创建用户
|
||||||
|
const storage = (db as any).storage;
|
||||||
|
if (storage && typeof storage.client?.hset === 'function') {
|
||||||
|
const userInfoKey = `user:${userV2.username}:info`;
|
||||||
|
const createdAt = userV2.created_at || Date.now();
|
||||||
|
|
||||||
|
const userInfo: any = {
|
||||||
|
role: importedRole,
|
||||||
|
banned: userV2.banned,
|
||||||
|
password: passwordV2,
|
||||||
|
created_at: createdAt.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (userV2.tags && userV2.tags.length > 0) {
|
||||||
|
userInfo.tags = JSON.stringify(userV2.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userV2.oidcSub) {
|
||||||
|
userInfo.oidcSub = userV2.oidcSub;
|
||||||
|
// 创建OIDC映射
|
||||||
|
const oidcSubKey = `oidc:sub:${userV2.oidcSub}`;
|
||||||
|
await storage.client.set(oidcSubKey, userV2.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userV2.enabledApis && userV2.enabledApis.length > 0) {
|
||||||
|
userInfo.enabledApis = JSON.stringify(userV2.enabledApis);
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.client.hset(userInfoKey, userInfo);
|
||||||
|
|
||||||
|
// 添加到用户列表(Sorted Set)
|
||||||
|
const userListKey = 'user:list';
|
||||||
|
await storage.client.zadd(userListKey, {
|
||||||
|
score: createdAt,
|
||||||
|
member: userV2.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`V2用户 ${userV2.username} 导入成功`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`导入V2用户 ${userV2.username} 失败:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入用户数据
|
||||||
const userData = importData.data.userData;
|
const userData = importData.data.userData;
|
||||||
const storage = (db as any).storage;
|
|
||||||
const usersV2Map = new Map((importData.data.usersV2 || []).map((u: any) => [u.username, u]));
|
|
||||||
|
|
||||||
const userCount = Object.keys(userData).length;
|
|
||||||
console.log(`准备导入 ${userCount} 个用户的数据`);
|
|
||||||
|
|
||||||
let importedCount = 0;
|
|
||||||
for (const username in userData) {
|
for (const username in userData) {
|
||||||
const user = userData[username];
|
const user = userData[username];
|
||||||
|
await db.createUserV2(username, user.password,user.role,user.tags);
|
||||||
// 为所有有passwordV2的用户创建user:info
|
|
||||||
if (user.passwordV2) {
|
|
||||||
const userV2 = usersV2Map.get(username) as any;
|
|
||||||
|
|
||||||
// 确定角色:站长为owner,其他用户从usersV2获取或默认为user
|
|
||||||
let role: 'owner' | 'admin' | 'user' = 'user';
|
|
||||||
if (username === process.env.USERNAME) {
|
|
||||||
role = 'owner';
|
|
||||||
} else if (userV2) {
|
|
||||||
role = userV2.role === 'owner' ? 'user' : userV2.role;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdAt = userV2?.created_at || Date.now();
|
|
||||||
|
|
||||||
// 直接设置用户信息(不经过createUserV2,避免密码被再次hash)
|
|
||||||
const userInfoKey = `user:${username}:info`;
|
|
||||||
const userInfo: Record<string, string> = {
|
|
||||||
role,
|
|
||||||
banned: String(userV2?.banned || false),
|
|
||||||
password: user.passwordV2, // 已经是hash过的密码,直接使用
|
|
||||||
created_at: createdAt.toString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (userV2?.tags && userV2.tags.length > 0) {
|
|
||||||
userInfo.tags = JSON.stringify(userV2.tags);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userV2?.oidcSub) {
|
|
||||||
userInfo.oidcSub = userV2.oidcSub;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userV2?.enabledApis && userV2.enabledApis.length > 0) {
|
|
||||||
userInfo.enabledApis = JSON.stringify(userV2.enabledApis);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用storage.withRetry直接设置用户信息
|
|
||||||
await storage.withRetry(() => storage.client.hSet(userInfoKey, userInfo));
|
|
||||||
|
|
||||||
// 添加到用户列表
|
|
||||||
await storage.withRetry(() => storage.client.zAdd('user:list', {
|
|
||||||
score: createdAt,
|
|
||||||
value: username,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 如果有oidcSub,创建映射
|
|
||||||
if (userV2?.oidcSub) {
|
|
||||||
const oidcSubKey = `oidc:sub:${userV2.oidcSub}`;
|
|
||||||
await storage.withRetry(() => storage.client.set(oidcSubKey, username));
|
|
||||||
}
|
|
||||||
|
|
||||||
importedCount++;
|
|
||||||
console.log(`用户 ${username} 导入成功`);
|
|
||||||
} else {
|
|
||||||
console.log(`跳过用户 ${username}:没有passwordV2`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导入播放记录
|
// 导入播放记录
|
||||||
if (user.playRecords) {
|
if (user.playRecords) {
|
||||||
@@ -201,8 +200,6 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`成功导入 ${importedCount} 个用户的user:info`);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: '数据导入成功',
|
message: '数据导入成功',
|
||||||
importedUsers: Object.keys(userData).length,
|
importedUsers: Object.keys(userData).length,
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import type { AdminConfig } from '@/lib/admin.types';
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
import { getStorage } from '@/lib/db';
|
|
||||||
import { EmailService } from '@/lib/email.service';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - 获取邮件配置
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storage = getStorage();
|
|
||||||
const userInfo = await storage.getUserInfoV2?.(authInfo.username);
|
|
||||||
|
|
||||||
// 只有管理员和站长可以访问
|
|
||||||
if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner')) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminConfig = await getConfig();
|
|
||||||
const emailConfig = adminConfig?.EmailConfig || {
|
|
||||||
enabled: false,
|
|
||||||
provider: 'smtp' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 不返回敏感信息(密码、API Key)
|
|
||||||
const safeConfig = {
|
|
||||||
enabled: emailConfig.enabled,
|
|
||||||
provider: emailConfig.provider,
|
|
||||||
smtp: emailConfig.smtp
|
|
||||||
? {
|
|
||||||
host: emailConfig.smtp.host,
|
|
||||||
port: emailConfig.smtp.port,
|
|
||||||
secure: emailConfig.smtp.secure,
|
|
||||||
user: emailConfig.smtp.user,
|
|
||||||
from: emailConfig.smtp.from,
|
|
||||||
password: emailConfig.smtp.password ? '******' : '',
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
resend: emailConfig.resend
|
|
||||||
? {
|
|
||||||
from: emailConfig.resend.from,
|
|
||||||
apiKey: emailConfig.resend.apiKey ? '******' : '',
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(safeConfig);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取邮件配置失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST - 保存邮件配置或发送测试邮件
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storage = getStorage();
|
|
||||||
const userInfo = await storage.getUserInfoV2?.(authInfo.username);
|
|
||||||
|
|
||||||
// 只有管理员和站长可以访问
|
|
||||||
if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner')) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { action, config, testEmail } = body;
|
|
||||||
|
|
||||||
// 发送测试邮件
|
|
||||||
if (action === 'test') {
|
|
||||||
if (!testEmail) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '请提供测试邮箱地址' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailConfig = config as AdminConfig['EmailConfig'];
|
|
||||||
if (!emailConfig || !emailConfig.enabled) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '邮件配置未启用' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const adminConfig = await getConfig();
|
|
||||||
const siteName = adminConfig?.SiteConfig?.SiteName || 'MoonTVPlus';
|
|
||||||
await EmailService.sendTestEmail(emailConfig, testEmail, siteName);
|
|
||||||
return NextResponse.json({ success: true, message: '测试邮件发送成功' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('发送测试邮件失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `发送失败: ${(error as Error).message}` },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存邮件配置
|
|
||||||
if (action === 'save') {
|
|
||||||
const emailConfig = config as AdminConfig['EmailConfig'];
|
|
||||||
if (!emailConfig) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '邮件配置不能为空' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证配置
|
|
||||||
if (emailConfig.enabled) {
|
|
||||||
if (emailConfig.provider === 'smtp') {
|
|
||||||
if (!emailConfig.smtp?.host || !emailConfig.smtp?.port || !emailConfig.smtp?.user || !emailConfig.smtp?.from) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'SMTP配置不完整' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (emailConfig.provider === 'resend') {
|
|
||||||
if (!emailConfig.resend?.apiKey || !emailConfig.resend?.from) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Resend配置不完整' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取现有配置
|
|
||||||
const adminConfig = await getConfig();
|
|
||||||
if (!adminConfig) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '管理员配置不存在' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果密码或API Key是占位符,保留原有值
|
|
||||||
if (emailConfig.smtp?.password === '******') {
|
|
||||||
const oldConfig = adminConfig.EmailConfig;
|
|
||||||
if (oldConfig?.smtp?.password) {
|
|
||||||
emailConfig.smtp.password = oldConfig.smtp.password;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emailConfig.resend?.apiKey === '******') {
|
|
||||||
const oldConfig = adminConfig.EmailConfig;
|
|
||||||
if (oldConfig?.resend?.apiKey) {
|
|
||||||
emailConfig.resend.apiKey = oldConfig.resend.apiKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新配置
|
|
||||||
adminConfig.EmailConfig = emailConfig;
|
|
||||||
await storage.setAdminConfig(adminConfig);
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, message: '邮件配置保存成功' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '无效的操作' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('处理邮件配置失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
|
||||||
if (storageType === 'localstorage') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '不支持本地存储进行管理员配置' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 仅站长可用
|
|
||||||
if (authInfo.username !== process.env.USERNAME) {
|
|
||||||
return NextResponse.json({ error: '权限不足,仅站长可用' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminConfig = await getConfig();
|
|
||||||
const embyConfig = adminConfig.EmbyConfig || {};
|
|
||||||
|
|
||||||
const exportData = JSON.stringify(embyConfig, null, 2);
|
|
||||||
|
|
||||||
return new NextResponse(exportData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Content-Disposition': `attachment; filename="emby-config-${Date.now()}.json"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '导出失败: ' + (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
import { db } from '@/lib/db';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
|
||||||
if (storageType === 'localstorage') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '不支持本地存储进行管理员配置' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 仅站长可用
|
|
||||||
if (authInfo.username !== process.env.USERNAME) {
|
|
||||||
return NextResponse.json({ error: '权限不足,仅站长可用' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { data } = body;
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return NextResponse.json({ error: '缺少导入数据' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminConfig = await getConfig();
|
|
||||||
|
|
||||||
// 追加和覆盖:合并Sources数组
|
|
||||||
if (data.Sources && Array.isArray(data.Sources)) {
|
|
||||||
const existingSources = adminConfig.EmbyConfig?.Sources || [];
|
|
||||||
|
|
||||||
// 覆盖已存在的,追加新的
|
|
||||||
const mergedSources = [...existingSources];
|
|
||||||
for (const importSource of data.Sources) {
|
|
||||||
const existingIndex = mergedSources.findIndex(s => s.key === importSource.key);
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
mergedSources[existingIndex] = importSource;
|
|
||||||
} else {
|
|
||||||
mergedSources.push(importSource);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
adminConfig.EmbyConfig = {
|
|
||||||
...adminConfig.EmbyConfig,
|
|
||||||
Sources: mergedSources,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// 旧格式:直接覆盖
|
|
||||||
adminConfig.EmbyConfig = {
|
|
||||||
...adminConfig.EmbyConfig,
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.saveAdminConfig(adminConfig);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: '导入成功',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '导入失败: ' + (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
import { db } from '@/lib/db';
|
|
||||||
import { EmbyClient } from '@/lib/emby.client';
|
|
||||||
import { clearEmbyCache } from '@/lib/emby-cache';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/admin/emby
|
|
||||||
* 保存 Emby 配置
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
|
||||||
if (storageType === 'localstorage') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '不支持本地存储进行管理员配置' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const { action, Enabled, ServerURL, ApiKey, Username, Password, UserId, Libraries } = body;
|
|
||||||
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
const username = authInfo.username;
|
|
||||||
|
|
||||||
// 获取配置
|
|
||||||
const adminConfig = await getConfig();
|
|
||||||
|
|
||||||
// 权限检查
|
|
||||||
if (username !== process.env.USERNAME) {
|
|
||||||
const userInfo = await db.getUserInfoV2(username);
|
|
||||||
if (!userInfo || userInfo.role !== 'admin' || userInfo.banned) {
|
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'save') {
|
|
||||||
// 如果功能未启用,允许保存空配置
|
|
||||||
if (!Enabled) {
|
|
||||||
adminConfig.EmbyConfig = {
|
|
||||||
Enabled: false,
|
|
||||||
ServerURL: ServerURL || '',
|
|
||||||
ApiKey: ApiKey || '',
|
|
||||||
Username: Username || '',
|
|
||||||
Password: Password || '',
|
|
||||||
Libraries: Libraries || [],
|
|
||||||
};
|
|
||||||
await db.saveAdminConfig(adminConfig);
|
|
||||||
return NextResponse.json({ success: true, message: 'Emby 配置已保存(未启用)' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证必填字段
|
|
||||||
if (!ServerURL) {
|
|
||||||
return NextResponse.json({ error: '请填写 Emby 服务器地址' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ApiKey && (!Username || !Password)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '请填写 API Key 或用户名密码' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试连接
|
|
||||||
const testConfig = {
|
|
||||||
ServerURL,
|
|
||||||
ApiKey,
|
|
||||||
Username,
|
|
||||||
Password,
|
|
||||||
UserId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const client = new EmbyClient(testConfig);
|
|
||||||
|
|
||||||
// 如果使用用户名密码,先认证
|
|
||||||
let finalUserId: string | undefined = UserId; // 使用用户提供的 UserId
|
|
||||||
let authToken: string | undefined;
|
|
||||||
if (!ApiKey && Username && Password) {
|
|
||||||
try {
|
|
||||||
const authResult = await client.authenticate(Username, Password);
|
|
||||||
finalUserId = authResult.User.Id;
|
|
||||||
authToken = authResult.AccessToken;
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Emby 认证失败: ' + (error as Error).message },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// <20><>试连接
|
|
||||||
const isConnected = await client.checkConnectivity();
|
|
||||||
if (!isConnected) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Emby 连接失败,请检查服务器地址和认证信息' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存配置
|
|
||||||
adminConfig.EmbyConfig = {
|
|
||||||
Enabled: true,
|
|
||||||
ServerURL,
|
|
||||||
ApiKey: ApiKey || undefined,
|
|
||||||
Username: Username || undefined,
|
|
||||||
Password: Password || undefined,
|
|
||||||
UserId: finalUserId,
|
|
||||||
AuthToken: authToken,
|
|
||||||
Libraries: Libraries || [],
|
|
||||||
LastSyncTime: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.saveAdminConfig(adminConfig);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Emby 配置已保存并测试成功',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'test') {
|
|
||||||
// 测试连接
|
|
||||||
if (!ServerURL) {
|
|
||||||
return NextResponse.json({ error: '请填写 Emby 服务器地址' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ApiKey && (!Username || !Password)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '请填写 API Key 或用户名密码' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const testConfig = {
|
|
||||||
ServerURL,
|
|
||||||
ApiKey,
|
|
||||||
Username,
|
|
||||||
Password,
|
|
||||||
};
|
|
||||||
|
|
||||||
const client = new EmbyClient(testConfig);
|
|
||||||
|
|
||||||
// 如果使用用户名密码,先认证
|
|
||||||
if (!ApiKey && Username && Password) {
|
|
||||||
try {
|
|
||||||
await client.authenticate(Username, Password);
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, message: 'Emby 认证失败: ' + (error as Error).message },
|
|
||||||
{ status: 200 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试连接
|
|
||||||
const isConnected = await client.checkConnectivity();
|
|
||||||
if (!isConnected) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, message: 'Emby 连接失败,请检查服务器地址和认证信息' },
|
|
||||||
{ status: 200 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Emby 连接测试成功',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'clearCache') {
|
|
||||||
// 清除缓存
|
|
||||||
const result = clearEmbyCache();
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: `已清除 ${result.cleared} 条 Emby 缓存`,
|
|
||||||
cleared: result.cleared,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: '不支持的操作' }, { status: 400 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Emby 配置保存失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Emby 配置保存失败: ' + (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { action, Enabled, URL, Username, Password, RootPaths, OfflineDownloadPath, ScanInterval, ScanMode, DisableVideoPreview } = body;
|
const { action, Enabled, URL, Username, Password, RootPath, OfflineDownloadPath, ScanInterval } = body;
|
||||||
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
@@ -53,13 +53,11 @@ export async function POST(request: NextRequest) {
|
|||||||
URL: URL || '',
|
URL: URL || '',
|
||||||
Username: Username || '',
|
Username: Username || '',
|
||||||
Password: Password || '',
|
Password: Password || '',
|
||||||
RootPaths: RootPaths || ['/'],
|
RootPath: RootPath || '/',
|
||||||
OfflineDownloadPath: OfflineDownloadPath || '/',
|
OfflineDownloadPath: OfflineDownloadPath || '/',
|
||||||
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||||
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
||||||
ScanInterval: 0,
|
ScanInterval: 0,
|
||||||
ScanMode: ScanMode || 'hybrid',
|
|
||||||
DisableVideoPreview: DisableVideoPreview || false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.saveAdminConfig(adminConfig);
|
await db.saveAdminConfig(adminConfig);
|
||||||
@@ -78,16 +76,8 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证 RootPaths
|
|
||||||
if (!Array.isArray(RootPaths) || RootPaths.length === 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '请至少提供一个根目录' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证扫描间隔
|
// 验证扫描间隔
|
||||||
const scanInterval = parseInt(ScanInterval) || 0;
|
let scanInterval = parseInt(ScanInterval) || 0;
|
||||||
if (scanInterval > 0 && scanInterval < 60) {
|
if (scanInterval > 0 && scanInterval < 60) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '定时扫描间隔最低为 60 分钟' },
|
{ error: '定时扫描间隔最低为 60 分钟' },
|
||||||
@@ -113,13 +103,11 @@ export async function POST(request: NextRequest) {
|
|||||||
URL,
|
URL,
|
||||||
Username,
|
Username,
|
||||||
Password,
|
Password,
|
||||||
RootPaths,
|
RootPath: RootPath || '/',
|
||||||
OfflineDownloadPath: OfflineDownloadPath || '/',
|
OfflineDownloadPath: OfflineDownloadPath || '/',
|
||||||
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||||
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
||||||
ScanInterval: scanInterval,
|
ScanInterval: scanInterval,
|
||||||
ScanMode: ScanMode || 'hybrid',
|
|
||||||
DisableVideoPreview: DisableVideoPreview || false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.saveAdminConfig(adminConfig);
|
await db.saveAdminConfig(adminConfig);
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { clearConfigCache } from '@/lib/config';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
|
||||||
if (storageType === 'localstorage') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: '不支持本地存储进行管理员配置',
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
const username = authInfo.username;
|
|
||||||
|
|
||||||
if (username !== process.env.USERNAME) {
|
|
||||||
return NextResponse.json({ error: '仅支持站长重载配置' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await clearConfigCache();
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ ok: true },
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: '重载配置失败',
|
|
||||||
details: (error as Error).message,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,13 +43,10 @@ export async function POST(request: NextRequest) {
|
|||||||
DanmakuApiToken,
|
DanmakuApiToken,
|
||||||
TMDBApiKey,
|
TMDBApiKey,
|
||||||
TMDBProxy,
|
TMDBProxy,
|
||||||
TMDBReverseProxy,
|
|
||||||
BannerDataSource,
|
BannerDataSource,
|
||||||
RecommendationDataSource,
|
|
||||||
PansouApiUrl,
|
PansouApiUrl,
|
||||||
PansouUsername,
|
PansouUsername,
|
||||||
PansouPassword,
|
PansouPassword,
|
||||||
PansouKeywordBlocklist,
|
|
||||||
EnableComments,
|
EnableComments,
|
||||||
CustomAdFilterCode,
|
CustomAdFilterCode,
|
||||||
CustomAdFilterVersion,
|
CustomAdFilterVersion,
|
||||||
@@ -84,13 +81,10 @@ export async function POST(request: NextRequest) {
|
|||||||
DanmakuApiToken: string;
|
DanmakuApiToken: string;
|
||||||
TMDBApiKey?: string;
|
TMDBApiKey?: string;
|
||||||
TMDBProxy?: string;
|
TMDBProxy?: string;
|
||||||
TMDBReverseProxy?: string;
|
|
||||||
BannerDataSource?: string;
|
BannerDataSource?: string;
|
||||||
RecommendationDataSource?: string;
|
|
||||||
PansouApiUrl?: string;
|
PansouApiUrl?: string;
|
||||||
PansouUsername?: string;
|
PansouUsername?: string;
|
||||||
PansouPassword?: string;
|
PansouPassword?: string;
|
||||||
PansouKeywordBlocklist?: string;
|
|
||||||
EnableComments: boolean;
|
EnableComments: boolean;
|
||||||
CustomAdFilterCode?: string;
|
CustomAdFilterCode?: string;
|
||||||
CustomAdFilterVersion?: number;
|
CustomAdFilterVersion?: number;
|
||||||
@@ -128,10 +122,7 @@ export async function POST(request: NextRequest) {
|
|||||||
typeof DanmakuApiToken !== 'string' ||
|
typeof DanmakuApiToken !== 'string' ||
|
||||||
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
|
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
|
||||||
(TMDBProxy !== undefined && typeof TMDBProxy !== 'string') ||
|
(TMDBProxy !== undefined && typeof TMDBProxy !== 'string') ||
|
||||||
(TMDBReverseProxy !== undefined && typeof TMDBReverseProxy !== 'string') ||
|
|
||||||
(BannerDataSource !== undefined && typeof BannerDataSource !== 'string') ||
|
(BannerDataSource !== undefined && typeof BannerDataSource !== 'string') ||
|
||||||
(RecommendationDataSource !== undefined && typeof RecommendationDataSource !== 'string') ||
|
|
||||||
(PansouKeywordBlocklist !== undefined && typeof PansouKeywordBlocklist !== 'string') ||
|
|
||||||
typeof EnableComments !== 'boolean' ||
|
typeof EnableComments !== 'boolean' ||
|
||||||
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
|
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
|
||||||
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
|
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
|
||||||
@@ -181,13 +172,10 @@ export async function POST(request: NextRequest) {
|
|||||||
DanmakuApiToken,
|
DanmakuApiToken,
|
||||||
TMDBApiKey,
|
TMDBApiKey,
|
||||||
TMDBProxy,
|
TMDBProxy,
|
||||||
TMDBReverseProxy,
|
|
||||||
BannerDataSource,
|
BannerDataSource,
|
||||||
RecommendationDataSource,
|
|
||||||
PansouApiUrl,
|
PansouApiUrl,
|
||||||
PansouUsername,
|
PansouUsername,
|
||||||
PansouPassword,
|
PansouPassword,
|
||||||
PansouKeywordBlocklist,
|
|
||||||
EnableComments,
|
EnableComments,
|
||||||
CustomAdFilterCode,
|
CustomAdFilterCode,
|
||||||
CustomAdFilterVersion,
|
CustomAdFilterVersion,
|
||||||
|
|||||||
@@ -65,16 +65,10 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!key || !name || !api) {
|
if (!key || !name || !api) {
|
||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
}
|
}
|
||||||
// 禁止添加保留关键字
|
// 禁止添加 openlist 保留关键字
|
||||||
if (key === 'openlist' || key === 'xiaoya') {
|
if (key === 'openlist') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `${key} 是保留关键字,不能作为视频源 key` },
|
{ error: 'openlist 是保留关键字,不能作为视频源 key' },
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (key.startsWith('emby')) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'emby 开头的 key 是保留关键字,不能作为视频源 key' },
|
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -173,17 +167,10 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!Array.isArray(keys) || keys.length === 0) {
|
if (!Array.isArray(keys) || keys.length === 0) {
|
||||||
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
// 过滤掉 from=config 的源,记录跳过的数量
|
// 过滤掉 from=config 的源,但不报错
|
||||||
const keysToDelete: string[] = [];
|
const keysToDelete = keys.filter(key => {
|
||||||
const skippedKeys: string[] = [];
|
|
||||||
|
|
||||||
keys.forEach(key => {
|
|
||||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||||
if (entry && entry.from === 'config') {
|
return entry && entry.from !== 'config';
|
||||||
skippedKeys.push(key);
|
|
||||||
} else if (entry) {
|
|
||||||
keysToDelete.push(key);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 批量删除
|
// 批量删除
|
||||||
@@ -212,12 +199,6 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存批量删除的统计信息,稍后返回
|
|
||||||
(body as any)._batchDeleteResult = {
|
|
||||||
deleted: keysToDelete.length,
|
|
||||||
skipped: skippedKeys.length,
|
|
||||||
};
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'sort': {
|
case 'sort': {
|
||||||
@@ -270,17 +251,8 @@ export async function POST(request: NextRequest) {
|
|||||||
// 不影响主流程,继续执行
|
// 不影响主流程,继续执行
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建响应数据
|
|
||||||
const responseData: Record<string, any> = { ok: true };
|
|
||||||
|
|
||||||
// 如果是批量删除操作,包含统计信息
|
|
||||||
if (action === 'batch_delete' && (body as any)._batchDeleteResult) {
|
|
||||||
responseData.deleted = (body as any)._batchDeleteResult.deleted;
|
|
||||||
responseData.skipped = (body as any)._batchDeleteResult.skipped;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
responseData,
|
{ ok: true },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
|
|||||||
@@ -454,9 +454,21 @@ export async function POST(request: NextRequest) {
|
|||||||
// 权限检查:站长可批量配置所有人的用户组,管理员只能批量配置普通用户
|
// 权限检查:站长可批量配置所有人的用户组,管理员只能批量配置普通用户
|
||||||
if (operatorRole !== 'owner') {
|
if (operatorRole !== 'owner') {
|
||||||
for (const targetUsername of usernames) {
|
for (const targetUsername of usernames) {
|
||||||
// 从V2存储中查找用户
|
// 先从配置中查找
|
||||||
const userV2 = await db.getUserInfoV2(targetUsername);
|
let targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
|
||||||
if (userV2 && userV2.role === 'admin' && targetUsername !== username) {
|
// 如果配置中没有,从V2存储中查找
|
||||||
|
if (!targetUser) {
|
||||||
|
const userV2 = await db.getUserInfoV2(targetUsername);
|
||||||
|
if (userV2) {
|
||||||
|
targetUser = {
|
||||||
|
username: targetUsername,
|
||||||
|
role: userV2.role,
|
||||||
|
banned: userV2.banned,
|
||||||
|
tags: userV2.tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetUser && targetUser.role === 'admin' && targetUsername !== username) {
|
||||||
return NextResponse.json({ error: `管理员无法操作其他管理员 ${targetUsername}` }, { status: 400 });
|
return NextResponse.json({ error: `管理员无法操作其他管理员 ${targetUsername}` }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -23,6 +24,9 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
const adminConfig = await getConfig();
|
||||||
|
|
||||||
// 判定操作者角色
|
// 判定操作者角色
|
||||||
let operatorRole: 'owner' | 'admin' | 'user' = 'user';
|
let operatorRole: 'owner' | 'admin' | 'user' = 'user';
|
||||||
if (authInfo.username === process.env.USERNAME) {
|
if (authInfo.username === process.env.USERNAME) {
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
import { db } from '@/lib/db';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
const username = authInfo?.username;
|
|
||||||
const config = await getConfig();
|
|
||||||
if (username !== process.env.USERNAME) {
|
|
||||||
const userInfo = await db.getUserInfoV2(username || '');
|
|
||||||
if (!userInfo || userInfo.role !== 'admin' || userInfo.banned) {
|
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { action } = body;
|
|
||||||
|
|
||||||
if (!config) {
|
|
||||||
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.WebLiveConfig) {
|
|
||||||
config.WebLiveConfig = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'toggleEnabled': {
|
|
||||||
const { enabled } = body;
|
|
||||||
config.WebLiveEnabled = enabled;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'add': {
|
|
||||||
const { name, platform, roomId } = body;
|
|
||||||
if (!name || !platform || !roomId) {
|
|
||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = `web_${Date.now()}`;
|
|
||||||
if (config.WebLiveConfig.some((s) => s.key === key)) {
|
|
||||||
return NextResponse.json({ error: 'Key已存在' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
config.WebLiveConfig.push({
|
|
||||||
key,
|
|
||||||
name,
|
|
||||||
platform,
|
|
||||||
roomId,
|
|
||||||
from: 'custom',
|
|
||||||
disabled: false,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'delete': {
|
|
||||||
const { key } = body;
|
|
||||||
const source = config.WebLiveConfig.find((s) => s.key === key);
|
|
||||||
if (!source) {
|
|
||||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
|
||||||
}
|
|
||||||
if (source.from === 'config') {
|
|
||||||
return NextResponse.json({ error: '无法删除配置文件中的源' }, { status: 400 });
|
|
||||||
}
|
|
||||||
config.WebLiveConfig = config.WebLiveConfig.filter((s) => s.key !== key);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'enable': {
|
|
||||||
const { key } = body;
|
|
||||||
const source = config.WebLiveConfig.find((s) => s.key === key);
|
|
||||||
if (!source) {
|
|
||||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
|
||||||
}
|
|
||||||
source.disabled = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'disable': {
|
|
||||||
const { key } = body;
|
|
||||||
const source = config.WebLiveConfig.find((s) => s.key === key);
|
|
||||||
if (!source) {
|
|
||||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
|
||||||
}
|
|
||||||
source.disabled = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'edit': {
|
|
||||||
const { key, name, platform, roomId } = body;
|
|
||||||
const source = config.WebLiveConfig.find((s) => s.key === key);
|
|
||||||
if (!source) {
|
|
||||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
|
||||||
}
|
|
||||||
if (source.from === 'config') {
|
|
||||||
return NextResponse.json({ error: '无法编辑配置文件中的源' }, { status: 400 });
|
|
||||||
}
|
|
||||||
source.name = name;
|
|
||||||
source.platform = platform;
|
|
||||||
source.roomId = roomId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'sort': {
|
|
||||||
const { keys } = body;
|
|
||||||
if (!Array.isArray(keys)) {
|
|
||||||
return NextResponse.json({ error: '无效的排序数据' }, { status: 400 });
|
|
||||||
}
|
|
||||||
const sortedSources = keys
|
|
||||||
.map((key) => config.WebLiveConfig!.find((s) => s.key === key))
|
|
||||||
.filter((s): s is NonNullable<typeof s> => s !== undefined);
|
|
||||||
config.WebLiveConfig = sortedSources;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.saveAdminConfig(config);
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error instanceof Error ? error.message : '操作失败' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
import { db } from '@/lib/db';
|
|
||||||
import { XiaoyaClient } from '@/lib/xiaoya.client';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/admin/xiaoya
|
|
||||||
* 管理小雅配置
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) {
|
|
||||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { action, ...configData } = body;
|
|
||||||
|
|
||||||
if (action === 'test') {
|
|
||||||
// 测试连接
|
|
||||||
try {
|
|
||||||
const client = new XiaoyaClient(
|
|
||||||
configData.ServerURL,
|
|
||||||
configData.Username,
|
|
||||||
configData.Password,
|
|
||||||
configData.Token
|
|
||||||
);
|
|
||||||
|
|
||||||
// 尝试列出根目录
|
|
||||||
await client.listDirectory('/');
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, message: '连接成功' });
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, message: (error as Error).message },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'save') {
|
|
||||||
// 保存配置
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
config.XiaoyaConfig = {
|
|
||||||
Enabled: configData.Enabled || false,
|
|
||||||
ServerURL: configData.ServerURL || '',
|
|
||||||
Token: configData.Token,
|
|
||||||
Username: configData.Username,
|
|
||||||
Password: configData.Password,
|
|
||||||
DisableVideoPreview: configData.DisableVideoPreview || false,
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.saveAdminConfig(config);
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, message: '保存成功' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: '无效的操作' }, { status: 400 });
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import {
|
import {
|
||||||
orchestrateDataSources,
|
orchestrateDataSources,
|
||||||
VideoContext,
|
VideoContext,
|
||||||
} from '@/lib/ai-orchestrator';
|
} from '@/lib/ai-orchestrator';
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
@@ -34,9 +34,8 @@ async function streamOpenAIChat(
|
|||||||
model: string;
|
model: string;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
},
|
}
|
||||||
enableStreaming = true
|
): Promise<ReadableStream> {
|
||||||
): Promise<ReadableStream | Response> {
|
|
||||||
const response = await fetch(`${config.baseURL}/chat/completions`, {
|
const response = await fetch(`${config.baseURL}/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -48,7 +47,7 @@ async function streamOpenAIChat(
|
|||||||
messages,
|
messages,
|
||||||
temperature: config.temperature,
|
temperature: config.temperature,
|
||||||
max_tokens: config.maxTokens,
|
max_tokens: config.maxTokens,
|
||||||
stream: enableStreaming,
|
stream: true,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,7 +57,46 @@ async function streamOpenAIChat(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return enableStreaming ? response.body! : response;
|
return response.body!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude API流式聊天请求
|
||||||
|
*/
|
||||||
|
async function streamClaudeChat(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
systemPrompt: string,
|
||||||
|
config: {
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
maxTokens: number;
|
||||||
|
}
|
||||||
|
): Promise<ReadableStream> {
|
||||||
|
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': config.apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: config.model,
|
||||||
|
max_tokens: config.maxTokens,
|
||||||
|
temperature: config.temperature,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages: messages,
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Claude API error: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.body!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,7 +239,6 @@ export async function POST(request: NextRequest) {
|
|||||||
// TMDB 配置
|
// TMDB 配置
|
||||||
tmdbApiKey: adminConfig.SiteConfig.TMDBApiKey,
|
tmdbApiKey: adminConfig.SiteConfig.TMDBApiKey,
|
||||||
tmdbProxy: adminConfig.SiteConfig.TMDBProxy,
|
tmdbProxy: adminConfig.SiteConfig.TMDBProxy,
|
||||||
tmdbReverseProxy: adminConfig.SiteConfig.TMDBReverseProxy,
|
|
||||||
// 决策模型配置(固定使用自定义provider,复用主模型的API配置)
|
// 决策模型配置(固定使用自定义provider,复用主模型的API配置)
|
||||||
enableDecisionModel: aiConfig.EnableDecisionModel,
|
enableDecisionModel: aiConfig.EnableDecisionModel,
|
||||||
decisionProvider: 'custom',
|
decisionProvider: 'custom',
|
||||||
@@ -228,7 +265,6 @@ export async function POST(request: NextRequest) {
|
|||||||
// 6. 调用自定义API
|
// 6. 调用自定义API
|
||||||
const temperature = aiConfig.Temperature ?? 0.7;
|
const temperature = aiConfig.Temperature ?? 0.7;
|
||||||
const maxTokens = aiConfig.MaxTokens ?? 1000;
|
const maxTokens = aiConfig.MaxTokens ?? 1000;
|
||||||
const enableStreaming = aiConfig.EnableStreaming !== false; // 默认启用流式响应
|
|
||||||
|
|
||||||
if (!aiConfig.CustomApiKey || !aiConfig.CustomBaseURL) {
|
if (!aiConfig.CustomApiKey || !aiConfig.CustomBaseURL) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -237,34 +273,24 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await streamOpenAIChat(messages, {
|
const stream = await streamOpenAIChat(messages, {
|
||||||
apiKey: aiConfig.CustomApiKey,
|
apiKey: aiConfig.CustomApiKey,
|
||||||
baseURL: aiConfig.CustomBaseURL,
|
baseURL: aiConfig.CustomBaseURL,
|
||||||
model: aiConfig.CustomModel || 'gpt-3.5-turbo',
|
model: aiConfig.CustomModel || 'gpt-3.5-turbo',
|
||||||
temperature,
|
temperature,
|
||||||
maxTokens,
|
maxTokens,
|
||||||
}, enableStreaming);
|
});
|
||||||
|
|
||||||
// 7. 根据是否启用流式响应返回不同格式
|
// 7. 转换为SSE格式并返回
|
||||||
if (enableStreaming) {
|
const sseStream = transformToSSE(stream, 'openai');
|
||||||
// 流式响应:转换为SSE格式并返回
|
|
||||||
const sseStream = transformToSSE(result as ReadableStream, 'openai');
|
|
||||||
|
|
||||||
return new NextResponse(sseStream, {
|
return new NextResponse(sseStream, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Connection: 'keep-alive',
|
Connection: 'keep-alive',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// 非流式响应:等待完整响应后返回JSON
|
|
||||||
const response = result as Response;
|
|
||||||
const data = await response.json();
|
|
||||||
const content = data.choices?.[0]?.message?.content || '';
|
|
||||||
|
|
||||||
return NextResponse.json({ content });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ AI聊天API错误:', error);
|
console.error('❌ AI聊天API错误:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import {
|
|
||||||
getUserDevices,
|
|
||||||
revokeAllRefreshTokens,
|
|
||||||
revokeRefreshToken,
|
|
||||||
} from '@/lib/refresh-token';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
// 获取所有设备
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const devices = await getUserDevices(authInfo.username);
|
|
||||||
|
|
||||||
// 标记当前设备
|
|
||||||
const devicesWithCurrent = devices.map((device) => ({
|
|
||||||
...device,
|
|
||||||
isCurrent: device.tokenId === authInfo.tokenId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({ devices: devicesWithCurrent });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get devices:', error);
|
|
||||||
return NextResponse.json({ error: 'Server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 撤销指定设备
|
|
||||||
export async function DELETE(request: NextRequest) {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { tokenId } = await request.json();
|
|
||||||
|
|
||||||
if (!tokenId) {
|
|
||||||
return NextResponse.json({ error: 'Token ID required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await revokeRefreshToken(authInfo.username, tokenId);
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to revoke device:', error);
|
|
||||||
return NextResponse.json({ error: 'Server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登出所有设备
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await revokeAllRefreshTokens(authInfo.username);
|
|
||||||
|
|
||||||
const response = NextResponse.json({ ok: true });
|
|
||||||
|
|
||||||
// 清除当前设备的 Cookie
|
|
||||||
response.cookies.set('auth', '', {
|
|
||||||
path: '/',
|
|
||||||
expires: new Date(0),
|
|
||||||
sameSite: 'lax',
|
|
||||||
httpOnly: false,
|
|
||||||
secure: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to revoke all devices:', error);
|
|
||||||
return NextResponse.json({ error: 'Server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,6 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import {
|
|
||||||
generateRefreshToken,
|
|
||||||
generateTokenId,
|
|
||||||
storeRefreshToken,
|
|
||||||
TOKEN_CONFIG,
|
|
||||||
} from '@/lib/refresh-token';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -36,66 +30,18 @@ async function generateSignature(
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取设备信息
|
|
||||||
function getDeviceInfo(userAgent: string): string {
|
|
||||||
const ua = userAgent.toLowerCase();
|
|
||||||
|
|
||||||
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
|
||||||
if (ua.includes('android')) return 'Android Mobile';
|
|
||||||
if (ua.includes('iphone')) return 'iPhone';
|
|
||||||
return 'Mobile Device';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ua.includes('tablet') || ua.includes('ipad')) {
|
|
||||||
return 'Tablet';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ua.includes('windows')) return 'Windows PC';
|
|
||||||
if (ua.includes('mac')) return 'Mac';
|
|
||||||
if (ua.includes('linux')) return 'Linux';
|
|
||||||
|
|
||||||
return 'Unknown Device';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成认证Cookie
|
// 生成认证Cookie
|
||||||
async function generateAuthCookie(
|
async function generateAuthCookie(
|
||||||
username: string,
|
username: string,
|
||||||
role: 'owner' | 'admin' | 'user',
|
role: 'owner' | 'admin' | 'user'
|
||||||
deviceInfo: string
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const authData: any = { role };
|
const authData: any = { role };
|
||||||
|
|
||||||
if (username && process.env.PASSWORD) {
|
if (username && process.env.PASSWORD) {
|
||||||
authData.username = username;
|
authData.username = username;
|
||||||
authData.timestamp = Date.now();
|
const signature = await generateSignature(username, process.env.PASSWORD);
|
||||||
|
|
||||||
// 生成签名(包含 username, role, timestamp)
|
|
||||||
const dataToSign = JSON.stringify({
|
|
||||||
username: authData.username,
|
|
||||||
role: authData.role,
|
|
||||||
timestamp: authData.timestamp
|
|
||||||
});
|
|
||||||
const signature = await generateSignature(dataToSign, process.env.PASSWORD);
|
|
||||||
authData.signature = signature;
|
authData.signature = signature;
|
||||||
|
authData.timestamp = Date.now();
|
||||||
// 生成双 Token
|
|
||||||
const tokenId = generateTokenId();
|
|
||||||
const refreshToken = generateRefreshToken();
|
|
||||||
const now = Date.now();
|
|
||||||
const refreshExpires = now + TOKEN_CONFIG.REFRESH_TOKEN_AGE;
|
|
||||||
|
|
||||||
authData.tokenId = tokenId;
|
|
||||||
authData.refreshToken = refreshToken;
|
|
||||||
authData.refreshExpires = refreshExpires;
|
|
||||||
|
|
||||||
// 存储 Refresh Token
|
|
||||||
await storeRefreshToken(username, tokenId, {
|
|
||||||
token: refreshToken,
|
|
||||||
deviceInfo,
|
|
||||||
createdAt: now,
|
|
||||||
expiresAt: refreshExpires,
|
|
||||||
lastUsed: now,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return encodeURIComponent(JSON.stringify(authData));
|
return encodeURIComponent(JSON.stringify(authData));
|
||||||
@@ -202,11 +148,12 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户是否已存在(通过OIDC sub查找)
|
// 检查用户是否已存在(通过OIDC sub查找)
|
||||||
const username = await db.getUserByOidcSub(oidcSub);
|
// 优先使用新版本查找
|
||||||
|
let username = await db.getUserByOidcSub(oidcSub);
|
||||||
let userRole: 'owner' | 'admin' | 'user' = 'user';
|
let userRole: 'owner' | 'admin' | 'user' = 'user';
|
||||||
|
|
||||||
if (username) {
|
if (username) {
|
||||||
// 获取用户信息
|
// 从新版本获取用户信息
|
||||||
const userInfoV2 = await db.getUserInfoV2(username);
|
const userInfoV2 = await db.getUserInfoV2(username);
|
||||||
if (userInfoV2) {
|
if (userInfoV2) {
|
||||||
userRole = userInfoV2.role;
|
userRole = userInfoV2.role;
|
||||||
@@ -217,15 +164,27 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 回退到配置中查找
|
||||||
|
const existingUser = config.UserConfig.Users.find((u: any) => u.oidcSub === oidcSub);
|
||||||
|
if (existingUser) {
|
||||||
|
username = existingUser.username;
|
||||||
|
userRole = existingUser.role || 'user';
|
||||||
|
// 检查用户是否被封禁
|
||||||
|
if (existingUser.banned) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL('/login?error=' + encodeURIComponent('用户被封禁'), origin)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (username) {
|
if (username) {
|
||||||
// 用户已存在,直接登录
|
// 用户已存在,直接登录
|
||||||
const response = NextResponse.redirect(new URL('/', origin));
|
const response = NextResponse.redirect(new URL('/', origin));
|
||||||
const userAgent = request.headers.get('user-agent') || 'Unknown';
|
const cookieValue = await generateAuthCookie(username, userRole);
|
||||||
const deviceInfo = getDeviceInfo(userAgent);
|
const expires = new Date();
|
||||||
const cookieValue = await generateAuthCookie(username, userRole, deviceInfo);
|
expires.setDate(expires.getDate() + 7);
|
||||||
const expires = new Date(Date.now() + TOKEN_CONFIG.REFRESH_TOKEN_AGE);
|
|
||||||
|
|
||||||
response.cookies.set('auth', cookieValue, {
|
response.cookies.set('auth', cookieValue, {
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -262,6 +221,7 @@ export async function GET(request: NextRequest) {
|
|||||||
response.cookies.set('oidc_session', JSON.stringify(oidcSession), {
|
response.cookies.set('oidc_session', JSON.stringify(oidcSession), {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 600, // 10分钟
|
maxAge: 600, // 10分钟
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,6 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import {
|
|
||||||
generateRefreshToken,
|
|
||||||
generateTokenId,
|
|
||||||
storeRefreshToken,
|
|
||||||
TOKEN_CONFIG,
|
|
||||||
} from '@/lib/refresh-token';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -36,66 +30,18 @@ async function generateSignature(
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取设备信息
|
|
||||||
function getDeviceInfo(userAgent: string): string {
|
|
||||||
const ua = userAgent.toLowerCase();
|
|
||||||
|
|
||||||
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
|
||||||
if (ua.includes('android')) return 'Android Mobile';
|
|
||||||
if (ua.includes('iphone')) return 'iPhone';
|
|
||||||
return 'Mobile Device';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ua.includes('tablet') || ua.includes('ipad')) {
|
|
||||||
return 'Tablet';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ua.includes('windows')) return 'Windows PC';
|
|
||||||
if (ua.includes('mac')) return 'Mac';
|
|
||||||
if (ua.includes('linux')) return 'Linux';
|
|
||||||
|
|
||||||
return 'Unknown Device';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成认证Cookie
|
// 生成认证Cookie
|
||||||
async function generateAuthCookie(
|
async function generateAuthCookie(
|
||||||
username: string,
|
username: string,
|
||||||
role: 'owner' | 'admin' | 'user',
|
role: 'owner' | 'admin' | 'user'
|
||||||
deviceInfo: string
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const authData: any = { role };
|
const authData: any = { role };
|
||||||
|
|
||||||
if (username && process.env.PASSWORD) {
|
if (username && process.env.PASSWORD) {
|
||||||
authData.username = username;
|
authData.username = username;
|
||||||
authData.timestamp = Date.now();
|
const signature = await generateSignature(username, process.env.PASSWORD);
|
||||||
|
|
||||||
// 生成签名(包含 username, role, timestamp)
|
|
||||||
const dataToSign = JSON.stringify({
|
|
||||||
username: authData.username,
|
|
||||||
role: authData.role,
|
|
||||||
timestamp: authData.timestamp
|
|
||||||
});
|
|
||||||
const signature = await generateSignature(dataToSign, process.env.PASSWORD);
|
|
||||||
authData.signature = signature;
|
authData.signature = signature;
|
||||||
|
authData.timestamp = Date.now();
|
||||||
// 生成双 Token
|
|
||||||
const tokenId = generateTokenId();
|
|
||||||
const refreshToken = generateRefreshToken();
|
|
||||||
const now = Date.now();
|
|
||||||
const refreshExpires = now + TOKEN_CONFIG.REFRESH_TOKEN_AGE;
|
|
||||||
|
|
||||||
authData.tokenId = tokenId;
|
|
||||||
authData.refreshToken = refreshToken;
|
|
||||||
authData.refreshExpires = refreshExpires;
|
|
||||||
|
|
||||||
// 存储 Refresh Token
|
|
||||||
await storeRefreshToken(username, tokenId, {
|
|
||||||
token: refreshToken,
|
|
||||||
deviceInfo,
|
|
||||||
createdAt: now,
|
|
||||||
expiresAt: refreshExpires,
|
|
||||||
lastUsed: now,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return encodeURIComponent(JSON.stringify(authData));
|
return encodeURIComponent(JSON.stringify(authData));
|
||||||
@@ -176,8 +122,8 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户名是否已存在
|
// 检查用户名是否已存在(优先使用新版本)
|
||||||
const userExists = await db.checkUserExistV2(username);
|
let userExists = await db.checkUserExistV2(username);
|
||||||
if (userExists) {
|
if (userExists) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '用户名已存在' },
|
{ error: '用户名已存在' },
|
||||||
@@ -185,8 +131,24 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查OIDC sub是否已被使用
|
// 检查配置中是否已存在
|
||||||
const existingOIDCUsername = await db.getUserByOidcSub(oidcSession.sub);
|
const existingUser = config.UserConfig.Users.find((u) => u.username === username);
|
||||||
|
if (existingUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '用户名已存在' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查OIDC sub是否已被使用(优先使用新版本)
|
||||||
|
let existingOIDCUsername = await db.getUserByOidcSub(oidcSession.sub);
|
||||||
|
if (!existingOIDCUsername) {
|
||||||
|
// 回退到配置中查找
|
||||||
|
const existingOIDCUser = config.UserConfig.Users.find((u: any) => u.oidcSub === oidcSession.sub);
|
||||||
|
if (existingOIDCUser) {
|
||||||
|
existingOIDCUsername = existingOIDCUser.username;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (existingOIDCUsername) {
|
if (existingOIDCUsername) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '该OIDC账号已被注册' },
|
{ error: '该OIDC账号已被注册' },
|
||||||
@@ -209,10 +171,9 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// 设置认证cookie
|
// 设置认证cookie
|
||||||
const response = NextResponse.json({ ok: true, message: '注册成功' });
|
const response = NextResponse.json({ ok: true, message: '注册成功' });
|
||||||
const userAgent = request.headers.get('user-agent') || 'Unknown';
|
const cookieValue = await generateAuthCookie(username, 'user');
|
||||||
const deviceInfo = getDeviceInfo(userAgent);
|
const expires = new Date();
|
||||||
const cookieValue = await generateAuthCookie(username, 'user', deviceInfo);
|
expires.setDate(expires.getDate() + 7);
|
||||||
const expires = new Date(Date.now() + TOKEN_CONFIG.REFRESH_TOKEN_AGE);
|
|
||||||
|
|
||||||
response.cookies.set('auth', cookieValue, {
|
response.cookies.set('auth', cookieValue, {
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export async function GET(request: NextRequest) {
|
|||||||
response.cookies.set('oidc_state', state, {
|
response.cookies.set('oidc_state', state, {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 600, // 10分钟
|
maxAge: 600, // 10分钟
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getUserDevices, revokeRefreshToken } from '@/lib/refresh-token';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -49,25 +48,6 @@ export async function POST(request: NextRequest) {
|
|||||||
// 修改密码(只更新V2存储)
|
// 修改密码(只更新V2存储)
|
||||||
await db.changePasswordV2(username, newPassword);
|
await db.changePasswordV2(username, newPassword);
|
||||||
|
|
||||||
// 撤销除当前设备外的所有 Refresh Token
|
|
||||||
try {
|
|
||||||
const currentTokenId = authInfo.tokenId;
|
|
||||||
const devices = await getUserDevices(username);
|
|
||||||
|
|
||||||
// 撤销所有非当前设备的 token
|
|
||||||
for (const device of devices) {
|
|
||||||
if (device.tokenId !== currentTokenId) {
|
|
||||||
await revokeRefreshToken(username, device.tokenId);
|
|
||||||
console.log(`Revoked token ${device.tokenId} for ${username} after password change`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Password changed for ${username}, revoked ${devices.length - 1} other devices`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to revoke other devices after password change:', error);
|
|
||||||
// 不影响密码修改的成功,只记录错误
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('修改密码失败:', error);
|
console.error('修改密码失败:', error);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { OpenListClient } from '@/lib/openlist.client';
|
||||||
import {
|
import {
|
||||||
getCachedMetaInfo,
|
getCachedMetaInfo,
|
||||||
MetaInfo,
|
MetaInfo,
|
||||||
@@ -98,6 +99,17 @@ export async function GET(request: NextRequest) {
|
|||||||
// 处理返回数据,替换播放链接为代理链接
|
// 处理返回数据,替换播放链接为代理链接
|
||||||
const processedData = processPlayUrls(data, origin);
|
const processedData = processPlayUrls(data, origin);
|
||||||
|
|
||||||
|
// 输出处理后的第一个视频的播放信息(用于调试)
|
||||||
|
if (processedData.list && processedData.list.length > 0) {
|
||||||
|
const firstItem = processedData.list[0];
|
||||||
|
console.log('第一个视频处理后的播放信息:', {
|
||||||
|
vod_name: firstItem.vod_name,
|
||||||
|
vod_play_from: firstItem.vod_play_from,
|
||||||
|
vod_play_url_length: firstItem.vod_play_url?.length || 0,
|
||||||
|
vod_play_url_preview: firstItem.vod_play_url?.substring(0, 200) || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(processedData, {
|
return NextResponse.json(processedData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
@@ -259,15 +271,22 @@ async function handleOpenListProxy(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
|
const client = new OpenListClient(
|
||||||
|
openListConfig.URL,
|
||||||
|
openListConfig.Username,
|
||||||
|
openListConfig.Password
|
||||||
|
);
|
||||||
|
|
||||||
// 读取 metainfo (从数据库或缓存)
|
// 读取 metainfo (从数据库或缓存)
|
||||||
let metaInfo: MetaInfo | null = getCachedMetaInfo();
|
let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath);
|
||||||
|
|
||||||
if (!metaInfo) {
|
if (!metaInfo) {
|
||||||
try {
|
try {
|
||||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
if (metainfoJson) {
|
if (metainfoJson) {
|
||||||
metaInfo = JSON.parse(metainfoJson) as MetaInfo;
|
metaInfo = JSON.parse(metainfoJson) as MetaInfo;
|
||||||
setCachedMetaInfo(metaInfo);
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -288,7 +307,7 @@ async function handleOpenListProxy(request: NextRequest) {
|
|||||||
if (wd) {
|
if (wd) {
|
||||||
const results = Object.entries(metaInfo.folders)
|
const results = Object.entries(metaInfo.folders)
|
||||||
.filter(
|
.filter(
|
||||||
([_key, info]) =>
|
([key, info]) =>
|
||||||
info.folderName.toLowerCase().includes(wd.toLowerCase()) ||
|
info.folderName.toLowerCase().includes(wd.toLowerCase()) ||
|
||||||
info.title.toLowerCase().includes(wd.toLowerCase())
|
info.title.toLowerCase().includes(wd.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { getConfig, refineConfig } from '@/lib/config';
|
import { getConfig, refineConfig } from '@/lib/config';
|
||||||
import { db, getStorage } from '@/lib/db';
|
import { db, getStorage } from '@/lib/db';
|
||||||
import { EmailService } from '@/lib/email.service';
|
|
||||||
import { FavoriteUpdate,getBatchFavoriteUpdateEmailTemplate } from '@/lib/email.templates';
|
|
||||||
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
||||||
import { refreshLiveChannels } from '@/lib/live';
|
import { refreshLiveChannels } from '@/lib/live';
|
||||||
import { startOpenListRefresh } from '@/lib/openlist-refresh';
|
import { startOpenListRefresh } from '@/lib/openlist-refresh';
|
||||||
@@ -13,20 +11,8 @@ import { SearchResult } from '@/lib/types';
|
|||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(request: NextRequest) {
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { password: string } }
|
|
||||||
) {
|
|
||||||
console.log(request.url);
|
console.log(request.url);
|
||||||
|
|
||||||
const cronPassword = process.env.CRON_PASSWORD || 'mtvpls';
|
|
||||||
if (params.password !== cronPassword) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, message: 'Unauthorized' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Cron job triggered:', new Date().toISOString());
|
console.log('Cron job triggered:', new Date().toISOString());
|
||||||
|
|
||||||
@@ -137,19 +123,6 @@ async function refreshRecordAndFavorites() {
|
|||||||
if (process.env.USERNAME && !users.includes(process.env.USERNAME)) {
|
if (process.env.USERNAME && !users.includes(process.env.USERNAME)) {
|
||||||
users.push(process.env.USERNAME);
|
users.push(process.env.USERNAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 环境变量控制是否跳过特定源(默认为 false,即默认跳过)
|
|
||||||
const includeSpecialSources = process.env.CRON_INCLUDE_SPECIAL_SOURCES === 'true';
|
|
||||||
|
|
||||||
// 检查是否应该跳过该源
|
|
||||||
const shouldSkipSource = (source: string): boolean => {
|
|
||||||
if (includeSpecialSources) {
|
|
||||||
return false; // 如果开启了包含特殊源,则不跳过任何源
|
|
||||||
}
|
|
||||||
// 默认跳过 emby 开头、openlist、xiaoya 和 live 开头的源
|
|
||||||
return source.startsWith('emby') || source === 'openlist' || source === 'xiaoya' || source.startsWith('live');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 函数级缓存:key 为 `${source}+${id}`,值为 Promise<VideoDetail | null>
|
// 函数级缓存:key 为 `${source}+${id}`,值为 Promise<VideoDetail | null>
|
||||||
const detailCache = new Map<string, Promise<SearchResult | null>>();
|
const detailCache = new Map<string, Promise<SearchResult | null>>();
|
||||||
|
|
||||||
@@ -199,13 +172,6 @@ async function refreshRecordAndFavorites() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否应该跳过该源
|
|
||||||
if (shouldSkipSource(source)) {
|
|
||||||
console.log(`跳过播放记录 (源被过滤): ${key}`);
|
|
||||||
processedRecords++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const detail = await getDetail(source, id, record.title);
|
const detail = await getDetail(source, id, record.title);
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
console.warn(`跳过无法获取详情的播放记录: ${key}`);
|
console.warn(`跳过无法获取详情的播放记录: ${key}`);
|
||||||
@@ -252,7 +218,6 @@ async function refreshRecordAndFavorites() {
|
|||||||
const totalFavorites = Object.keys(favorites).length;
|
const totalFavorites = Object.keys(favorites).length;
|
||||||
let processedFavorites = 0;
|
let processedFavorites = 0;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const userUpdates: FavoriteUpdate[] = []; // 收集该用户的所有更新
|
|
||||||
|
|
||||||
for (const [key, fav] of Object.entries(favorites)) {
|
for (const [key, fav] of Object.entries(favorites)) {
|
||||||
try {
|
try {
|
||||||
@@ -262,13 +227,6 @@ async function refreshRecordAndFavorites() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否应该跳过该源
|
|
||||||
if (shouldSkipSource(source)) {
|
|
||||||
console.log(`跳过收藏 (源被过滤): ${key}`);
|
|
||||||
processedFavorites++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const favDetail = await getDetail(source, id, fav.title);
|
const favDetail = await getDetail(source, id, fav.title);
|
||||||
if (!favDetail) {
|
if (!favDetail) {
|
||||||
console.warn(`跳过无法获取详情的收藏: ${key}`);
|
console.warn(`跳过无法获取详情的收藏: ${key}`);
|
||||||
@@ -309,17 +267,6 @@ async function refreshRecordAndFavorites() {
|
|||||||
|
|
||||||
await storage.addNotification(user, notification);
|
await storage.addNotification(user, notification);
|
||||||
console.log(`已为用户 ${user} 创建收藏更新通知: ${fav.title}`);
|
console.log(`已为用户 ${user} 创建收藏更新通知: ${fav.title}`);
|
||||||
|
|
||||||
// 收集更新信息用于邮件
|
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
|
||||||
const playUrl = `${siteUrl}/play?source=${source}&id=${id}`;
|
|
||||||
userUpdates.push({
|
|
||||||
title: fav.title,
|
|
||||||
oldEpisodes: fav.total_episodes,
|
|
||||||
newEpisodes: favEpisodeCount,
|
|
||||||
url: playUrl,
|
|
||||||
cover: favDetail.poster || fav.cover,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
processedFavorites++;
|
processedFavorites++;
|
||||||
@@ -330,42 +277,6 @@ async function refreshRecordAndFavorites() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`收藏处理完成: ${processedFavorites}/${totalFavorites}`);
|
console.log(`收藏处理完成: ${processedFavorites}/${totalFavorites}`);
|
||||||
|
|
||||||
// 如果有更新,发送汇总邮件
|
|
||||||
if (userUpdates.length > 0) {
|
|
||||||
try {
|
|
||||||
const userEmail = storage.getUserEmail ? await storage.getUserEmail(user) : null;
|
|
||||||
const emailNotifications = storage.getEmailNotificationPreference
|
|
||||||
? await storage.getEmailNotificationPreference(user)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
if (userEmail && emailNotifications) {
|
|
||||||
const config = await getConfig();
|
|
||||||
const emailConfig = config?.EmailConfig;
|
|
||||||
|
|
||||||
if (emailConfig?.enabled) {
|
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
|
||||||
const siteName = config?.SiteConfig?.SiteName || 'MoonTVPlus';
|
|
||||||
|
|
||||||
await EmailService.send(emailConfig, {
|
|
||||||
to: userEmail,
|
|
||||||
subject: `📺 收藏更新汇总 - ${userUpdates.length} 部影片有更新`,
|
|
||||||
html: getBatchFavoriteUpdateEmailTemplate(
|
|
||||||
user,
|
|
||||||
userUpdates,
|
|
||||||
siteUrl,
|
|
||||||
siteName
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`邮件汇总已发送至: ${userEmail} (${userUpdates.length} 个更新)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (emailError) {
|
|
||||||
console.error(`发送邮件汇总失败 (${user}):`, emailError);
|
|
||||||
// 邮件发送失败不影响主流程
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`获取用户收藏失败 (${user}):`, err);
|
console.error(`获取用户收藏失败 (${user}):`, err);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { DanmakuFilterConfig } from '@/lib/types';
|
import { DanmakuFilterConfig } from '@/lib/types';
|
||||||
|
|
||||||
|
|||||||
@@ -45,13 +45,13 @@ export async function GET(request: NextRequest) {
|
|||||||
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||||
const { db } = await import('@/lib/db');
|
const { db } = await import('@/lib/db');
|
||||||
|
|
||||||
metaInfo = getCachedMetaInfo();
|
metaInfo = getCachedMetaInfo(rootPath);
|
||||||
|
|
||||||
if (!metaInfo) {
|
if (!metaInfo) {
|
||||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
if (metainfoJson) {
|
if (metainfoJson) {
|
||||||
metaInfo = JSON.parse(metainfoJson);
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
setCachedMetaInfo(metaInfo);
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,31 +81,14 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
let videoInfo = getCachedVideoInfo(folderPath);
|
let videoInfo = getCachedVideoInfo(folderPath);
|
||||||
|
|
||||||
// 获取所有分页的视频文件
|
const listResponse = await client.listDirectory(folderPath);
|
||||||
const allFiles: any[] = [];
|
|
||||||
let currentPage = 1;
|
|
||||||
const pageSize = 100;
|
|
||||||
let total = 0;
|
|
||||||
|
|
||||||
while (true) {
|
if (listResponse.code !== 200) {
|
||||||
const listResponse = await client.listDirectory(folderPath, currentPage, pageSize);
|
throw new Error('OpenList 列表获取失败');
|
||||||
|
|
||||||
if (listResponse.code !== 200) {
|
|
||||||
throw new Error('OpenList 列表获取失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
total = listResponse.data.total;
|
|
||||||
allFiles.push(...listResponse.data.content);
|
|
||||||
|
|
||||||
if (allFiles.length >= total) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPage++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob'];
|
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob'];
|
||||||
const videoFiles = allFiles.filter((item) => {
|
const videoFiles = listResponse.data.content.filter((item) => {
|
||||||
if (item.is_dir || item.name.startsWith('.') || item.name.endsWith('.json')) return false;
|
if (item.is_dir || item.name.startsWith('.') || item.name.endsWith('.json')) return false;
|
||||||
return videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext));
|
return videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext));
|
||||||
});
|
});
|
||||||
@@ -121,7 +104,6 @@ export async function GET(request: NextRequest) {
|
|||||||
season: parsed.season,
|
season: parsed.season,
|
||||||
title: parsed.title,
|
title: parsed.title,
|
||||||
parsed_from: 'filename',
|
parsed_from: 'filename',
|
||||||
isOVA: parsed.isOVA,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
setCachedVideoInfo(folderPath, videoInfo);
|
setCachedVideoInfo(folderPath, videoInfo);
|
||||||
@@ -132,26 +114,20 @@ export async function GET(request: NextRequest) {
|
|||||||
const parsed = parseVideoFileName(file.name);
|
const parsed = parseVideoFileName(file.name);
|
||||||
let episodeInfo;
|
let episodeInfo;
|
||||||
if (parsed.episode) {
|
if (parsed.episode) {
|
||||||
episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename', isOVA: parsed.isOVA };
|
episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename' };
|
||||||
} else {
|
} else {
|
||||||
episodeInfo = videoInfo!.episodes[file.name] || { episode: index + 1, season: undefined, title: undefined, parsed_from: 'filename' };
|
episodeInfo = videoInfo!.episodes[file.name] || { episode: index + 1, season: undefined, title: undefined, parsed_from: 'filename' };
|
||||||
}
|
}
|
||||||
let displayTitle = episodeInfo.title;
|
let displayTitle = episodeInfo.title;
|
||||||
if (!displayTitle && episodeInfo.episode) {
|
if (!displayTitle && episodeInfo.episode) {
|
||||||
displayTitle = episodeInfo.isOVA ? `OVA ${episodeInfo.episode}` : `第${episodeInfo.episode}集`;
|
displayTitle = `第${episodeInfo.episode}集`;
|
||||||
}
|
}
|
||||||
if (!displayTitle) {
|
if (!displayTitle) {
|
||||||
displayTitle = file.name;
|
displayTitle = file.name;
|
||||||
}
|
}
|
||||||
return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle, isOVA: episodeInfo.isOVA };
|
return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle };
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName));
|
||||||
// OVA 排在最后
|
|
||||||
if (a.isOVA && !b.isOVA) return 1;
|
|
||||||
if (!a.isOVA && b.isOVA) return -1;
|
|
||||||
// 都是 OVA 或都不是 OVA,按集数排序
|
|
||||||
return a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. 从 metainfo 中获取元数据
|
// 3. 从 metainfo 中获取元数据
|
||||||
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import * as cheerio from 'cheerio';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
import { fetchDoubanWithVerification } from '@/lib/douban-anti-crawler';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -27,10 +25,19 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 请求豆瓣短评页面(使用反爬验证)
|
// 请求豆瓣短评页面
|
||||||
const url = `https://movie.douban.com/subject/${doubanId}/comments?start=${start}&limit=${limit}&status=P&sort=new_score`;
|
const url = `https://movie.douban.com/subject/${doubanId}/comments?start=${start}&limit=${limit}&status=P&sort=new_score`;
|
||||||
|
|
||||||
const response = await fetchDoubanWithVerification(url);
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
Accept:
|
||||||
|
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||||
|
Referer: 'https://movie.douban.com/',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import * as cheerio from 'cheerio';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
import { fetchDoubanData } from '@/lib/douban';
|
import { fetchDoubanData } from '@/lib/douban';
|
||||||
import { fetchDoubanWithVerification } from '@/lib/douban-anti-crawler';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -28,10 +26,25 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 请求豆瓣电影页面(使用反爬验证)
|
// 请求豆瓣电影页面,使用和其他豆瓣API相同的请求头
|
||||||
const url = `https://movie.douban.com/subject/${doubanId}/`;
|
const url = `https://movie.douban.com/subject/${doubanId}/`;
|
||||||
|
|
||||||
const response = await fetchDoubanWithVerification(url);
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||||
|
Referer: 'https://movie.douban.com/',
|
||||||
|
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||||
|
Origin: 'https://movie.douban.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { fetchDoubanData } from '@/lib/douban';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
interface DoubanSearchResult {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
year: string;
|
|
||||||
type?: string;
|
|
||||||
sub_title?: string;
|
|
||||||
episode?: string;
|
|
||||||
img?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DoubanSearchResponse {
|
|
||||||
code: number;
|
|
||||||
data?: DoubanSearchResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/douban/search?q=<query>
|
|
||||||
* 搜索豆瓣影视作品
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const query = searchParams.get('q');
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const target = `https://movie.douban.com/j/subject_suggest?q=${encodeURIComponent(query)}`;
|
|
||||||
const data = await fetchDoubanData<DoubanSearchResult[]>(target);
|
|
||||||
|
|
||||||
const response: DoubanSearchResponse = {
|
|
||||||
code: 200,
|
|
||||||
data: data,
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '搜索豆瓣数据失败', details: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
import { EmbyClient } from '@/lib/emby.client';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emby CMS 代理接口(动态路由)
|
|
||||||
* 将 Emby 媒体库转换为 TVBox 兼容的 CMS API 格式
|
|
||||||
* 路径格式:/api/emby/cms-proxy/{token}?ac=videolist&...
|
|
||||||
*/
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { token: string } }
|
|
||||||
) {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const ac = searchParams.get('ac');
|
|
||||||
const wd = searchParams.get('wd'); // 搜索关键词
|
|
||||||
const ids = searchParams.get('ids'); // 视频ID
|
|
||||||
|
|
||||||
// 检查必要参数
|
|
||||||
if (ac !== 'videolist' && ac !== 'list' && ac !== 'detail') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ code: 400, msg: '不支持的操作' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证 TVBox Token
|
|
||||||
const requestToken = params.token;
|
|
||||||
const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
|
|
||||||
|
|
||||||
if (!subscribeToken || requestToken !== subscribeToken) {
|
|
||||||
return NextResponse.json({
|
|
||||||
code: 401,
|
|
||||||
msg: '无效的访问token',
|
|
||||||
page: 1,
|
|
||||||
pagecount: 0,
|
|
||||||
limit: 0,
|
|
||||||
total: 0,
|
|
||||||
list: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
// 验证 Emby 配置(多源)
|
|
||||||
if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) {
|
|
||||||
return NextResponse.json({
|
|
||||||
code: 0,
|
|
||||||
msg: 'Emby 未配置或未启用',
|
|
||||||
page: 1,
|
|
||||||
pagecount: 0,
|
|
||||||
limit: 0,
|
|
||||||
total: 0,
|
|
||||||
list: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取 embyKey 参数
|
|
||||||
const embyKey = searchParams.get('embyKey') || undefined;
|
|
||||||
|
|
||||||
// 使用 EmbyManager 获取客户端
|
|
||||||
const { embyManager } = await import('@/lib/emby-manager');
|
|
||||||
const client = await embyManager.getClient(embyKey);
|
|
||||||
|
|
||||||
// 路由处理
|
|
||||||
if (wd) {
|
|
||||||
// 搜索模式
|
|
||||||
if (ac === 'detail') {
|
|
||||||
return await handleDetailBySearch(client, wd, requestToken, embyKey, request);
|
|
||||||
}
|
|
||||||
return await handleSearch(client, wd);
|
|
||||||
} else if (ids || ac === 'detail') {
|
|
||||||
// 详情模式
|
|
||||||
if (!ids) {
|
|
||||||
return NextResponse.json({
|
|
||||||
code: 0,
|
|
||||||
msg: '缺少视频ID',
|
|
||||||
page: 1,
|
|
||||||
pagecount: 0,
|
|
||||||
limit: 0,
|
|
||||||
total: 0,
|
|
||||||
list: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return await handleDetail(client, ids, requestToken, embyKey, request);
|
|
||||||
} else {
|
|
||||||
// 列表模式
|
|
||||||
return await handleSearch(client, '');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Emby CMS Proxy] 错误:', error);
|
|
||||||
return NextResponse.json({
|
|
||||||
code: 500,
|
|
||||||
msg: (error as Error).message,
|
|
||||||
page: 1,
|
|
||||||
pagecount: 0,
|
|
||||||
limit: 0,
|
|
||||||
total: 0,
|
|
||||||
list: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理搜索请求
|
|
||||||
*/
|
|
||||||
async function handleSearch(client: EmbyClient, query: string) {
|
|
||||||
const result = await client.getItems({
|
|
||||||
searchTerm: query || undefined,
|
|
||||||
IncludeItemTypes: 'Movie,Series',
|
|
||||||
Recursive: true,
|
|
||||||
Fields: 'Overview,ProductionYear',
|
|
||||||
Limit: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
const list = result.Items.map((item) => ({
|
|
||||||
vod_id: item.Id,
|
|
||||||
vod_name: item.Name,
|
|
||||||
vod_pic: client.getImageUrl(item.Id, 'Primary'),
|
|
||||||
vod_remarks: item.Type === 'Movie' ? '电影' : '剧集',
|
|
||||||
vod_year: item.ProductionYear?.toString() || '',
|
|
||||||
vod_content: item.Overview || '',
|
|
||||||
type_name: item.Type === 'Movie' ? '电影' : '电视剧',
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
code: 1,
|
|
||||||
msg: '数据列表',
|
|
||||||
page: 1,
|
|
||||||
pagecount: 1,
|
|
||||||
limit: list.length,
|
|
||||||
total: list.length,
|
|
||||||
list,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理通过搜索关键词获取详情的请求
|
|
||||||
*/
|
|
||||||
async function handleDetailBySearch(
|
|
||||||
client: EmbyClient,
|
|
||||||
query: string,
|
|
||||||
token: string,
|
|
||||||
embyKey: string | undefined,
|
|
||||||
request: NextRequest
|
|
||||||
) {
|
|
||||||
const result = await client.getItems({
|
|
||||||
searchTerm: query,
|
|
||||||
IncludeItemTypes: 'Movie,Series',
|
|
||||||
Recursive: true,
|
|
||||||
Fields: 'Overview,ProductionYear',
|
|
||||||
Limit: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.Items.length === 0) {
|
|
||||||
return NextResponse.json({
|
|
||||||
code: 0,
|
|
||||||
msg: '未找到该视频',
|
|
||||||
page: 1,
|
|
||||||
pagecount: 0,
|
|
||||||
limit: 0,
|
|
||||||
total: 0,
|
|
||||||
list: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return await handleDetail(client, result.Items[0].Id, token, embyKey, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理详情请求
|
|
||||||
*/
|
|
||||||
async function handleDetail(
|
|
||||||
client: EmbyClient,
|
|
||||||
itemId: string,
|
|
||||||
token: string,
|
|
||||||
embyKey: string | undefined,
|
|
||||||
request: NextRequest
|
|
||||||
) {
|
|
||||||
const item = await client.getItem(itemId);
|
|
||||||
|
|
||||||
// 获取当前请求的 baseUrl
|
|
||||||
const host = request.headers.get('host') || request.headers.get('x-forwarded-host');
|
|
||||||
const proto = request.headers.get('x-forwarded-proto') ||
|
|
||||||
(host?.includes('localhost') || host?.includes('127.0.0.1') ? 'http' : 'https');
|
|
||||||
const baseUrl = process.env.SITE_BASE || `${proto}://${host}`;
|
|
||||||
|
|
||||||
const embyKeyParam = embyKey ? `&embyKey=${embyKey}` : '';
|
|
||||||
let vodPlayUrl = '';
|
|
||||||
|
|
||||||
if (item.Type === 'Movie') {
|
|
||||||
// 电影:单个播放链接(使用代理,添加 .mp4 扩展名)
|
|
||||||
const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${item.Id}${embyKeyParam}`;
|
|
||||||
vodPlayUrl = `正片$${proxyUrl}`;
|
|
||||||
} else if (item.Type === 'Series') {
|
|
||||||
// 剧集:获取所有集
|
|
||||||
const allEpisodes = await client.getEpisodes(itemId);
|
|
||||||
|
|
||||||
const episodes = allEpisodes
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (a.ParentIndexNumber !== b.ParentIndexNumber) {
|
|
||||||
return (a.ParentIndexNumber || 0) - (b.ParentIndexNumber || 0);
|
|
||||||
}
|
|
||||||
return (a.IndexNumber || 0) - (b.IndexNumber || 0);
|
|
||||||
})
|
|
||||||
.map((ep) => {
|
|
||||||
const title = `第${ep.IndexNumber}集`;
|
|
||||||
const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${ep.Id}${embyKeyParam}`;
|
|
||||||
return `${title}$${proxyUrl}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
vodPlayUrl = episodes.join('#');
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
code: 1,
|
|
||||||
msg: '数据列表',
|
|
||||||
page: 1,
|
|
||||||
pagecount: 1,
|
|
||||||
limit: 1,
|
|
||||||
total: 1,
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
vod_id: item.Id,
|
|
||||||
vod_name: item.Name,
|
|
||||||
vod_pic: client.getImageUrl(item.Id, 'Primary'),
|
|
||||||
vod_remarks: item.Type === 'Movie' ? '电影' : '剧集',
|
|
||||||
vod_year: item.ProductionYear?.toString() || '',
|
|
||||||
vod_content: item.Overview || '',
|
|
||||||
type_name: item.Type === 'Movie' ? '电影' : '电视剧',
|
|
||||||
vod_play_url: vodPlayUrl,
|
|
||||||
vod_play_from: 'Emby',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { embyManager } from '@/lib/emby-manager';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const itemId = searchParams.get('id');
|
|
||||||
const embyKey = searchParams.get('embyKey') || undefined;
|
|
||||||
|
|
||||||
if (!itemId) {
|
|
||||||
return NextResponse.json({ error: '缺少媒体ID' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 获取Emby客户端
|
|
||||||
const client = await embyManager.getClient(embyKey);
|
|
||||||
|
|
||||||
// 获取媒体详情
|
|
||||||
const item = await client.getItem(itemId);
|
|
||||||
|
|
||||||
let episodes: any[] = [];
|
|
||||||
|
|
||||||
if (item.Type === 'Series') {
|
|
||||||
// 获取所有剧集
|
|
||||||
const allEpisodes = await client.getEpisodes(itemId);
|
|
||||||
|
|
||||||
episodes = await Promise.all(
|
|
||||||
allEpisodes
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (a.ParentIndexNumber !== b.ParentIndexNumber) {
|
|
||||||
return (a.ParentIndexNumber || 0) - (b.ParentIndexNumber || 0);
|
|
||||||
}
|
|
||||||
return (a.IndexNumber || 0) - (b.IndexNumber || 0);
|
|
||||||
})
|
|
||||||
.map(async (ep) => ({
|
|
||||||
id: ep.Id,
|
|
||||||
title: ep.Name,
|
|
||||||
episode: ep.IndexNumber || 0,
|
|
||||||
season: ep.ParentIndexNumber || 1,
|
|
||||||
overview: ep.Overview || '',
|
|
||||||
playUrl: await client.getStreamUrl(ep.Id),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
item: {
|
|
||||||
id: item.Id,
|
|
||||||
title: item.Name,
|
|
||||||
type: item.Type === 'Movie' ? 'movie' : 'tv',
|
|
||||||
overview: item.Overview || '',
|
|
||||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
|
||||||
year: item.ProductionYear?.toString() || '',
|
|
||||||
rating: item.CommunityRating || 0,
|
|
||||||
playUrl: item.Type === 'Movie' ? await client.getStreamUrl(item.Id) : undefined,
|
|
||||||
},
|
|
||||||
episodes: item.Type === 'Series' ? episodes : [],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取 Emby 详情失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '获取 Emby 详情失败: ' + (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getCachedEmbyList, setCachedEmbyList } from '@/lib/emby-cache';
|
|
||||||
import { embyManager } from '@/lib/emby-manager';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const page = parseInt(searchParams.get('page') || '1');
|
|
||||||
const pageSize = parseInt(searchParams.get('pageSize') || '20');
|
|
||||||
const parentId = searchParams.get('parentId') || undefined;
|
|
||||||
const embyKey = searchParams.get('embyKey') || undefined;
|
|
||||||
const sortBy = searchParams.get('sortBy') || 'SortName';
|
|
||||||
const sortOrder = searchParams.get('sortOrder') || 'Ascending';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 判断是否是默认排序(只有默认排序才使用缓存)
|
|
||||||
const isDefaultSort = sortBy === 'SortName' && sortOrder === 'Ascending';
|
|
||||||
|
|
||||||
// 只有默认排序才检查缓存
|
|
||||||
if (isDefaultSort) {
|
|
||||||
const cached = getCachedEmbyList(page, pageSize, parentId, embyKey);
|
|
||||||
if (cached) {
|
|
||||||
return NextResponse.json(cached);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取Emby客户端
|
|
||||||
const client = await embyManager.getClient(embyKey);
|
|
||||||
|
|
||||||
// 获取媒体列表
|
|
||||||
const result = await client.getItems({
|
|
||||||
ParentId: parentId,
|
|
||||||
IncludeItemTypes: 'Movie,Series',
|
|
||||||
Recursive: true,
|
|
||||||
Fields: 'Overview,ProductionYear',
|
|
||||||
SortBy: sortBy,
|
|
||||||
SortOrder: sortOrder,
|
|
||||||
StartIndex: (page - 1) * pageSize,
|
|
||||||
Limit: pageSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
const list = result.Items.map((item) => ({
|
|
||||||
id: item.Id,
|
|
||||||
title: item.Name,
|
|
||||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
|
||||||
year: item.ProductionYear?.toString() || '',
|
|
||||||
rating: item.CommunityRating || 0,
|
|
||||||
mediaType: item.Type === 'Movie' ? 'movie' : 'tv',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(result.TotalRecordCount / pageSize);
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
success: true,
|
|
||||||
list,
|
|
||||||
totalPages,
|
|
||||||
currentPage: page,
|
|
||||||
total: result.TotalRecordCount,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 只有默认排序才缓存结果
|
|
||||||
if (isDefaultSort) {
|
|
||||||
setCachedEmbyList(page, pageSize, response, parentId, embyKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取 Emby 列表失败:', error);
|
|
||||||
return NextResponse.json({
|
|
||||||
error: '获取 Emby 列表失败: ' + (error as Error).message,
|
|
||||||
list: [],
|
|
||||||
totalPages: 0,
|
|
||||||
currentPage: page,
|
|
||||||
total: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 Emby 客户端
|
|
||||||
*/
|
|
||||||
async function getEmbyClient(embyKey?: string) {
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) {
|
|
||||||
throw new Error('Emby 未配置或未启用');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { embyManager } = await import('@/lib/emby-manager');
|
|
||||||
return await embyManager.getClient(embyKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/emby/play/{token}/{filename}?itemId=xxx
|
|
||||||
* 代理 Emby 视频播放链接,URL 中包含文件扩展名(如 video.mp4)
|
|
||||||
* 实际返回的内容根据 Emby 服务器的 Content-Type 决定
|
|
||||||
*
|
|
||||||
* 权限验证:TVBox Token(路径参数) 或 用户登录(满足其一即可)
|
|
||||||
*/
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { token: string; filename: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
|
|
||||||
// 双重验证:TVBox Token 或 用户登录
|
|
||||||
const requestToken = params.token;
|
|
||||||
const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
|
|
||||||
// 验证 TVBox Token
|
|
||||||
const hasValidToken = subscribeToken && requestToken === subscribeToken;
|
|
||||||
// 验证用户登录
|
|
||||||
const hasValidAuth = authInfo && authInfo.username;
|
|
||||||
|
|
||||||
// 两者至少满足其一
|
|
||||||
if (!hasValidToken && !hasValidAuth) {
|
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemId = searchParams.get('itemId');
|
|
||||||
const embyKey = searchParams.get('embyKey') || undefined;
|
|
||||||
|
|
||||||
if (!itemId) {
|
|
||||||
return NextResponse.json({ error: '缺少 itemId 参数' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取 Emby 客户端
|
|
||||||
let client = await getEmbyClient(embyKey);
|
|
||||||
|
|
||||||
// 构建 Emby 原始播放链接(强制获取直接URL,避免代理循环)
|
|
||||||
let embyStreamUrl = await client.getStreamUrl(itemId, true, true);
|
|
||||||
|
|
||||||
// 构建请求头,转发 Range 请求
|
|
||||||
const requestHeaders: HeadersInit = {};
|
|
||||||
const rangeHeader = request.headers.get('range');
|
|
||||||
if (rangeHeader) {
|
|
||||||
requestHeaders['Range'] = rangeHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 流式代理视频内容
|
|
||||||
let videoResponse = await fetch(embyStreamUrl, {
|
|
||||||
headers: requestHeaders,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 如果返回 401,尝试重新认证并重试
|
|
||||||
if (videoResponse.status === 401) {
|
|
||||||
console.log('[Emby Play] 收到 401 错误,尝试重新认证');
|
|
||||||
const { embyManager } = await import('@/lib/emby-manager');
|
|
||||||
embyManager.clearCache();
|
|
||||||
client = await getEmbyClient(embyKey);
|
|
||||||
embyStreamUrl = await client.getStreamUrl(itemId, true, true);
|
|
||||||
videoResponse = await fetch(embyStreamUrl, {
|
|
||||||
headers: requestHeaders,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!videoResponse.ok) {
|
|
||||||
console.error('[Emby Play] 获取视频流失败:', {
|
|
||||||
itemId,
|
|
||||||
status: videoResponse.status,
|
|
||||||
statusText: videoResponse.statusText,
|
|
||||||
});
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '获取视频流失败' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取 Content-Type
|
|
||||||
const contentType = videoResponse.headers.get('content-type') || 'video/mp4';
|
|
||||||
|
|
||||||
// 构建响应头
|
|
||||||
const headers = new Headers();
|
|
||||||
headers.set('Content-Type', contentType);
|
|
||||||
|
|
||||||
// 复制重要的响应头
|
|
||||||
const contentLength = videoResponse.headers.get('content-length');
|
|
||||||
if (contentLength) {
|
|
||||||
headers.set('Content-Length', contentLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
const acceptRanges = videoResponse.headers.get('accept-ranges');
|
|
||||||
if (acceptRanges) {
|
|
||||||
headers.set('Accept-Ranges', acceptRanges);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentRange = videoResponse.headers.get('content-range');
|
|
||||||
if (contentRange) {
|
|
||||||
headers.set('Content-Range', contentRange);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 URL 中的文件名
|
|
||||||
headers.set('Content-Disposition', `inline; filename="${params.filename}"`);
|
|
||||||
|
|
||||||
// 流式返回视频内容,不等待下载完成
|
|
||||||
return new NextResponse(videoResponse.body, {
|
|
||||||
status: videoResponse.status,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Emby Play] 错误:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '播放失败', details: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { embyManager } from '@/lib/emby-manager';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
export const dynamic = 'force-dynamic'; // 禁用缓存
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有启用的Emby源列表
|
|
||||||
*/
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const sources = await embyManager.getEnabledSources();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
sources: sources.map(s => ({
|
|
||||||
key: s.key,
|
|
||||||
name: s.name,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Emby Sources] 获取Emby源列表失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '获取Emby源列表失败', sources: [] },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getCachedEmbyViews, setCachedEmbyViews } from '@/lib/emby-cache';
|
|
||||||
import { embyManager } from '@/lib/emby-manager';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const embyKey = searchParams.get('embyKey') || undefined;
|
|
||||||
|
|
||||||
// 检查缓存(按embyKey缓存)
|
|
||||||
const cacheKey = embyKey || 'default';
|
|
||||||
const cached = getCachedEmbyViews(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return NextResponse.json(cached);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取Emby客户端
|
|
||||||
const client = await embyManager.getClient(embyKey);
|
|
||||||
|
|
||||||
// 获取媒体库列表
|
|
||||||
const views = await client.getUserViews();
|
|
||||||
|
|
||||||
// 过滤出电影和电视剧媒体库
|
|
||||||
const filteredViews = views.filter(
|
|
||||||
(view) => view.CollectionType === 'movies' || view.CollectionType === 'tvshows'
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
success: true,
|
|
||||||
views: filteredViews.map((view) => ({
|
|
||||||
id: view.Id,
|
|
||||||
name: view.Name,
|
|
||||||
type: view.CollectionType,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 缓存结果
|
|
||||||
setCachedEmbyViews(cacheKey, response);
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取 Emby 媒体库列表失败:', error);
|
|
||||||
return NextResponse.json({
|
|
||||||
error: '获取 Emby 媒体库列表失败: ' + (error as Error).message,
|
|
||||||
views: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
159
src/app/api/favorites/check-updates/route.ts
Normal file
159
src/app/api/favorites/check-updates/route.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getStorage } from '@/lib/db';
|
||||||
|
import { getAvailableApiSites } from '@/lib/config';
|
||||||
|
import { getDetailFromApi } from '@/lib/downstream';
|
||||||
|
import { Notification } from '@/lib/types';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storage = getStorage();
|
||||||
|
const username = authInfo.username;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
console.log(`用户 ${username} 请求检查收藏更新`);
|
||||||
|
console.log(`当前时间: ${new Date(now).toLocaleString('zh-CN')}`);
|
||||||
|
console.log(`开始检查收藏更新...`);
|
||||||
|
|
||||||
|
// 获取所有收藏
|
||||||
|
const favorites = await storage.getAllFavorites(username);
|
||||||
|
const favoriteKeys = Object.keys(favorites);
|
||||||
|
|
||||||
|
if (favoriteKeys.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: '没有收藏',
|
||||||
|
updates: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取可用的 API 站点
|
||||||
|
const apiSites = await getAvailableApiSites(username);
|
||||||
|
|
||||||
|
// 检查每个收藏的更新
|
||||||
|
const updates: Array<{
|
||||||
|
source: string;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
old_episodes: number;
|
||||||
|
new_episodes: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// 限制并发请求数量,避免过载
|
||||||
|
const BATCH_SIZE = 5;
|
||||||
|
for (let i = 0; i < favoriteKeys.length; i += BATCH_SIZE) {
|
||||||
|
const batch = favoriteKeys.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(async (key) => {
|
||||||
|
try {
|
||||||
|
const favorite = favorites[key];
|
||||||
|
|
||||||
|
// 跳过 live 类型的收藏
|
||||||
|
if (favorite.origin === 'live') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过已完结的收藏
|
||||||
|
if (favorite.is_completed) {
|
||||||
|
console.log(`跳过已完结的收藏: ${favorite.title}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 source 和 id
|
||||||
|
const [source, id] = key.split('+');
|
||||||
|
if (!source || !id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找对应的 API 站点
|
||||||
|
const apiSite = apiSites.find((site) => site.key === source);
|
||||||
|
if (!apiSite) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最新详情
|
||||||
|
const detail = await getDetailFromApi(apiSite, id);
|
||||||
|
|
||||||
|
// 比较集数
|
||||||
|
const oldEpisodes = favorite.total_episodes;
|
||||||
|
const newEpisodes = detail.episodes.length;
|
||||||
|
|
||||||
|
console.log(`检查收藏: ${favorite.title} (${source}+${id})`);
|
||||||
|
console.log(` 旧集数: ${oldEpisodes}, 新集数: ${newEpisodes}`);
|
||||||
|
console.log(` 是否完结: ${favorite.is_completed}, 备注: ${favorite.vod_remarks}`);
|
||||||
|
|
||||||
|
if (newEpisodes > oldEpisodes) {
|
||||||
|
updates.push({
|
||||||
|
source,
|
||||||
|
id,
|
||||||
|
title: favorite.title,
|
||||||
|
old_episodes: oldEpisodes,
|
||||||
|
new_episodes: newEpisodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新收藏的集数和完结状态
|
||||||
|
await storage.setFavorite(username, key, {
|
||||||
|
...favorite,
|
||||||
|
total_episodes: newEpisodes,
|
||||||
|
is_completed: detail.vod_remarks
|
||||||
|
? ['全', '完结', '大结局', 'end', '完'].some((keyword) =>
|
||||||
|
detail.vod_remarks!.toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
: false,
|
||||||
|
vod_remarks: detail.vod_remarks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`检查收藏更新失败 (${key}):`, error);
|
||||||
|
// 继续处理其他收藏
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`检查完成,发现 ${updates.length} 个更新`);
|
||||||
|
|
||||||
|
// 如果有更新,创建通知
|
||||||
|
if (updates.length > 0) {
|
||||||
|
for (const update of updates) {
|
||||||
|
const notification: Notification = {
|
||||||
|
id: `fav_update_${update.source}_${update.id}_${now}`,
|
||||||
|
type: 'favorite_update',
|
||||||
|
title: '收藏更新',
|
||||||
|
message: `《${update.title}》有新集数更新!从 ${update.old_episodes} 集更新到 ${update.new_episodes} 集`,
|
||||||
|
timestamp: now,
|
||||||
|
read: false,
|
||||||
|
metadata: {
|
||||||
|
source: update.source,
|
||||||
|
id: update.id,
|
||||||
|
title: update.title,
|
||||||
|
old_episodes: update.old_episodes,
|
||||||
|
new_episodes: update.new_episodes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await storage.addNotification(username, notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: updates.length > 0 ? `发现 ${updates.length} 个更新` : '没有更新',
|
||||||
|
updates,
|
||||||
|
checked: favoriteKeys.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查收藏更新失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { Favorite } from '@/lib/types';
|
import { Favorite } from '@/lib/types';
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export async function GET(request: Request) {
|
|||||||
'User-Agent':
|
'User-Agent':
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||||
Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8',
|
Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8',
|
||||||
Referer: 'https://movie.douban.com/',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { parseAuthInfo } from '@/lib/auth';
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import {
|
|
||||||
generateRefreshToken,
|
|
||||||
generateTokenId,
|
|
||||||
storeRefreshToken,
|
|
||||||
TOKEN_CONFIG,
|
|
||||||
} from '@/lib/refresh-token';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -22,21 +15,6 @@ const STORAGE_TYPE =
|
|||||||
| 'kvrocks'
|
| 'kvrocks'
|
||||||
| undefined) || 'localstorage';
|
| undefined) || 'localstorage';
|
||||||
|
|
||||||
function buildLoginResponse(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成签名
|
// 生成签名
|
||||||
async function generateSignature(
|
async function generateSignature(
|
||||||
data: string,
|
data: string,
|
||||||
@@ -64,15 +42,13 @@ async function generateSignature(
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成认证Cookie(带签名和 Refresh Token)
|
// 生成认证Cookie(带签名)
|
||||||
async function generateAuthCookie(
|
async function generateAuthCookie(
|
||||||
username?: string,
|
username?: string,
|
||||||
password?: string,
|
password?: string,
|
||||||
role?: 'owner' | 'admin' | 'user',
|
role?: 'owner' | 'admin' | 'user',
|
||||||
includePassword = false,
|
includePassword = false
|
||||||
deviceInfo?: string
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const now = Date.now();
|
|
||||||
const authData: any = { role: role || 'user' };
|
const authData: any = { role: role || 'user' };
|
||||||
|
|
||||||
// 只在需要时包含 password
|
// 只在需要时包含 password
|
||||||
@@ -82,40 +58,10 @@ async function generateAuthCookie(
|
|||||||
|
|
||||||
if (username && process.env.PASSWORD) {
|
if (username && process.env.PASSWORD) {
|
||||||
authData.username = username;
|
authData.username = username;
|
||||||
authData.timestamp = now; // Access Token 时间戳
|
// 使用密码作为密钥对用户名进行签名
|
||||||
|
const signature = await generateSignature(username, process.env.PASSWORD);
|
||||||
// 生成 Refresh Token(仅数据库模式)
|
|
||||||
if (!includePassword && STORAGE_TYPE !== 'localstorage') {
|
|
||||||
const tokenId = generateTokenId();
|
|
||||||
const refreshToken = generateRefreshToken();
|
|
||||||
const refreshExpires = now + TOKEN_CONFIG.REFRESH_TOKEN_AGE;
|
|
||||||
|
|
||||||
authData.tokenId = tokenId;
|
|
||||||
authData.refreshToken = refreshToken;
|
|
||||||
authData.refreshExpires = refreshExpires;
|
|
||||||
|
|
||||||
// 存储到 Redis Hash
|
|
||||||
try {
|
|
||||||
await storeRefreshToken(username, tokenId, {
|
|
||||||
token: refreshToken,
|
|
||||||
deviceInfo: deviceInfo || 'Unknown Device',
|
|
||||||
createdAt: now,
|
|
||||||
expiresAt: refreshExpires,
|
|
||||||
lastUsed: now,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to store refresh token:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 签名所有关键字段(username, role, timestamp)防止篡改
|
|
||||||
const dataToSign = JSON.stringify({
|
|
||||||
username: authData.username,
|
|
||||||
role: authData.role,
|
|
||||||
timestamp: authData.timestamp
|
|
||||||
});
|
|
||||||
const signature = await generateSignature(dataToSign, process.env.PASSWORD);
|
|
||||||
authData.signature = signature;
|
authData.signature = signature;
|
||||||
|
authData.timestamp = Date.now(); // 添加时间戳防重放攻击
|
||||||
}
|
}
|
||||||
|
|
||||||
return encodeURIComponent(JSON.stringify(authData));
|
return encodeURIComponent(JSON.stringify(authData));
|
||||||
@@ -143,28 +89,6 @@ async function verifyTurnstileToken(token: string, secretKey: string): Promise<b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取设备信息
|
|
||||||
function getDeviceInfo(request: NextRequest): string {
|
|
||||||
const userAgent = request.headers.get('user-agent') || 'Unknown';
|
|
||||||
|
|
||||||
// 简单解析 User-Agent
|
|
||||||
let browser = 'Unknown Browser';
|
|
||||||
let os = 'Unknown OS';
|
|
||||||
|
|
||||||
if (userAgent.includes('Chrome')) browser = 'Chrome';
|
|
||||||
else if (userAgent.includes('Firefox')) browser = 'Firefox';
|
|
||||||
else if (userAgent.includes('Safari')) browser = 'Safari';
|
|
||||||
else if (userAgent.includes('Edge')) browser = 'Edge';
|
|
||||||
|
|
||||||
if (userAgent.includes('Windows')) os = 'Windows';
|
|
||||||
else if (userAgent.includes('Mac')) os = 'macOS';
|
|
||||||
else if (userAgent.includes('Linux')) os = 'Linux';
|
|
||||||
else if (userAgent.includes('Android')) os = 'Android';
|
|
||||||
else if (userAgent.includes('iOS')) os = 'iOS';
|
|
||||||
|
|
||||||
return `${browser} on ${os}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// 获取站点配置
|
// 获取站点配置
|
||||||
@@ -177,14 +101,15 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// 未配置 PASSWORD 时直接放行
|
// 未配置 PASSWORD 时直接放行
|
||||||
if (!envPassword) {
|
if (!envPassword) {
|
||||||
const response = buildLoginResponse();
|
const response = NextResponse.json({ ok: true });
|
||||||
|
|
||||||
// 清除可能存在的认证cookie
|
// 清除可能存在的认证cookie
|
||||||
response.cookies.set('auth', '', {
|
response.cookies.set('auth', '', {
|
||||||
path: '/',
|
path: '/',
|
||||||
expires: new Date(0),
|
expires: new Date(0),
|
||||||
sameSite: 'lax',
|
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||||
httpOnly: false,
|
httpOnly: false, // PWA 需要客户端可访问
|
||||||
|
secure: false, // 根据协议自动设置
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -203,25 +128,22 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证成功,设置认证cookie
|
// 验证成功,设置认证cookie
|
||||||
const username = process.env.USERNAME || 'default';
|
const response = NextResponse.json({ ok: true });
|
||||||
const deviceInfo = getDeviceInfo(req);
|
|
||||||
const cookieValue = await generateAuthCookie(
|
const cookieValue = await generateAuthCookie(
|
||||||
username,
|
undefined,
|
||||||
password,
|
password,
|
||||||
'owner',
|
'user',
|
||||||
true,
|
true
|
||||||
deviceInfo
|
|
||||||
); // localstorage 模式包含 password
|
); // localstorage 模式包含 password
|
||||||
const response = buildLoginResponse(cookieValue);
|
|
||||||
const expires = new Date();
|
const expires = new Date();
|
||||||
expires.setDate(expires.getDate() + 60); // 60天过期(Refresh Token 有效期)
|
expires.setDate(expires.getDate() + 7); // 7天过期
|
||||||
|
|
||||||
response.cookies.set('auth', cookieValue, {
|
response.cookies.set('auth', cookieValue, {
|
||||||
path: '/',
|
path: '/',
|
||||||
expires,
|
expires,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||||
httpOnly: false, // 允许客户端访问
|
httpOnly: false, // PWA 需要客户端可访问
|
||||||
secure: false,
|
secure: false, // 根据协议自动设置
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -270,24 +192,22 @@ export async function POST(req: NextRequest) {
|
|||||||
password === process.env.PASSWORD
|
password === process.env.PASSWORD
|
||||||
) {
|
) {
|
||||||
// 验证成功,设置认证cookie
|
// 验证成功,设置认证cookie
|
||||||
const deviceInfo = getDeviceInfo(req);
|
const response = NextResponse.json({ ok: true });
|
||||||
const cookieValue = await generateAuthCookie(
|
const cookieValue = await generateAuthCookie(
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
'owner',
|
'owner',
|
||||||
false,
|
false
|
||||||
deviceInfo
|
|
||||||
); // 数据库模式不包含 password
|
); // 数据库模式不包含 password
|
||||||
const response = buildLoginResponse(cookieValue);
|
|
||||||
const expires = new Date();
|
const expires = new Date();
|
||||||
expires.setDate(expires.getDate() + 60); // 60天过期(Refresh Token 有效期)
|
expires.setDate(expires.getDate() + 7); // 7天过期
|
||||||
|
|
||||||
response.cookies.set('auth', cookieValue, {
|
response.cookies.set('auth', cookieValue, {
|
||||||
path: '/',
|
path: '/',
|
||||||
expires,
|
expires,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||||
httpOnly: false, // 允许客户端访问
|
httpOnly: false, // PWA 需要客户端可访问
|
||||||
secure: false,
|
secure: false, // 根据协议自动设置
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -295,12 +215,15 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 });
|
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用新版本的用户验证
|
const config = await getConfig();
|
||||||
|
const user = config.UserConfig.Users.find((u) => u.username === username);
|
||||||
|
|
||||||
|
// 优先使用新版本的用户验证
|
||||||
let pass = false;
|
let pass = false;
|
||||||
let userRole: 'owner' | 'admin' | 'user' = 'user';
|
let userRole: 'owner' | 'admin' | 'user' = 'user';
|
||||||
let isBanned = false;
|
let isBanned = false;
|
||||||
|
|
||||||
// 验证用户
|
// 尝试使用新版本验证
|
||||||
const userInfoV2 = await db.getUserInfoV2(username);
|
const userInfoV2 = await db.getUserInfoV2(username);
|
||||||
|
|
||||||
if (userInfoV2) {
|
if (userInfoV2) {
|
||||||
@@ -323,24 +246,23 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证成功,设置认证cookie
|
// 验证成功,设置认证cookie
|
||||||
const deviceInfo = getDeviceInfo(req);
|
const response = NextResponse.json({ ok: true });
|
||||||
const cookieValue = await generateAuthCookie(
|
const cookieValue = await generateAuthCookie(
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
userRole,
|
userRole,
|
||||||
false,
|
false
|
||||||
deviceInfo
|
|
||||||
); // 数据库模式不包含 password
|
); // 数据库模式不包含 password
|
||||||
const response = buildLoginResponse(cookieValue);
|
|
||||||
const expires = new Date();
|
const expires = new Date();
|
||||||
expires.setDate(expires.getDate() + 60); // 60天过期(Refresh Token 有效期)
|
expires.setDate(expires.getDate() + 7); // 7天过期
|
||||||
|
|
||||||
response.cookies.set('auth', cookieValue, {
|
response.cookies.set('auth', cookieValue, {
|
||||||
path: '/',
|
path: '/',
|
||||||
expires,
|
expires,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||||
httpOnly: false, // 允许客户端访问
|
httpOnly: false, // PWA 需要客户端可访问
|
||||||
});
|
secure: false, // 根据协议自动设置
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`Cookie已设置`);
|
console.log(`Cookie已设置`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { revokeRefreshToken } from '@/lib/refresh-token';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST() {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
|
|
||||||
// 撤销当前设备的 Refresh Token
|
|
||||||
if (authInfo && authInfo.username && authInfo.tokenId) {
|
|
||||||
try {
|
|
||||||
await revokeRefreshToken(authInfo.username, authInfo.tokenId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to revoke refresh token:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = NextResponse.json({ ok: true });
|
const response = NextResponse.json({ ok: true });
|
||||||
|
|
||||||
// 清除认证cookie
|
// 清除认证cookie
|
||||||
response.cookies.set('auth', '', {
|
response.cookies.set('auth', '', {
|
||||||
path: '/',
|
path: '/',
|
||||||
expires: new Date(0),
|
expires: new Date(0),
|
||||||
sameSite: 'lax',
|
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||||
httpOnly: false,
|
httpOnly: false, // PWA 需要客户端可访问
|
||||||
secure: false,
|
secure: false, // 根据协议自动设置
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getStorage } from '@/lib/db';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
// GET: 获取单个求片详情
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storage = getStorage();
|
|
||||||
const movieRequest = await storage.getMovieRequest(params.id);
|
|
||||||
|
|
||||||
if (!movieRequest) {
|
|
||||||
return NextResponse.json({ error: '求片不存在' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ request: movieRequest });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取求片详情失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH: 更新求片状态(标记已上架)
|
|
||||||
export async function PATCH(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storage = getStorage();
|
|
||||||
|
|
||||||
// 检查权限:只有管理员和站长可以操作
|
|
||||||
if (storage.getUserInfoV2) {
|
|
||||||
const userInfo = await storage.getUserInfoV2(authInfo.username);
|
|
||||||
if (userInfo?.role !== 'admin' && userInfo?.role !== 'owner') {
|
|
||||||
return NextResponse.json({ error: '无权限操作' }, { status: 403 });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果不支持 getUserInfoV2,只允许站长操作
|
|
||||||
if (authInfo.username !== process.env.USERNAME) {
|
|
||||||
return NextResponse.json({ error: '无权限操作' }, { status: 403 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { status, fulfilledSource, fulfilledId } = body;
|
|
||||||
|
|
||||||
const movieRequest = await storage.getMovieRequest(params.id);
|
|
||||||
if (!movieRequest) {
|
|
||||||
return NextResponse.json({ error: '求片不存在' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新状态
|
|
||||||
const updates: any = {
|
|
||||||
status,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (status === 'fulfilled') {
|
|
||||||
updates.fulfilledAt = Date.now();
|
|
||||||
updates.fulfilledSource = fulfilledSource;
|
|
||||||
updates.fulfilledId = fulfilledId;
|
|
||||||
|
|
||||||
// 给所有求片用户发送通知
|
|
||||||
for (const username of movieRequest.requestedBy) {
|
|
||||||
await storage.addNotification(username, {
|
|
||||||
id: `req_fulfilled_${params.id}_${Date.now()}`,
|
|
||||||
type: 'request_fulfilled',
|
|
||||||
title: '求片已上架',
|
|
||||||
message: `您求的《${movieRequest.title}》已上架`,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
read: false,
|
|
||||||
metadata: {
|
|
||||||
requestId: params.id,
|
|
||||||
source: fulfilledSource,
|
|
||||||
id: fulfilledId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await storage.updateMovieRequest(params.id, updates);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
message: '更新成功',
|
|
||||||
request: { ...movieRequest, ...updates },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('更新求片失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE: 删除求片
|
|
||||||
export async function DELETE(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storage = getStorage();
|
|
||||||
|
|
||||||
// 检查权限:只有管理员和站长可以删除
|
|
||||||
if (storage.getUserInfoV2) {
|
|
||||||
const userInfo = await storage.getUserInfoV2(authInfo.username);
|
|
||||||
if (userInfo?.role !== 'admin' && userInfo?.role !== 'owner') {
|
|
||||||
return NextResponse.json({ error: '无权限操作' }, { status: 403 });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果不支持 getUserInfoV2,只允许站长操作
|
|
||||||
if (authInfo.username !== process.env.USERNAME) {
|
|
||||||
return NextResponse.json({ error: '无权限操作' }, { status: 403 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const movieRequest = await storage.getMovieRequest(params.id);
|
|
||||||
if (!movieRequest) {
|
|
||||||
return NextResponse.json({ error: '求片不存在' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除求片
|
|
||||||
await storage.deleteMovieRequest(params.id);
|
|
||||||
|
|
||||||
// 从所有用户的求片列表中移除
|
|
||||||
for (const username of movieRequest.requestedBy) {
|
|
||||||
await storage.removeUserMovieRequest(username, params.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ message: '删除成功' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除求片失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import { nanoid } from 'nanoid';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getStorage } from '@/lib/db';
|
|
||||||
import { MovieRequest } from '@/lib/types';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
// GET: 获取求片列表
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const status = searchParams.get('status') as 'pending' | 'fulfilled' | null;
|
|
||||||
const detail = searchParams.get('detail') !== 'false';
|
|
||||||
const myRequests = searchParams.get('my') === 'true';
|
|
||||||
|
|
||||||
const storage = getStorage();
|
|
||||||
|
|
||||||
if (myRequests) {
|
|
||||||
// 获取用户自己的求片
|
|
||||||
const requestIds = await storage.getUserMovieRequests(authInfo.username);
|
|
||||||
const requests = await Promise.all(
|
|
||||||
requestIds.map(id => storage.getMovieRequest(id))
|
|
||||||
);
|
|
||||||
const filtered = requests.filter(r => r !== null) as MovieRequest[];
|
|
||||||
return NextResponse.json({ requests: filtered });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取所有求片
|
|
||||||
let requests = await storage.getAllMovieRequests();
|
|
||||||
|
|
||||||
// 按状态筛选
|
|
||||||
if (status) {
|
|
||||||
requests = requests.filter(r => r.status === status);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 列表页不返回 requestedBy
|
|
||||||
if (!detail) {
|
|
||||||
requests = requests.map(r => ({ ...r, requestedBy: [] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按求片人数和时间排序
|
|
||||||
requests.sort((a, b) => {
|
|
||||||
if (b.requestCount !== a.requestCount) {
|
|
||||||
return b.requestCount - a.requestCount;
|
|
||||||
}
|
|
||||||
return b.createdAt - a.createdAt;
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ requests });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取求片列表失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST: 创建或加入求片
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 检查求片功能是否启用并获取冷却时间
|
|
||||||
const { getConfig } = await import('@/lib/config');
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
if (config.SiteConfig.EnableMovieRequest === false) {
|
|
||||||
return NextResponse.json({ error: '求片功能已关闭' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { tmdbId, title, year, mediaType, season, poster, overview } = body;
|
|
||||||
|
|
||||||
if (!title || !mediaType) {
|
|
||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const storage = getStorage();
|
|
||||||
|
|
||||||
// 检查频率限制 - 使用配置中的冷却时间
|
|
||||||
const cooldownSeconds = config.SiteConfig.MovieRequestCooldown ?? 3600;
|
|
||||||
const rateLimit = cooldownSeconds * 1000;
|
|
||||||
|
|
||||||
if (storage.getUserInfoV2) {
|
|
||||||
const userInfo = await storage.getUserInfoV2(authInfo.username);
|
|
||||||
if (userInfo?.last_movie_request_time) {
|
|
||||||
const elapsed = Date.now() - userInfo.last_movie_request_time;
|
|
||||||
if (elapsed < rateLimit) {
|
|
||||||
const remaining = Math.ceil((rateLimit - elapsed) / 60000);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `操作太频繁,请${remaining}分钟后再试` },
|
|
||||||
{ status: 429 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查重(剧集需要匹配季度)
|
|
||||||
const allRequests = await storage.getAllMovieRequests();
|
|
||||||
const existing = allRequests.find(r =>
|
|
||||||
(tmdbId && r.tmdbId === tmdbId && r.season === season) ||
|
|
||||||
(r.title === title && r.year === year && r.season === season)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
// 如果已上架,不允许再求
|
|
||||||
if (existing.status === 'fulfilled') {
|
|
||||||
return NextResponse.json({ error: '该影片已上架' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查用户是否已经求过
|
|
||||||
if (existing.requestedBy.includes(authInfo.username)) {
|
|
||||||
return NextResponse.json({ error: '您已经求过这部影片了' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加入求片
|
|
||||||
existing.requestedBy.push(authInfo.username);
|
|
||||||
existing.requestCount++;
|
|
||||||
existing.updatedAt = Date.now();
|
|
||||||
await storage.updateMovieRequest(existing.id, existing);
|
|
||||||
await storage.addUserMovieRequest(authInfo.username, existing.id);
|
|
||||||
|
|
||||||
// 给站长发送通知
|
|
||||||
const ownerUsername = process.env.USERNAME;
|
|
||||||
if (ownerUsername) {
|
|
||||||
await storage.addNotification(ownerUsername, {
|
|
||||||
id: `movie_request_join_${existing.id}_${Date.now()}`,
|
|
||||||
type: 'movie_request',
|
|
||||||
title: '求片人数增加',
|
|
||||||
message: `${authInfo.username} 也想看:${existing.title}${existing.season ? ` 第${existing.season}季` : ''} (${existing.requestCount}人)`,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
read: false,
|
|
||||||
metadata: {
|
|
||||||
requestId: existing.id,
|
|
||||||
username: authInfo.username,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
message: '已加入求片',
|
|
||||||
request: existing
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建新求片
|
|
||||||
const newRequest: MovieRequest = {
|
|
||||||
id: nanoid(),
|
|
||||||
tmdbId,
|
|
||||||
title,
|
|
||||||
year,
|
|
||||||
mediaType,
|
|
||||||
season,
|
|
||||||
poster,
|
|
||||||
overview,
|
|
||||||
requestedBy: [authInfo.username],
|
|
||||||
requestCount: 1,
|
|
||||||
status: 'pending',
|
|
||||||
createdAt: Date.now(),
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await storage.createMovieRequest(newRequest);
|
|
||||||
await storage.addUserMovieRequest(authInfo.username, newRequest.id);
|
|
||||||
|
|
||||||
// 更新频率限制 - 保存到用户信息的 hash 中
|
|
||||||
if ('client' in storage && storage.client && typeof (storage.client as any).hSet === 'function') {
|
|
||||||
await (storage.client as any).hSet(
|
|
||||||
`user:${authInfo.username}:info`,
|
|
||||||
'last_movie_request_time',
|
|
||||||
Date.now().toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
// 清除用户信息缓存,确保下次读取到最新数据
|
|
||||||
const { userInfoCache } = await import('@/lib/user-cache');
|
|
||||||
userInfoCache?.delete(authInfo.username);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 给站长发送通知
|
|
||||||
const ownerUsername = process.env.USERNAME;
|
|
||||||
if (ownerUsername) {
|
|
||||||
await storage.addNotification(ownerUsername, {
|
|
||||||
id: `movie_request_${newRequest.id}_${Date.now()}`,
|
|
||||||
type: 'movie_request',
|
|
||||||
title: '新求片请求',
|
|
||||||
message: `${authInfo.username} 求片:${title}${season ? ` 第${season}季` : ''}`,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
read: false,
|
|
||||||
metadata: {
|
|
||||||
requestId: newRequest.id,
|
|
||||||
username: authInfo.username,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
message: '求片成功',
|
|
||||||
request: newRequest
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('创建求片失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,10 @@
|
|||||||
* 路径格式: /api/offline-download/local/[source]/[videoId]/[episodeIndex]/[file]
|
* 路径格式: /api/offline-download/local/[source]/[videoId]/[episodeIndex]/[file]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
// 检查是否启用离线下载功能
|
// 检查是否启用离线下载功能
|
||||||
const OFFLINE_DOWNLOAD_ENABLED = process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true';
|
const OFFLINE_DOWNLOAD_ENABLED = process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true';
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
* 本地下载视频播放代理 API
|
* 本地下载视频播放代理 API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
// 检查是否启用离线下载功能
|
// 检查是否启用离线下载功能
|
||||||
const OFFLINE_DOWNLOAD_ENABLED = process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true';
|
const OFFLINE_DOWNLOAD_ENABLED = process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true';
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
* 离线下载任务管理 API
|
* 离线下载任务管理 API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { OfflineDownloader, OfflineDownloadTask } from '@/lib/offline-downloader';
|
import { OfflineDownloader, OfflineDownloadTask } from '@/lib/offline-downloader';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
// 检查是否启用离线下载功能
|
// 检查是否启用离线下载功能
|
||||||
const OFFLINE_DOWNLOAD_ENABLED = process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true';
|
const OFFLINE_DOWNLOAD_ENABLED = process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true';
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export async function GET(
|
|||||||
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||||
const { db } = await import('@/lib/db');
|
const { db } = await import('@/lib/db');
|
||||||
|
|
||||||
let metaInfo = getCachedMetaInfo();
|
let metaInfo = getCachedMetaInfo(rootPath);
|
||||||
|
|
||||||
if (!metaInfo) {
|
if (!metaInfo) {
|
||||||
try {
|
try {
|
||||||
@@ -84,7 +84,7 @@ export async function GET(
|
|||||||
if (metainfoJson) {
|
if (metainfoJson) {
|
||||||
metaInfo = JSON.parse(metainfoJson);
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
if (metaInfo) {
|
if (metaInfo) {
|
||||||
setCachedMetaInfo(metaInfo);
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -267,39 +267,22 @@ async function handleDetail(
|
|||||||
|
|
||||||
let videoInfo = getCachedVideoInfo(folderPath);
|
let videoInfo = getCachedVideoInfo(folderPath);
|
||||||
|
|
||||||
// 获取所有分页的视频文件
|
const listResponse = await client.listDirectory(folderPath);
|
||||||
const allFiles: any[] = [];
|
|
||||||
let currentPage = 1;
|
|
||||||
const pageSize = 100;
|
|
||||||
let total = 0;
|
|
||||||
|
|
||||||
while (true) {
|
if (listResponse.code !== 200) {
|
||||||
const listResponse = await client.listDirectory(folderPath, currentPage, pageSize);
|
return NextResponse.json({
|
||||||
|
code: 0,
|
||||||
if (listResponse.code !== 200) {
|
msg: 'OpenList 列表获取失败',
|
||||||
return NextResponse.json({
|
page: 1,
|
||||||
code: 0,
|
pagecount: 0,
|
||||||
msg: 'OpenList 列表获取失败',
|
limit: 0,
|
||||||
page: 1,
|
total: 0,
|
||||||
pagecount: 0,
|
list: [],
|
||||||
limit: 0,
|
});
|
||||||
total: 0,
|
|
||||||
list: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
total = listResponse.data.total;
|
|
||||||
allFiles.push(...listResponse.data.content);
|
|
||||||
|
|
||||||
if (allFiles.length >= total) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPage++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob'];
|
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob'];
|
||||||
const videoFiles = allFiles.filter((item) => {
|
const videoFiles = listResponse.data.content.filter((item) => {
|
||||||
if (item.is_dir || item.name.startsWith('.') || item.name.endsWith('.json')) return false;
|
if (item.is_dir || item.name.startsWith('.') || item.name.endsWith('.json')) return false;
|
||||||
return videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext));
|
return videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext));
|
||||||
});
|
});
|
||||||
@@ -315,7 +298,6 @@ async function handleDetail(
|
|||||||
season: parsed.season,
|
season: parsed.season,
|
||||||
title: parsed.title,
|
title: parsed.title,
|
||||||
parsed_from: 'filename',
|
parsed_from: 'filename',
|
||||||
isOVA: parsed.isOVA,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
setCachedVideoInfo(folderPath, videoInfo);
|
setCachedVideoInfo(folderPath, videoInfo);
|
||||||
@@ -333,13 +315,13 @@ async function handleDetail(
|
|||||||
const parsed = parseVideoFileName(file.name);
|
const parsed = parseVideoFileName(file.name);
|
||||||
let episodeInfo;
|
let episodeInfo;
|
||||||
if (parsed.episode) {
|
if (parsed.episode) {
|
||||||
episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename', isOVA: parsed.isOVA };
|
episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename' };
|
||||||
} else {
|
} else {
|
||||||
episodeInfo = videoInfo!.episodes[file.name] || { episode: index + 1, season: undefined, title: undefined, parsed_from: 'filename' };
|
episodeInfo = videoInfo!.episodes[file.name] || { episode: index + 1, season: undefined, title: undefined, parsed_from: 'filename' };
|
||||||
}
|
}
|
||||||
let displayTitle = episodeInfo.title;
|
let displayTitle = episodeInfo.title;
|
||||||
if (!displayTitle && episodeInfo.episode) {
|
if (!displayTitle && episodeInfo.episode) {
|
||||||
displayTitle = episodeInfo.isOVA ? `OVA ${episodeInfo.episode}` : `第${episodeInfo.episode}集`;
|
displayTitle = `第${episodeInfo.episode}集`;
|
||||||
}
|
}
|
||||||
if (!displayTitle) {
|
if (!displayTitle) {
|
||||||
displayTitle = file.name;
|
displayTitle = file.name;
|
||||||
@@ -354,16 +336,9 @@ async function handleDetail(
|
|||||||
season: episodeInfo.season,
|
season: episodeInfo.season,
|
||||||
title: displayTitle,
|
title: displayTitle,
|
||||||
playUrl,
|
playUrl,
|
||||||
isOVA: episodeInfo.isOVA,
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName));
|
||||||
// OVA 排在最后
|
|
||||||
if (a.isOVA && !b.isOVA) return 1;
|
|
||||||
if (!a.isOVA && b.isOVA) return -1;
|
|
||||||
// 都是 OVA 或都不是 OVA,按集数排序
|
|
||||||
return a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 转换为 CMS vod_play_url 格式
|
// 转换为 CMS vod_play_url 格式
|
||||||
// 格式:第1集$url1#第2集$url2#第3集$url3
|
// 格式:第1集$url1#第2集$url2#第3集$url3
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
const client = new OpenListClient(
|
const client = new OpenListClient(
|
||||||
openListConfig.URL,
|
openListConfig.URL,
|
||||||
openListConfig.Username,
|
openListConfig.Username,
|
||||||
@@ -72,7 +73,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 读取现有 metainfo (从数据库或缓存)
|
// 读取现有 metainfo (从数据库或缓存)
|
||||||
let metaInfo: MetaInfo | null = getCachedMetaInfo();
|
let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath);
|
||||||
|
|
||||||
if (!metaInfo) {
|
if (!metaInfo) {
|
||||||
try {
|
try {
|
||||||
@@ -131,8 +132,8 @@ export async function POST(request: NextRequest) {
|
|||||||
await db.setGlobalValue('video.metainfo', metainfoContent);
|
await db.setGlobalValue('video.metainfo', metainfoContent);
|
||||||
|
|
||||||
// 更新缓存
|
// 更新缓存
|
||||||
invalidateMetaInfoCache();
|
invalidateMetaInfoCache(rootPath);
|
||||||
setCachedMetaInfo(metaInfo);
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getAuthInfoFromCookie } from '@/lib/auth';
|
|||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import {
|
import {
|
||||||
|
getCachedMetaInfo,
|
||||||
invalidateMetaInfoCache,
|
invalidateMetaInfoCache,
|
||||||
MetaInfo,
|
MetaInfo,
|
||||||
setCachedMetaInfo,
|
setCachedMetaInfo,
|
||||||
@@ -48,6 +49,8 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
|
|
||||||
// 从数据库读取 metainfo
|
// 从数据库读取 metainfo
|
||||||
const metainfoContent = await db.getGlobalValue('video.metainfo');
|
const metainfoContent = await db.getGlobalValue('video.metainfo');
|
||||||
if (!metainfoContent) {
|
if (!metainfoContent) {
|
||||||
@@ -75,8 +78,8 @@ export async function POST(request: NextRequest) {
|
|||||||
await db.setGlobalValue('video.metainfo', updatedMetainfoContent);
|
await db.setGlobalValue('video.metainfo', updatedMetainfoContent);
|
||||||
|
|
||||||
// 更新缓存
|
// 更新缓存
|
||||||
invalidateMetaInfoCache();
|
invalidateMetaInfoCache(rootPath);
|
||||||
setCachedMetaInfo(metaInfo);
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
|
|
||||||
// 更新配置中的资源数量
|
// 更新配置中的资源数量
|
||||||
if (config.OpenListConfig) {
|
if (config.OpenListConfig) {
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// folderName 已经是完整路径,直接使用
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
const folderPath = folderName;
|
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`;
|
||||||
const client = new OpenListClient(
|
const client = new OpenListClient(
|
||||||
openListConfig.URL,
|
openListConfig.URL,
|
||||||
openListConfig.Username,
|
openListConfig.Username,
|
||||||
@@ -127,7 +127,6 @@ export async function GET(request: NextRequest) {
|
|||||||
season: parsed.season,
|
season: parsed.season,
|
||||||
title: parsed.title,
|
title: parsed.title,
|
||||||
parsed_from: 'filename',
|
parsed_from: 'filename',
|
||||||
isOVA: parsed.isOVA,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +174,6 @@ export async function GET(request: NextRequest) {
|
|||||||
season: parsed.season,
|
season: parsed.season,
|
||||||
title: parsed.title,
|
title: parsed.title,
|
||||||
parsed_from: 'filename',
|
parsed_from: 'filename',
|
||||||
isOVA: parsed.isOVA,
|
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// 如果解析失败,尝试从 videoInfo 获取
|
// 如果解析失败,尝试从 videoInfo 获取
|
||||||
@@ -194,7 +192,8 @@ export async function GET(request: NextRequest) {
|
|||||||
// 优先使用解析出的标题,其次是"第X集"格式,最后才是文件名
|
// 优先使用解析出的标题,其次是"第X集"格式,最后才是文件名
|
||||||
let displayTitle = episodeInfo.title;
|
let displayTitle = episodeInfo.title;
|
||||||
if (!displayTitle && episodeInfo.episode) {
|
if (!displayTitle && episodeInfo.episode) {
|
||||||
displayTitle = episodeInfo.isOVA ? `OVA ${episodeInfo.episode}` : `第${episodeInfo.episode}集`;
|
// 支持小数集数显示
|
||||||
|
displayTitle = `第${episodeInfo.episode}集`;
|
||||||
}
|
}
|
||||||
if (!displayTitle) {
|
if (!displayTitle) {
|
||||||
displayTitle = file.name;
|
displayTitle = file.name;
|
||||||
@@ -206,13 +205,9 @@ export async function GET(request: NextRequest) {
|
|||||||
season: episodeInfo.season,
|
season: episodeInfo.season,
|
||||||
title: displayTitle,
|
title: displayTitle,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
isOVA: episodeInfo.isOVA,
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// OVA 排在最后
|
|
||||||
if (a.isOVA && !b.isOVA) return 1;
|
|
||||||
if (!a.isOVA && b.isOVA) return -1;
|
|
||||||
// 确保排序稳定,即使 episode 相同也按文件名排序
|
// 确保排序稳定,即使 episode 相同也按文件名排序
|
||||||
if (a.episode !== b.episode) {
|
if (a.episode !== b.episode) {
|
||||||
return a.episode - b.episode;
|
return a.episode - b.episode;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
const client = new OpenListClient(
|
const client = new OpenListClient(
|
||||||
openListConfig.URL,
|
openListConfig.URL,
|
||||||
openListConfig.Username,
|
openListConfig.Username,
|
||||||
@@ -61,7 +62,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (noCache) {
|
if (noCache) {
|
||||||
// noCache 模式:跳过缓存
|
// noCache 模式:跳过缓存
|
||||||
} else {
|
} else {
|
||||||
metaInfo = getCachedMetaInfo();
|
metaInfo = getCachedMetaInfo(rootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metaInfo) {
|
if (!metaInfo) {
|
||||||
@@ -82,7 +83,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// 只有在不是 noCache 模式时才更新缓存
|
// 只有在不是 noCache 模式时才更新缓存
|
||||||
if (!noCache) {
|
if (!noCache) {
|
||||||
setCachedMetaInfo(metaInfo);
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error('[OpenList List] JSON 解析或验证失败:', parseError);
|
console.error('[OpenList List] JSON 解析或验证失败:', parseError);
|
||||||
|
|||||||
@@ -9,59 +9,9 @@ import { OpenListClient } from '@/lib/openlist.client';
|
|||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 HEAD 请求跟随重定向获取最终 URL(直连方法 - 降级使用)
|
* GET /api/openlist/play?folder=xxx&fileName=xxx
|
||||||
*/
|
* 获取单个视频文件的播放链接(懒加载)
|
||||||
async function getFinalUrl(url: string, maxRedirects = 5): Promise<string> {
|
* 返回重定向到真实播放 URL
|
||||||
let currentUrl = url;
|
|
||||||
let redirectCount = 0;
|
|
||||||
|
|
||||||
while (redirectCount < maxRedirects) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(currentUrl, {
|
|
||||||
method: 'HEAD',
|
|
||||||
redirect: 'manual',
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status >= 300 && response.status < 400) {
|
|
||||||
const location = response.headers.get('location');
|
|
||||||
if (!location) {
|
|
||||||
return currentUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (location.startsWith('http://') || location.startsWith('https://')) {
|
|
||||||
currentUrl = location;
|
|
||||||
} else if (location.startsWith('/')) {
|
|
||||||
const urlObj = new URL(currentUrl);
|
|
||||||
currentUrl = `${urlObj.protocol}//${urlObj.host}${location}`;
|
|
||||||
} else {
|
|
||||||
const urlObj = new URL(currentUrl);
|
|
||||||
const pathParts = urlObj.pathname.split('/');
|
|
||||||
pathParts.pop();
|
|
||||||
pathParts.push(location);
|
|
||||||
currentUrl = `${urlObj.protocol}//${urlObj.host}${pathParts.join('/')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
redirectCount++;
|
|
||||||
} else {
|
|
||||||
return currentUrl;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[openlist/play] 获取最终 URL 失败:', error);
|
|
||||||
return currentUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/openlist/play?folder=xxx&fileName=xxx&format=json
|
|
||||||
* 获取单个视频文件的播放链接(优先使用视频预览流,失败时降级到直连)
|
|
||||||
* format=json: 返回 JSON 格式(用于 play 页面)
|
|
||||||
* 默认: 返回重定向(用于 tvbox 等)
|
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -73,7 +23,6 @@ export async function GET(request: NextRequest) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const folderName = searchParams.get('folder');
|
const folderName = searchParams.get('folder');
|
||||||
const fileName = searchParams.get('fileName');
|
const fileName = searchParams.get('fileName');
|
||||||
const format = searchParams.get('format'); // 新增 format 参数
|
|
||||||
|
|
||||||
if (!folderName || !fileName) {
|
if (!folderName || !fileName) {
|
||||||
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
||||||
@@ -92,8 +41,8 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// folderName 已经是完整路径,直接使用
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
const folderPath = folderName;
|
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`;
|
||||||
const filePath = `${folderPath}/${fileName}`;
|
const filePath = `${folderPath}/${fileName}`;
|
||||||
|
|
||||||
const client = new OpenListClient(
|
const client = new OpenListClient(
|
||||||
@@ -102,120 +51,23 @@ export async function GET(request: NextRequest) {
|
|||||||
openListConfig.Password
|
openListConfig.Password
|
||||||
);
|
);
|
||||||
|
|
||||||
// 如果启用了禁用预览视频,直接使用直连方法
|
// 获取文件的播放链接
|
||||||
if (openListConfig.DisableVideoPreview) {
|
const fileResponse = await client.getFile(filePath);
|
||||||
const fileResponse = await client.getFile(filePath);
|
|
||||||
|
|
||||||
if (fileResponse.code !== 200 || !fileResponse.data.raw_url) {
|
if (fileResponse.code !== 200 || !fileResponse.data.raw_url) {
|
||||||
console.error('[OpenList Play] 获取播放URL失败:', {
|
console.error('[OpenList Play] 获取播放URL失败:', {
|
||||||
fileName,
|
fileName,
|
||||||
code: fileResponse.code,
|
code: fileResponse.code,
|
||||||
message: fileResponse.message,
|
message: fileResponse.message,
|
||||||
});
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '获取播放链接失败' },
|
{ error: '获取播放链接失败' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 如果指定了 format=json,使用 getFinalUrl 并返回 JSON
|
|
||||||
if (format === 'json') {
|
|
||||||
const finalUrl = await getFinalUrl(fileResponse.data.raw_url);
|
|
||||||
|
|
||||||
// 检查URL是否为空
|
|
||||||
if (!finalUrl || finalUrl.trim() === '') {
|
|
||||||
throw new Error('获取到的播放链接为空');
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ url: finalUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查URL是否为空
|
|
||||||
if (!fileResponse.data.raw_url || fileResponse.data.raw_url.trim() === '') {
|
|
||||||
throw new Error('获取到的播放链接为空');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 默认返回重定向(用于 tvbox)
|
|
||||||
return NextResponse.redirect(fileResponse.data.raw_url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先尝试视频预览流方法
|
// 返回重定向到真实播放 URL
|
||||||
try {
|
return NextResponse.redirect(fileResponse.data.raw_url);
|
||||||
const data = await client.getVideoPreview(filePath);
|
|
||||||
|
|
||||||
const taskList = data.data?.video_preview_play_info?.live_transcoding_task_list;
|
|
||||||
if (!taskList || taskList.length === 0) {
|
|
||||||
throw new Error('未找到可用的播放链接');
|
|
||||||
}
|
|
||||||
|
|
||||||
const qualityOrder: Record<string, number> = {
|
|
||||||
'FHD': 1,
|
|
||||||
'HD': 2,
|
|
||||||
'LD': 3,
|
|
||||||
'SD': 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
const qualities = taskList
|
|
||||||
.filter((task: any) => task.status === 'finished')
|
|
||||||
.map((task: any) => ({
|
|
||||||
name: task.template_id,
|
|
||||||
url: task.url,
|
|
||||||
}))
|
|
||||||
.filter((quality: any) => quality.url && quality.url.trim() !== '') // 过滤空URL
|
|
||||||
.sort((a: any, b: any) => (qualityOrder[a.name] || 999) - (qualityOrder[b.name] || 999));
|
|
||||||
|
|
||||||
if (qualities.length === 0) {
|
|
||||||
throw new Error('未找到已完成的播放链接');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果指定了 format=json,返回 JSON 格式
|
|
||||||
if (format === 'json') {
|
|
||||||
return NextResponse.json({
|
|
||||||
url: qualities[0].url,
|
|
||||||
qualities
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 默认返回重定向(用于 tvbox)
|
|
||||||
return NextResponse.redirect(qualities[0].url);
|
|
||||||
} catch (error) {
|
|
||||||
// 视频预览流失败,降级到直连方法
|
|
||||||
console.log('[openlist/play] 视频预览流失败,降级到直连方法:', (error as Error).message);
|
|
||||||
|
|
||||||
const fileResponse = await client.getFile(filePath);
|
|
||||||
|
|
||||||
if (fileResponse.code !== 200 || !fileResponse.data.raw_url) {
|
|
||||||
console.error('[OpenList Play] 获取播放URL失败:', {
|
|
||||||
fileName,
|
|
||||||
code: fileResponse.code,
|
|
||||||
message: fileResponse.message,
|
|
||||||
});
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '获取播放链接失败' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果指定了 format=json,使用 getFinalUrl 并返回 JSON
|
|
||||||
if (format === 'json') {
|
|
||||||
const finalUrl = await getFinalUrl(fileResponse.data.raw_url);
|
|
||||||
|
|
||||||
// 检查URL是否为空
|
|
||||||
if (!finalUrl || finalUrl.trim() === '') {
|
|
||||||
throw new Error('获取到的播放链接为空');
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ url: finalUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查URL是否为空
|
|
||||||
if (!fileResponse.data.raw_url || fileResponse.data.raw_url.trim() === '') {
|
|
||||||
throw new Error('获取到的播放链接为空');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 默认返回重定向(用于 tvbox)
|
|
||||||
return NextResponse.redirect(fileResponse.data.raw_url);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取播放链接失败:', error);
|
console.error('获取播放链接失败:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// folder 已经是完整路径,直接使用
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
const folderPath = folder;
|
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folder}`;
|
||||||
const client = new OpenListClient(
|
const client = new OpenListClient(
|
||||||
openListConfig.URL,
|
openListConfig.URL,
|
||||||
openListConfig.Username,
|
openListConfig.Username,
|
||||||
|
|||||||
@@ -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 { getConfig } from '@/lib/config';
|
|
||||||
import { startOpenListRefresh } from '@/lib/openlist-refresh';
|
import { startOpenListRefresh } from '@/lib/openlist-refresh';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -20,15 +19,6 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 TMDB API Key 是否配置
|
|
||||||
const config = await getConfig();
|
|
||||||
if (!config.SiteConfig.TMDBApiKey || config.SiteConfig.TMDBApiKey.trim() === '') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '请先在站点配置中配置 TMDB API Key' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取请求参数
|
// 获取请求参数
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const clearMetaInfo = body.clearMetaInfo === true;
|
const clearMetaInfo = body.clearMetaInfo === true;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { PansouLink, searchPansou } from '@/lib/pansou.client';
|
import { searchPansou } from '@/lib/pansou.client';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -44,50 +44,13 @@ export async function POST(request: NextRequest) {
|
|||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawBlocklist = config.SiteConfig.PansouKeywordBlocklist || '';
|
|
||||||
const normalizedBlocklist = rawBlocklist.replace(/,/g, ',');
|
|
||||||
const blockedKeywords = normalizedBlocklist
|
|
||||||
.split(',')
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
let filteredResults = results;
|
|
||||||
|
|
||||||
if (blockedKeywords.length > 0 && results.merged_by_type) {
|
|
||||||
const mergedByType: Record<string, PansouLink[]> = {};
|
|
||||||
let total = 0;
|
|
||||||
|
|
||||||
const shouldBlock = (link: PansouLink) => {
|
|
||||||
const content = `${link.note || ''} ${link.url || ''} ${link.source || ''}`.toLowerCase();
|
|
||||||
return blockedKeywords.some((item) =>
|
|
||||||
content.includes(item.toLowerCase())
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.entries(results.merged_by_type).forEach(([type, links]) => {
|
|
||||||
const filteredLinks = links.filter((link) => !shouldBlock(link));
|
|
||||||
if (filteredLinks.length > 0) {
|
|
||||||
mergedByType[type] = filteredLinks;
|
|
||||||
total += filteredLinks.length;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
filteredResults = {
|
|
||||||
...results,
|
|
||||||
merged_by_type: mergedByType,
|
|
||||||
total,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Pansou 搜索结果:', {
|
console.log('Pansou 搜索结果:', {
|
||||||
total: filteredResults.total,
|
total: results.total,
|
||||||
hasData: !!filteredResults.merged_by_type,
|
hasData: !!results.merged_by_type,
|
||||||
types: filteredResults.merged_by_type
|
types: results.merged_by_type ? Object.keys(results.merged_by_type) : [],
|
||||||
? Object.keys(filteredResults.merged_by_type)
|
|
||||||
: [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(filteredResults);
|
return NextResponse.json(results);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Pansou 搜索失败:', error);
|
console.error('Pansou 搜索失败:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { PlayRecord } from '@/lib/types';
|
import { PlayRecord } from '@/lib/types';
|
||||||
|
|
||||||
|
|||||||
@@ -107,51 +107,37 @@ export async function GET(request: Request) {
|
|||||||
function filterAdsFromM3U8Default(type: string, m3u8Content: string): string {
|
function filterAdsFromM3U8Default(type: string, m3u8Content: string): string {
|
||||||
if (!m3u8Content) return '';
|
if (!m3u8Content) return '';
|
||||||
|
|
||||||
// 广告关键字列表
|
|
||||||
const adKeywords = [
|
|
||||||
'sponsor',
|
|
||||||
'/ad/',
|
|
||||||
'/ads/',
|
|
||||||
'advert',
|
|
||||||
'advertisement',
|
|
||||||
'/adjump',
|
|
||||||
'redtraffic'
|
|
||||||
];
|
|
||||||
|
|
||||||
// 按行分割M3U8内容
|
// 按行分割M3U8内容
|
||||||
const lines = m3u8Content.split('\n');
|
const lines = m3u8Content.split('\n');
|
||||||
const filteredLines = [];
|
const filteredLines = [];
|
||||||
|
|
||||||
let i = 0;
|
let nextdelete = false;
|
||||||
while (i < lines.length) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
|
|
||||||
// 跳过 #EXT-X-DISCONTINUITY 标识
|
if (nextdelete) {
|
||||||
if (line.includes('#EXT-X-DISCONTINUITY')) {
|
nextdelete = false;
|
||||||
i++;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是 EXTINF 行,检查下一行 URL 是否包含广告关键字
|
// 只过滤#EXT-X-DISCONTINUITY标识
|
||||||
if (line.includes('#EXTINF:')) {
|
if (!line.includes('#EXT-X-DISCONTINUITY')) {
|
||||||
// 检查下一行 URL 是否包含广告关键字
|
if (
|
||||||
if (i + 1 < lines.length) {
|
type === 'ruyi' &&
|
||||||
const nextLine = lines[i + 1];
|
(line.includes('EXTINF:5.640000') ||
|
||||||
const containsAdKeyword = adKeywords.some(keyword =>
|
line.includes('EXTINF:2.960000') ||
|
||||||
nextLine.toLowerCase().includes(keyword.toLowerCase())
|
line.includes('EXTINF:3.480000') ||
|
||||||
);
|
line.includes('EXTINF:4.000000') ||
|
||||||
|
line.includes('EXTINF:0.960000') ||
|
||||||
if (containsAdKeyword) {
|
line.includes('EXTINF:10.000000') ||
|
||||||
// 跳过 EXTINF 行和 URL 行
|
line.includes('EXTINF:1.266667'))
|
||||||
i += 2;
|
) {
|
||||||
continue;
|
nextdelete = true;
|
||||||
}
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 保留当前行
|
filteredLines.push(line);
|
||||||
filteredLines.push(line);
|
}
|
||||||
i++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredLines.join('\n');
|
return filteredLines.join('\n');
|
||||||
|
|||||||
@@ -44,114 +44,57 @@ export async function GET(request: NextRequest) {
|
|||||||
config.OpenListConfig?.Password
|
config.OpenListConfig?.Password
|
||||||
);
|
);
|
||||||
|
|
||||||
// 获取所有启用的 Emby 源
|
// 搜索 OpenList(如果配置了)
|
||||||
const { embyManager } = await import('@/lib/emby-manager');
|
let openlistResults: any[] = [];
|
||||||
const embySourcesMap = await embyManager.getAllClients();
|
if (hasOpenList) {
|
||||||
const embySources = Array.from(embySourcesMap.values());
|
try {
|
||||||
|
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||||
|
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||||||
|
const { db } = await import('@/lib/db');
|
||||||
|
|
||||||
console.log('[Search] Emby sources count:', embySources.length);
|
const rootPath = config.OpenListConfig!.RootPath || '/';
|
||||||
console.log('[Search] Emby sources:', embySources.map(s => ({ key: s.config.key, name: s.config.name })));
|
let metaInfo = getCachedMetaInfo(rootPath);
|
||||||
|
|
||||||
// 为每个 Emby 源创建搜索 Promise(全部并发,无限制)
|
// 如果没有缓存,尝试从数据库读取
|
||||||
const embyPromises = embySources.map(({ client, config: embyConfig }) =>
|
if (!metaInfo) {
|
||||||
Promise.race([
|
|
||||||
(async () => {
|
|
||||||
try {
|
try {
|
||||||
const searchResult = await client.getItems({
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
searchTerm: query,
|
if (metainfoJson) {
|
||||||
IncludeItemTypes: 'Movie,Series',
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
Recursive: true,
|
if (metaInfo) {
|
||||||
Fields: 'Overview,ProductionYear',
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
Limit: 50,
|
}
|
||||||
});
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Search] 从数据库读取 metainfo 失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 如果只有一个Emby源,保持旧格式(向后兼容)
|
if (metaInfo && metaInfo.folders) {
|
||||||
const sourceValue = embySources.length === 1 ? 'emby' : `emby_${embyConfig.key}`;
|
openlistResults = Object.entries(metaInfo.folders)
|
||||||
const sourceName = embySources.length === 1 ? 'Emby' : embyConfig.name;
|
.filter(([folderName, info]: [string, any]) => {
|
||||||
|
const matchFolder = folderName.toLowerCase().includes(query.toLowerCase());
|
||||||
return searchResult.Items.map((item) => ({
|
const matchTitle = info.title.toLowerCase().includes(query.toLowerCase());
|
||||||
id: item.Id,
|
return matchFolder || matchTitle;
|
||||||
source: sourceValue,
|
})
|
||||||
source_name: sourceName,
|
.map(([folderName, info]: [string, any]) => ({
|
||||||
title: item.Name,
|
id: folderName,
|
||||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
source: 'openlist',
|
||||||
|
source_name: '私人影库',
|
||||||
|
title: info.title,
|
||||||
|
poster: getTMDBImageUrl(info.poster_path),
|
||||||
episodes: [],
|
episodes: [],
|
||||||
episodes_titles: [],
|
episodes_titles: [],
|
||||||
year: item.ProductionYear?.toString() || '',
|
year: info.release_date.split('-')[0] || '',
|
||||||
desc: item.Overview || '',
|
desc: info.overview,
|
||||||
type_name: item.Type === 'Movie' ? '电影' : '电视剧',
|
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
||||||
douban_id: 0,
|
douban_id: 0,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
}
|
||||||
console.error(`[Search] 搜索 ${embyConfig.name} 失败:`, error);
|
} catch (error) {
|
||||||
return [];
|
console.error('[Search] 搜索 OpenList 失败:', error);
|
||||||
}
|
}
|
||||||
})(),
|
}
|
||||||
new Promise<any[]>((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error(`${embyConfig.name} timeout`)), 20000)
|
|
||||||
),
|
|
||||||
]).catch((error) => {
|
|
||||||
console.error(`[Search] 搜索 ${embyConfig.name} 超时:`, error);
|
|
||||||
return [];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// 搜索 OpenList(如果配置了)- 异步带超时
|
|
||||||
const openlistPromise = hasOpenList
|
|
||||||
? Promise.race([
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
|
||||||
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
|
||||||
const { db } = await import('@/lib/db');
|
|
||||||
|
|
||||||
let metaInfo = getCachedMetaInfo();
|
|
||||||
|
|
||||||
if (!metaInfo) {
|
|
||||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
|
||||||
if (metainfoJson) {
|
|
||||||
metaInfo = JSON.parse(metainfoJson);
|
|
||||||
if (metaInfo) {
|
|
||||||
setCachedMetaInfo(metaInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metaInfo && metaInfo.folders) {
|
|
||||||
return Object.entries(metaInfo.folders)
|
|
||||||
.filter(([folderName, info]: [string, any]) => {
|
|
||||||
const matchFolder = folderName.toLowerCase().includes(query.toLowerCase());
|
|
||||||
const matchTitle = info.title.toLowerCase().includes(query.toLowerCase());
|
|
||||||
return matchFolder || matchTitle;
|
|
||||||
})
|
|
||||||
.map(([folderName, info]: [string, any]) => ({
|
|
||||||
id: folderName,
|
|
||||||
source: 'openlist',
|
|
||||||
source_name: '私人影库',
|
|
||||||
title: info.title,
|
|
||||||
poster: getTMDBImageUrl(info.poster_path),
|
|
||||||
episodes: [],
|
|
||||||
episodes_titles: [],
|
|
||||||
year: info.release_date.split('-')[0] || '',
|
|
||||||
desc: info.overview,
|
|
||||||
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
|
||||||
douban_id: 0,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Search] 搜索 OpenList 失败:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
new Promise<any[]>((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('OpenList timeout')), 20000)
|
|
||||||
),
|
|
||||||
]).catch((error) => {
|
|
||||||
console.error('[Search] 搜索 OpenList 超时:', error);
|
|
||||||
return [];
|
|
||||||
})
|
|
||||||
: Promise.resolve([]);
|
|
||||||
|
|
||||||
// 添加超时控制和错误处理,避免慢接口拖累整体响应
|
// 添加超时控制和错误处理,避免慢接口拖累整体响应
|
||||||
const searchPromises = apiSites.map((site) =>
|
const searchPromises = apiSites.map((site) =>
|
||||||
@@ -167,24 +110,11 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allResults = await Promise.all([
|
const results = await Promise.allSettled(searchPromises);
|
||||||
openlistPromise,
|
const successResults = results
|
||||||
...embyPromises,
|
.filter((result) => result.status === 'fulfilled')
|
||||||
...searchPromises,
|
.map((result) => (result as PromiseFulfilledResult<any>).value);
|
||||||
]);
|
let flattenedResults = [...openlistResults, ...successResults.flat()];
|
||||||
|
|
||||||
// 分离结果:第一个是 openlist,接下来是 emby 结果,最后是 api 结果
|
|
||||||
// 添加安全检查,确保即使某个结果处理出错也不影响其他结果
|
|
||||||
const openlistResults = Array.isArray(allResults[0]) ? allResults[0] : [];
|
|
||||||
const embyResultsArray = allResults.slice(1, 1 + embyPromises.length);
|
|
||||||
const apiResults = allResults.slice(1 + embyPromises.length);
|
|
||||||
|
|
||||||
// 合并所有 Emby 结果,添加安全检查
|
|
||||||
const embyResults = embyResultsArray.filter(Array.isArray).flat();
|
|
||||||
const apiResultsFlat = apiResults.filter(Array.isArray).flat();
|
|
||||||
|
|
||||||
let flattenedResults = [...openlistResults, ...embyResults, ...apiResultsFlat];
|
|
||||||
|
|
||||||
if (!config.SiteConfig.DisableYellowFilter) {
|
if (!config.SiteConfig.DisableYellowFilter) {
|
||||||
flattenedResults = flattenedResults.filter((result) => {
|
flattenedResults = flattenedResults.filter((result) => {
|
||||||
const typeName = result.type_name || '';
|
const typeName = result.type_name || '';
|
||||||
@@ -210,7 +140,6 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Search] 搜索结果处理失败:', error);
|
|
||||||
return NextResponse.json({ error: '搜索失败' }, { status: 500 });
|
return NextResponse.json({ error: '搜索失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,13 +41,6 @@ export async function GET(request: NextRequest) {
|
|||||||
config.OpenListConfig?.Password
|
config.OpenListConfig?.Password
|
||||||
);
|
);
|
||||||
|
|
||||||
// 检查是否配置了 Emby(支持多源)
|
|
||||||
const hasEmby = !!(
|
|
||||||
config.EmbyConfig?.Sources &&
|
|
||||||
config.EmbyConfig.Sources.length > 0 &&
|
|
||||||
config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 共享状态
|
// 共享状态
|
||||||
let streamClosed = false;
|
let streamClosed = false;
|
||||||
|
|
||||||
@@ -73,23 +66,11 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取 Emby 源数量
|
|
||||||
let embySourcesCount = 0;
|
|
||||||
if (hasEmby) {
|
|
||||||
try {
|
|
||||||
const { embyManager } = await import('@/lib/emby-manager');
|
|
||||||
const embySourcesMap = await embyManager.getAllClients();
|
|
||||||
embySourcesCount = embySourcesMap.size;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Search WS] 获取 Emby 源数量失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送开始事件
|
// 发送开始事件
|
||||||
const startEvent = `data: ${JSON.stringify({
|
const startEvent = `data: ${JSON.stringify({
|
||||||
type: 'start',
|
type: 'start',
|
||||||
query,
|
query,
|
||||||
totalSources: apiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount,
|
totalSources: apiSites.length + (hasOpenList ? 1 : 0),
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})}\n\n`;
|
})}\n\n`;
|
||||||
|
|
||||||
@@ -101,185 +82,75 @@ export async function GET(request: NextRequest) {
|
|||||||
let completedSources = 0;
|
let completedSources = 0;
|
||||||
const allResults: any[] = [];
|
const allResults: any[] = [];
|
||||||
|
|
||||||
// 搜索 Emby(如果配置了)- 异步带超时,支持多源
|
// 搜索 OpenList(如果配置了)
|
||||||
if (hasEmby) {
|
if (hasOpenList) {
|
||||||
(async () => {
|
try {
|
||||||
let embyCompletedCount = 0;
|
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||||
try {
|
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||||||
const { embyManager } = await import('@/lib/emby-manager');
|
const { db } = await import('@/lib/db');
|
||||||
const embySourcesMap = await embyManager.getAllClients();
|
|
||||||
const embySources = Array.from(embySourcesMap.values());
|
|
||||||
|
|
||||||
// 为每个 Emby 源并发搜索,并单独发送结果
|
const rootPath = config.OpenListConfig!.RootPath || '/';
|
||||||
const embySearchPromises = embySources.map(async ({ client, config: embyConfig }) => {
|
let metaInfo = getCachedMetaInfo(rootPath);
|
||||||
try {
|
|
||||||
const searchResult = await client.getItems({
|
|
||||||
searchTerm: query,
|
|
||||||
IncludeItemTypes: 'Movie,Series',
|
|
||||||
Recursive: true,
|
|
||||||
Fields: 'Overview,ProductionYear',
|
|
||||||
Limit: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sourceValue = embySources.length === 1 ? 'emby' : `emby_${embyConfig.key}`;
|
// 如果没有缓存,尝试从数据库读取
|
||||||
const sourceName = embySources.length === 1 ? 'Emby' : embyConfig.name;
|
if (!metaInfo) {
|
||||||
|
try {
|
||||||
// 添加安全检查,确保 Items 存在且是数组
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
const items = Array.isArray(searchResult?.Items) ? searchResult.Items : [];
|
if (metainfoJson) {
|
||||||
const results = items.map((item) => ({
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
id: item.Id,
|
if (metaInfo) {
|
||||||
source: sourceValue,
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
source_name: sourceName,
|
|
||||||
title: item.Name,
|
|
||||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
|
||||||
episodes: [],
|
|
||||||
episodes_titles: [],
|
|
||||||
year: item.ProductionYear?.toString() || '',
|
|
||||||
desc: item.Overview || '',
|
|
||||||
type_name: item.Type === 'Movie' ? '电影' : '电视剧',
|
|
||||||
douban_id: 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 单独发送每个源的结果
|
|
||||||
embyCompletedCount++;
|
|
||||||
completedSources++;
|
|
||||||
if (!streamClosed) {
|
|
||||||
const sourceEvent = `data: ${JSON.stringify({
|
|
||||||
type: 'source_result',
|
|
||||||
source: sourceValue,
|
|
||||||
sourceName: sourceName,
|
|
||||||
results: results,
|
|
||||||
timestamp: Date.now()
|
|
||||||
})}\n\n`;
|
|
||||||
if (safeEnqueue(encoder.encode(sourceEvent))) {
|
|
||||||
if (results.length > 0) {
|
|
||||||
allResults.push(...results);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
streamClosed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[Search WS] 搜索 ${embyConfig.name} 失败:`, error);
|
|
||||||
embyCompletedCount++;
|
|
||||||
completedSources++;
|
|
||||||
// 发送空结果
|
|
||||||
if (!streamClosed) {
|
|
||||||
const sourceValue = embySources.length === 1 ? 'emby' : `emby_${embyConfig.key}`;
|
|
||||||
const sourceName = embySources.length === 1 ? 'Emby' : embyConfig.name;
|
|
||||||
const sourceEvent = `data: ${JSON.stringify({
|
|
||||||
type: 'source_result',
|
|
||||||
source: sourceValue,
|
|
||||||
sourceName: sourceName,
|
|
||||||
results: [],
|
|
||||||
timestamp: Date.now()
|
|
||||||
})}\n\n`;
|
|
||||||
safeEnqueue(encoder.encode(sourceEvent));
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(embySearchPromises);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Search WS] 搜索 Emby 整体失败:', error);
|
|
||||||
// 如果整个 emby 搜索失败,需要补齐未完成的源
|
|
||||||
const remainingSources = embySourcesCount - embyCompletedCount;
|
|
||||||
for (let i = 0; i < remainingSources; i++) {
|
|
||||||
completedSources++;
|
|
||||||
if (!streamClosed) {
|
|
||||||
const sourceEvent = `data: ${JSON.stringify({
|
|
||||||
type: 'source_result',
|
|
||||||
source: 'emby',
|
|
||||||
sourceName: 'Emby',
|
|
||||||
results: [],
|
|
||||||
timestamp: Date.now()
|
|
||||||
})}\n\n`;
|
|
||||||
safeEnqueue(encoder.encode(sourceEvent));
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Search WS] 从数据库读取 metainfo 失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索 OpenList(如果配置了)- 异步带超时
|
if (metaInfo && metaInfo.folders) {
|
||||||
if (hasOpenList) {
|
const openlistResults = Object.entries(metaInfo.folders)
|
||||||
Promise.race([
|
.filter(([key, info]: [string, any]) => {
|
||||||
(async () => {
|
const matchFolder = info.folderName.toLowerCase().includes(query.toLowerCase());
|
||||||
try {
|
const matchTitle = info.title.toLowerCase().includes(query.toLowerCase());
|
||||||
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
return matchFolder || matchTitle;
|
||||||
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
})
|
||||||
const { db } = await import('@/lib/db');
|
.map(([key, info]: [string, any]) => ({
|
||||||
|
id: key,
|
||||||
|
source: 'openlist',
|
||||||
|
source_name: '私人影库',
|
||||||
|
title: info.title,
|
||||||
|
poster: getTMDBImageUrl(info.poster_path),
|
||||||
|
episodes: [],
|
||||||
|
episodes_titles: [],
|
||||||
|
year: info.release_date.split('-')[0] || '',
|
||||||
|
desc: info.overview,
|
||||||
|
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
||||||
|
douban_id: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
let metaInfo = getCachedMetaInfo();
|
|
||||||
|
|
||||||
if (!metaInfo) {
|
|
||||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
|
||||||
if (metainfoJson) {
|
|
||||||
metaInfo = JSON.parse(metainfoJson);
|
|
||||||
if (metaInfo) {
|
|
||||||
setCachedMetaInfo(metaInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metaInfo && metaInfo.folders) {
|
|
||||||
return Object.entries(metaInfo.folders)
|
|
||||||
.filter(([key, info]: [string, any]) => {
|
|
||||||
const matchFolder = info.folderName.toLowerCase().includes(query.toLowerCase());
|
|
||||||
const matchTitle = info.title.toLowerCase().includes(query.toLowerCase());
|
|
||||||
return matchFolder || matchTitle;
|
|
||||||
})
|
|
||||||
.map(([key, info]: [string, any]) => ({
|
|
||||||
id: key,
|
|
||||||
source: 'openlist',
|
|
||||||
source_name: '私人影库',
|
|
||||||
title: info.title,
|
|
||||||
poster: getTMDBImageUrl(info.poster_path),
|
|
||||||
episodes: [],
|
|
||||||
episodes_titles: [],
|
|
||||||
year: info.release_date.split('-')[0] || '',
|
|
||||||
desc: info.overview,
|
|
||||||
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
|
||||||
douban_id: 0,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Search WS] 搜索 OpenList 失败:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('OpenList timeout')), 20000)
|
|
||||||
),
|
|
||||||
])
|
|
||||||
.then((openlistResults: any) => {
|
|
||||||
completedSources++;
|
completedSources++;
|
||||||
|
|
||||||
if (!streamClosed) {
|
if (!streamClosed) {
|
||||||
// 添加安全检查,确保结果是数组
|
|
||||||
const safeResults = Array.isArray(openlistResults) ? openlistResults : [];
|
|
||||||
const sourceEvent = `data: ${JSON.stringify({
|
const sourceEvent = `data: ${JSON.stringify({
|
||||||
type: 'source_result',
|
type: 'source_result',
|
||||||
source: 'openlist',
|
source: 'openlist',
|
||||||
sourceName: '私人影库',
|
sourceName: '私人影库',
|
||||||
results: safeResults,
|
results: openlistResults,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})}\n\n`;
|
})}\n\n`;
|
||||||
|
|
||||||
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
||||||
streamClosed = true;
|
streamClosed = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (safeResults.length > 0) {
|
|
||||||
allResults.push(...safeResults);
|
if (openlistResults.length > 0) {
|
||||||
|
allResults.push(...openlistResults);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
} else {
|
||||||
.catch((error) => {
|
|
||||||
console.error('[Search WS] 搜索 OpenList 超时:', error);
|
|
||||||
completedSources++;
|
completedSources++;
|
||||||
|
|
||||||
if (!streamClosed) {
|
if (!streamClosed) {
|
||||||
const sourceEvent = `data: ${JSON.stringify({
|
const sourceEvent = `data: ${JSON.stringify({
|
||||||
type: 'source_result',
|
type: 'source_result',
|
||||||
@@ -288,9 +159,32 @@ export async function GET(request: NextRequest) {
|
|||||||
results: [],
|
results: [],
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})}\n\n`;
|
})}\n\n`;
|
||||||
safeEnqueue(encoder.encode(sourceEvent));
|
|
||||||
|
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
||||||
|
streamClosed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Search WS] 搜索 OpenList 失败:', error);
|
||||||
|
completedSources++;
|
||||||
|
|
||||||
|
if (!streamClosed) {
|
||||||
|
const errorEvent = `data: ${JSON.stringify({
|
||||||
|
type: 'source_error',
|
||||||
|
source: 'openlist',
|
||||||
|
sourceName: '私人影库',
|
||||||
|
error: error instanceof Error ? error.message : '搜索失败',
|
||||||
|
timestamp: Date.now()
|
||||||
|
})}\n\n`;
|
||||||
|
|
||||||
|
if (!safeEnqueue(encoder.encode(errorEvent))) {
|
||||||
|
streamClosed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为每个源创建搜索 Promise
|
// 为每个源创建搜索 Promise
|
||||||
@@ -306,13 +200,10 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const results = await searchPromise as any[];
|
const results = await searchPromise as any[];
|
||||||
|
|
||||||
// 添加安全检查,确保结果是数组
|
|
||||||
const safeResults = Array.isArray(results) ? results : [];
|
|
||||||
|
|
||||||
// 过滤黄色内容
|
// 过滤黄色内容
|
||||||
let filteredResults = safeResults;
|
let filteredResults = results;
|
||||||
if (!config.SiteConfig.DisableYellowFilter) {
|
if (!config.SiteConfig.DisableYellowFilter) {
|
||||||
filteredResults = safeResults.filter((result) => {
|
filteredResults = results.filter((result) => {
|
||||||
const typeName = result.type_name || '';
|
const typeName = result.type_name || '';
|
||||||
return !yellowWords.some((word: string) => typeName.includes(word));
|
return !yellowWords.some((word: string) => typeName.includes(word));
|
||||||
});
|
});
|
||||||
@@ -363,7 +254,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否所有源都已完成
|
// 检查是否所有源都已完成
|
||||||
if (completedSources === apiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount) {
|
if (completedSources === apiSites.length + (hasOpenList ? 1 : 0)) {
|
||||||
if (!streamClosed) {
|
if (!streamClosed) {
|
||||||
// 发送最终完成事件
|
// 发送最终完成事件
|
||||||
const completeEvent = `data: ${JSON.stringify({
|
const completeEvent = `data: ${JSON.stringify({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { getConfig } from '@/lib/config';
|
|||||||
import { CURRENT_VERSION } from '@/lib/version'
|
import { CURRENT_VERSION } from '@/lib/version'
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic'; // 禁用缓存
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
console.log('server-config called: ', request.url);
|
console.log('server-config called: ', request.url);
|
||||||
@@ -55,8 +54,6 @@ export async function GET(request: NextRequest) {
|
|||||||
AIEnableHomepageEntry: config.AIConfig?.EnableHomepageEntry || false,
|
AIEnableHomepageEntry: config.AIConfig?.EnableHomepageEntry || false,
|
||||||
AIEnableVideoCardEntry: config.AIConfig?.EnableVideoCardEntry || false,
|
AIEnableVideoCardEntry: config.AIConfig?.EnableVideoCardEntry || false,
|
||||||
AIEnablePlayPageEntry: config.AIConfig?.EnablePlayPageEntry || false,
|
AIEnablePlayPageEntry: config.AIConfig?.EnablePlayPageEntry || false,
|
||||||
AIDefaultMessageNoVideo: config.AIConfig?.DefaultMessageNoVideo || '',
|
|
||||||
AIDefaultMessageWithVideo: config.AIConfig?.DefaultMessageWithVideo || '',
|
|
||||||
};
|
};
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { SkipConfig } from '@/lib/types';
|
import { SkipConfig } from '@/lib/types';
|
||||||
|
|
||||||
|
|||||||
@@ -22,206 +22,11 @@ export async function GET(request: NextRequest) {
|
|||||||
const id = searchParams.get('id');
|
const id = searchParams.get('id');
|
||||||
const sourceCode = searchParams.get('source');
|
const sourceCode = searchParams.get('source');
|
||||||
const title = searchParams.get('title'); // 用于搜索的标题
|
const title = searchParams.get('title'); // 用于搜索的标题
|
||||||
const fileName = searchParams.get('fileName'); // 小雅源:用户点击的文件名
|
|
||||||
|
|
||||||
if (!id || !sourceCode || !title) {
|
if (!id || !sourceCode || !title) {
|
||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 特殊处理 emby 源(支持多源)
|
|
||||||
if (sourceCode === 'emby' || sourceCode.startsWith('emby_')) {
|
|
||||||
try {
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
// 检查是否有启用的 Emby 源
|
|
||||||
if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) {
|
|
||||||
throw new Error('Emby 未配置或未启用');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 embyKey
|
|
||||||
let embyKey: string | undefined;
|
|
||||||
if (sourceCode.startsWith('emby_')) {
|
|
||||||
embyKey = sourceCode.substring(5); // 'emby_'.length = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 EmbyManager 获取客户端和配置
|
|
||||||
const { embyManager } = await import('@/lib/emby-manager');
|
|
||||||
const sources = await embyManager.getEnabledSources();
|
|
||||||
const sourceConfig = sources.find(s => s.key === embyKey);
|
|
||||||
const sourceName = sourceConfig?.name || 'Emby';
|
|
||||||
|
|
||||||
const client = await embyManager.getClient(embyKey);
|
|
||||||
|
|
||||||
// 获取媒体详情
|
|
||||||
const item = await client.getItem(id);
|
|
||||||
|
|
||||||
// 根据类型处理
|
|
||||||
if (item.Type === 'Movie') {
|
|
||||||
// 电影
|
|
||||||
const subtitles = client.getSubtitles(item);
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
source: sourceCode, // 保持与请求一致(emby 或 emby_key)
|
|
||||||
source_name: sourceName,
|
|
||||||
id: item.Id,
|
|
||||||
title: item.Name,
|
|
||||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
|
||||||
year: item.ProductionYear?.toString() || '',
|
|
||||||
douban_id: 0,
|
|
||||||
desc: item.Overview || '',
|
|
||||||
episodes: [await client.getStreamUrl(item.Id)],
|
|
||||||
episodes_titles: [item.Name],
|
|
||||||
subtitles: subtitles.length > 0 ? [subtitles] : [],
|
|
||||||
proxyMode: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} else if (item.Type === 'Series') {
|
|
||||||
// 剧集 - 获取所有季和集
|
|
||||||
const seasons = await client.getSeasons(item.Id);
|
|
||||||
const allEpisodes: any[] = [];
|
|
||||||
|
|
||||||
for (const season of seasons) {
|
|
||||||
const episodes = await client.getEpisodes(item.Id, season.Id);
|
|
||||||
allEpisodes.push(...episodes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按季和集排序
|
|
||||||
allEpisodes.sort((a, b) => {
|
|
||||||
if (a.ParentIndexNumber !== b.ParentIndexNumber) {
|
|
||||||
return (a.ParentIndexNumber || 0) - (b.ParentIndexNumber || 0);
|
|
||||||
}
|
|
||||||
return (a.IndexNumber || 0) - (b.IndexNumber || 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
source: sourceCode, // 保持与请求一致(emby 或 emby_key)
|
|
||||||
source_name: sourceName,
|
|
||||||
id: item.Id,
|
|
||||||
title: item.Name,
|
|
||||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
|
||||||
year: item.ProductionYear?.toString() || '',
|
|
||||||
douban_id: 0,
|
|
||||||
desc: item.Overview || '',
|
|
||||||
episodes: await Promise.all(allEpisodes.map((ep) => client.getStreamUrl(ep.Id))),
|
|
||||||
episodes_titles: allEpisodes.map((ep) => {
|
|
||||||
const seasonNum = ep.ParentIndexNumber || 1;
|
|
||||||
const episodeNum = ep.IndexNumber || 1;
|
|
||||||
return `S${seasonNum.toString().padStart(2, '0')}E${episodeNum.toString().padStart(2, '0')}`;
|
|
||||||
}),
|
|
||||||
subtitles: allEpisodes.map((ep) => client.getSubtitles(ep)),
|
|
||||||
proxyMode: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} else {
|
|
||||||
throw new Error('不支持的媒体类型');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 特殊处理 xiaoya 源
|
|
||||||
if (sourceCode === 'xiaoya') {
|
|
||||||
try {
|
|
||||||
const config = await getConfig();
|
|
||||||
const xiaoyaConfig = config.XiaoyaConfig;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!xiaoyaConfig ||
|
|
||||||
!xiaoyaConfig.Enabled ||
|
|
||||||
!xiaoyaConfig.ServerURL
|
|
||||||
) {
|
|
||||||
throw new Error('小雅未配置或未启用');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { XiaoyaClient } = await import('@/lib/xiaoya.client');
|
|
||||||
const { getXiaoyaMetadata, getXiaoyaEpisodes } = await import('@/lib/xiaoya-metadata');
|
|
||||||
const { base58Decode, base58Encode } = await import('@/lib/utils');
|
|
||||||
|
|
||||||
const client = new XiaoyaClient(
|
|
||||||
xiaoyaConfig.ServerURL,
|
|
||||||
xiaoyaConfig.Username,
|
|
||||||
xiaoyaConfig.Password,
|
|
||||||
xiaoyaConfig.Token
|
|
||||||
);
|
|
||||||
|
|
||||||
// 对id进行base58解码得到目录路径
|
|
||||||
let decodedDirPath: string;
|
|
||||||
try {
|
|
||||||
decodedDirPath = base58Decode(id);
|
|
||||||
console.log('[xiaoya] 解码目录路径:', decodedDirPath);
|
|
||||||
} catch (decodeError) {
|
|
||||||
console.error('[xiaoya] Base58解码失败:', decodeError);
|
|
||||||
throw new Error('无效的视频ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证解码后的路径
|
|
||||||
if (!decodedDirPath || decodedDirPath.trim() === '') {
|
|
||||||
throw new Error('解码后的路径为空');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有fileName参数,拼接完整文件路径
|
|
||||||
let clickedFilePath: string | undefined;
|
|
||||||
if (fileName) {
|
|
||||||
// 拼接目录路径和文件名
|
|
||||||
clickedFilePath = `${decodedDirPath}${decodedDirPath.endsWith('/') ? '' : '/'}${fileName}`;
|
|
||||||
console.log('[xiaoya] 用户点击的文件路径:', clickedFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取元数据(使用目录路径或点击的文件路径)
|
|
||||||
const metadataPath = clickedFilePath || decodedDirPath;
|
|
||||||
const metadata = await getXiaoyaMetadata(
|
|
||||||
client,
|
|
||||||
metadataPath,
|
|
||||||
config.SiteConfig.TMDBApiKey,
|
|
||||||
config.SiteConfig.TMDBProxy,
|
|
||||||
config.SiteConfig.TMDBReverseProxy
|
|
||||||
);
|
|
||||||
|
|
||||||
// 获取集数列表(使用目录路径或点击的文件路径)
|
|
||||||
const episodes = await getXiaoyaEpisodes(client, metadataPath);
|
|
||||||
|
|
||||||
// 如果有点击的文件路径,找到对应的集数索引
|
|
||||||
let clickedFileIndex = -1;
|
|
||||||
if (clickedFilePath) {
|
|
||||||
clickedFileIndex = episodes.findIndex(ep => ep.path === clickedFilePath);
|
|
||||||
console.log('[xiaoya] 文件在集数列表中的索引:', clickedFileIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
source: 'xiaoya',
|
|
||||||
source_name: '小雅',
|
|
||||||
id: id, // 保持编码后的目录id
|
|
||||||
title: metadata.title,
|
|
||||||
poster: metadata.poster || '',
|
|
||||||
year: metadata.year || '',
|
|
||||||
douban_id: 0,
|
|
||||||
desc: metadata.plot || '',
|
|
||||||
episodes: episodes.map(ep => `/api/xiaoya/play?path=${encodeURIComponent(base58Encode(ep.path))}`),
|
|
||||||
episodes_titles: episodes.map(ep => ep.title),
|
|
||||||
subtitles: [],
|
|
||||||
proxyMode: false,
|
|
||||||
// 返回用户点击的文件索引(如果找到的话)
|
|
||||||
initialEpisodeIndex: clickedFileIndex >= 0 ? clickedFileIndex : undefined,
|
|
||||||
// 返回元数据来源
|
|
||||||
metadataSource: metadata.source,
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[xiaoya] 获取详情失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 特殊处理 openlist 源 - 直接调用 /api/detail
|
// 特殊处理 openlist 源 - 直接调用 /api/detail
|
||||||
if (sourceCode === 'openlist') {
|
if (sourceCode === 'openlist') {
|
||||||
try {
|
try {
|
||||||
@@ -247,13 +52,13 @@ export async function GET(request: NextRequest) {
|
|||||||
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||||
const { db } = await import('@/lib/db');
|
const { db } = await import('@/lib/db');
|
||||||
|
|
||||||
metaInfo = getCachedMetaInfo();
|
metaInfo = getCachedMetaInfo(rootPath);
|
||||||
|
|
||||||
if (!metaInfo) {
|
if (!metaInfo) {
|
||||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
if (metainfoJson) {
|
if (metainfoJson) {
|
||||||
metaInfo = JSON.parse(metainfoJson);
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
setCachedMetaInfo(metaInfo);
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,31 +88,14 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
let videoInfo = getCachedVideoInfo(folderPath);
|
let videoInfo = getCachedVideoInfo(folderPath);
|
||||||
|
|
||||||
// 获取所有分页的视频文件
|
const listResponse = await client.listDirectory(folderPath);
|
||||||
const allFiles: any[] = [];
|
|
||||||
let currentPage = 1;
|
|
||||||
const pageSize = 100;
|
|
||||||
let total = 0;
|
|
||||||
|
|
||||||
while (true) {
|
if (listResponse.code !== 200) {
|
||||||
const listResponse = await client.listDirectory(folderPath, currentPage, pageSize);
|
throw new Error('OpenList 列表获取失败');
|
||||||
|
|
||||||
if (listResponse.code !== 200) {
|
|
||||||
throw new Error('OpenList 列表获取失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
total = listResponse.data.total;
|
|
||||||
allFiles.push(...listResponse.data.content);
|
|
||||||
|
|
||||||
if (allFiles.length >= total) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPage++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob'];
|
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob'];
|
||||||
const videoFiles = allFiles.filter((item) => {
|
const videoFiles = listResponse.data.content.filter((item) => {
|
||||||
if (item.is_dir || item.name.startsWith('.') || item.name.endsWith('.json')) return false;
|
if (item.is_dir || item.name.startsWith('.') || item.name.endsWith('.json')) return false;
|
||||||
return videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext));
|
return videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext));
|
||||||
});
|
});
|
||||||
@@ -323,7 +111,6 @@ export async function GET(request: NextRequest) {
|
|||||||
season: parsed.season,
|
season: parsed.season,
|
||||||
title: parsed.title,
|
title: parsed.title,
|
||||||
parsed_from: 'filename',
|
parsed_from: 'filename',
|
||||||
isOVA: parsed.isOVA,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
setCachedVideoInfo(folderPath, videoInfo);
|
setCachedVideoInfo(folderPath, videoInfo);
|
||||||
@@ -334,26 +121,20 @@ export async function GET(request: NextRequest) {
|
|||||||
const parsed = parseVideoFileName(file.name);
|
const parsed = parseVideoFileName(file.name);
|
||||||
let episodeInfo;
|
let episodeInfo;
|
||||||
if (parsed.episode) {
|
if (parsed.episode) {
|
||||||
episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename', isOVA: parsed.isOVA };
|
episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename' };
|
||||||
} else {
|
} else {
|
||||||
episodeInfo = videoInfo!.episodes[file.name] || { episode: index + 1, season: undefined, title: undefined, parsed_from: 'filename' };
|
episodeInfo = videoInfo!.episodes[file.name] || { episode: index + 1, season: undefined, title: undefined, parsed_from: 'filename' };
|
||||||
}
|
}
|
||||||
let displayTitle = episodeInfo.title;
|
let displayTitle = episodeInfo.title;
|
||||||
if (!displayTitle && episodeInfo.episode) {
|
if (!displayTitle && episodeInfo.episode) {
|
||||||
displayTitle = episodeInfo.isOVA ? `OVA ${episodeInfo.episode}` : `第${episodeInfo.episode}集`;
|
displayTitle = `第${episodeInfo.episode}集`;
|
||||||
}
|
}
|
||||||
if (!displayTitle) {
|
if (!displayTitle) {
|
||||||
displayTitle = file.name;
|
displayTitle = file.name;
|
||||||
}
|
}
|
||||||
return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle, isOVA: episodeInfo.isOVA };
|
return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle };
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName));
|
||||||
// OVA 排在最后
|
|
||||||
if (a.isOVA && !b.isOVA) return 1;
|
|
||||||
if (!a.isOVA && b.isOVA) return -1;
|
|
||||||
// 都是 OVA 或都不是 OVA,按集数排序
|
|
||||||
return a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. 从 metainfo 中获取元数据
|
// 3. 从 metainfo 中获取元数据
|
||||||
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { API_CONFIG, getAvailableApiSites, getConfig } from '@/lib/config';
|
import { API_CONFIG, getAvailableApiSites } from '@/lib/config';
|
||||||
import { yellowWords } from '@/lib/yellow';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await getConfig();
|
|
||||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||||
const targetSite = apiSites.find((site) => site.key === sourceKey);
|
const targetSite = apiSites.find((site) => site.key === sourceKey);
|
||||||
|
|
||||||
@@ -63,17 +61,8 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用黄色过滤器规则
|
|
||||||
let filteredCategories = classData.class;
|
|
||||||
if (!config.SiteConfig.DisableYellowFilter) {
|
|
||||||
filteredCategories = classData.class.filter((item) => {
|
|
||||||
const typeName = item.type_name || '';
|
|
||||||
return !yellowWords.some((word: string) => typeName.includes(word));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
categories: filteredCategories.map((item) => ({
|
categories: classData.class.map((item) => ({
|
||||||
id: item.type_id.toString(),
|
id: item.type_id.toString(),
|
||||||
name: item.type_name,
|
name: item.type_name,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
import { getThemeCSS } from '@/styles/themes';
|
import { getThemeCSS } from '@/styles/themes';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic'; // 禁用缓存
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
import {
|
import {
|
||||||
getTMDBImageUrl,
|
searchTMDBMulti,
|
||||||
getTMDBMovieDetails,
|
getTMDBMovieDetails,
|
||||||
getTMDBTVDetails,
|
getTMDBTVDetails,
|
||||||
searchTMDBMulti,
|
getTMDBImageUrl,
|
||||||
} from '@/lib/tmdb.client';
|
} from '@/lib/tmdb.client';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
// 服务器端缓存(内存)
|
// 服务器端缓存(内存)
|
||||||
const searchCache = new Map<
|
const searchCache = new Map<
|
||||||
@@ -62,7 +61,6 @@ export async function GET(request: NextRequest) {
|
|||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
||||||
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
||||||
const tmdbReverseProxy = config.SiteConfig.TMDBReverseProxy;
|
|
||||||
|
|
||||||
if (!tmdbApiKey) {
|
if (!tmdbApiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -96,8 +94,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const searchResult = await searchTMDBMulti(
|
const searchResult = await searchTMDBMulti(
|
||||||
tmdbApiKey,
|
tmdbApiKey,
|
||||||
cleanedTitle,
|
cleanedTitle,
|
||||||
tmdbProxy,
|
tmdbProxy
|
||||||
tmdbReverseProxy
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (searchResult.code !== 200 || !searchResult.results.length) {
|
if (searchResult.code !== 200 || !searchResult.results.length) {
|
||||||
@@ -137,9 +134,9 @@ export async function GET(request: NextRequest) {
|
|||||||
// 获取详情
|
// 获取详情
|
||||||
let detailsResult;
|
let detailsResult;
|
||||||
if (mediaType === 'movie') {
|
if (mediaType === 'movie') {
|
||||||
detailsResult = await getTMDBMovieDetails(tmdbApiKey, tmdbId, tmdbProxy, tmdbReverseProxy);
|
detailsResult = await getTMDBMovieDetails(tmdbApiKey, tmdbId, tmdbProxy);
|
||||||
} else {
|
} else {
|
||||||
detailsResult = await getTMDBTVDetails(tmdbApiKey, tmdbId, tmdbProxy, tmdbReverseProxy);
|
detailsResult = await getTMDBTVDetails(tmdbApiKey, tmdbId, tmdbProxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detailsResult.code !== 200 || !detailsResult.details) {
|
if (detailsResult.code !== 200 || !detailsResult.details) {
|
||||||
@@ -165,7 +162,6 @@ export async function GET(request: NextRequest) {
|
|||||||
overview: details.overview || '',
|
overview: details.overview || '',
|
||||||
rating: details.vote_average ? details.vote_average.toFixed(1) : '',
|
rating: details.vote_average ? details.vote_average.toFixed(1) : '',
|
||||||
releaseDate: details.release_date || details.first_air_date || '',
|
releaseDate: details.release_date || details.first_air_date || '',
|
||||||
genres: details.genres || [], // 添加类型标签
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(responseData, {
|
return NextResponse.json(responseData, {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
import {
|
import {
|
||||||
getTMDBImageUrl,
|
searchTMDBMulti,
|
||||||
getTMDBMovieRecommendations,
|
getTMDBMovieRecommendations,
|
||||||
getTMDBTVRecommendations,
|
getTMDBTVRecommendations,
|
||||||
searchTMDBMulti,
|
getTMDBImageUrl,
|
||||||
} from '@/lib/tmdb.client';
|
} from '@/lib/tmdb.client';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
// 服务器端缓存(1天)
|
// 服务器端缓存(1天)
|
||||||
const searchCache = new Map<string, { data: any; timestamp: number }>();
|
const searchCache = new Map<string, { data: any; timestamp: number }>();
|
||||||
@@ -63,7 +62,6 @@ export async function GET(request: NextRequest) {
|
|||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
||||||
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
||||||
const tmdbReverseProxy = config.SiteConfig.TMDBReverseProxy;
|
|
||||||
|
|
||||||
if (!tmdbApiKey) {
|
if (!tmdbApiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -92,7 +90,7 @@ export async function GET(request: NextRequest) {
|
|||||||
mediaType = cached.data.mediaType;
|
mediaType = cached.data.mediaType;
|
||||||
} else {
|
} else {
|
||||||
// 搜索TMDB
|
// 搜索TMDB
|
||||||
const searchResult = await searchTMDBMulti(tmdbApiKey, cleanedTitle, tmdbProxy, tmdbReverseProxy);
|
const searchResult = await searchTMDBMulti(tmdbApiKey, cleanedTitle, tmdbProxy);
|
||||||
|
|
||||||
if (searchResult.code !== 200 || !searchResult.results.length) {
|
if (searchResult.code !== 200 || !searchResult.results.length) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -147,8 +145,8 @@ export async function GET(request: NextRequest) {
|
|||||||
// 获取推荐
|
// 获取推荐
|
||||||
const recommendationsResult =
|
const recommendationsResult =
|
||||||
mediaType === 'movie'
|
mediaType === 'movie'
|
||||||
? await getTMDBMovieRecommendations(tmdbApiKey, tmdbId, tmdbProxy, tmdbReverseProxy)
|
? await getTMDBMovieRecommendations(tmdbApiKey, tmdbId, tmdbProxy)
|
||||||
: await getTMDBTVRecommendations(tmdbApiKey, tmdbId, tmdbProxy, tmdbReverseProxy);
|
: await getTMDBTVRecommendations(tmdbApiKey, tmdbId, tmdbProxy);
|
||||||
|
|
||||||
if (recommendationsResult.code !== 200) {
|
if (recommendationsResult.code !== 200) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||||
|
|
||||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import nodeFetch from 'node-fetch';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { getNextApiKey } from '@/lib/tmdb.client';
|
import { getNextApiKey } from '@/lib/tmdb.client';
|
||||||
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
|
import nodeFetch from 'node-fetch';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -32,7 +32,6 @@ export async function GET(request: NextRequest) {
|
|||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
||||||
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
||||||
const tmdbReverseProxy = config.SiteConfig.TMDBReverseProxy;
|
|
||||||
|
|
||||||
const actualKey = getNextApiKey(tmdbApiKey || '');
|
const actualKey = getNextApiKey(tmdbApiKey || '');
|
||||||
if (!actualKey) {
|
if (!actualKey) {
|
||||||
@@ -42,11 +41,9 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用反代代理或默认 Base URL
|
|
||||||
const baseUrl = tmdbReverseProxy || 'https://api.themoviedb.org';
|
|
||||||
// 根据类型选择API端点
|
// 根据类型选择API端点
|
||||||
const endpoint = type === 'movie' ? 'movie' : 'tv';
|
const endpoint = type === 'movie' ? 'movie' : 'tv';
|
||||||
const url = `${baseUrl}/3/${endpoint}/${id}?api_key=${actualKey}&language=zh-CN&append_to_response=credits`;
|
const url = `https://api.themoviedb.org/3/${endpoint}/${id}?api_key=${actualKey}&language=zh-CN&append_to_response=credits`;
|
||||||
|
|
||||||
const fetchOptions: any = tmdbProxy
|
const fetchOptions: any = tmdbProxy
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
|
||||||
|
|
||||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import nodeFetch from 'node-fetch';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
import { getNextApiKey } from '@/lib/tmdb.client';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/tmdb/episodes?id=xxx&season=xxx
|
|
||||||
* 获取电视剧季度的集数详情(带图片)
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const id = searchParams.get('id');
|
|
||||||
const season = searchParams.get('season');
|
|
||||||
|
|
||||||
if (!id || !season) {
|
|
||||||
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = await getConfig();
|
|
||||||
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
|
||||||
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
|
||||||
const tmdbReverseProxy = config.SiteConfig.TMDBReverseProxy;
|
|
||||||
|
|
||||||
if (!tmdbApiKey) {
|
|
||||||
return NextResponse.json({ error: 'TMDB API Key 未配置' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const actualKey = getNextApiKey(tmdbApiKey);
|
|
||||||
if (!actualKey) {
|
|
||||||
return NextResponse.json({ error: 'TMDB API Key 无效' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用反代代理或默认 Base URL
|
|
||||||
const baseUrl = tmdbReverseProxy || 'https://api.themoviedb.org';
|
|
||||||
const url = `${baseUrl}/3/tv/${id}/season/${season}?api_key=${actualKey}&language=zh-CN`;
|
|
||||||
|
|
||||||
const fetchOptions: any = tmdbProxy
|
|
||||||
? {
|
|
||||||
agent: new HttpsProxyAgent(tmdbProxy, {
|
|
||||||
timeout: 30000,
|
|
||||||
keepAlive: false,
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(30000),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
signal: AbortSignal.timeout(15000),
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await nodeFetch(url, fetchOptions);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return NextResponse.json({ error: '获取失败' }, { status: response.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: any = await response.json();
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取集数详情失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '获取失败', details: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||||
|
|
||||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import nodeFetch from 'node-fetch';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { getNextApiKey } from '@/lib/tmdb.client';
|
import { getNextApiKey } from '@/lib/tmdb.client';
|
||||||
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
|
import nodeFetch from 'node-fetch';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -31,7 +31,6 @@ export async function GET(request: NextRequest) {
|
|||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
||||||
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
||||||
const tmdbReverseProxy = config.SiteConfig.TMDBReverseProxy;
|
|
||||||
|
|
||||||
const actualKey = getNextApiKey(tmdbApiKey || '');
|
const actualKey = getNextApiKey(tmdbApiKey || '');
|
||||||
if (!actualKey) {
|
if (!actualKey) {
|
||||||
@@ -41,10 +40,8 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用反代代理或默认 Base URL
|
|
||||||
const baseUrl = tmdbReverseProxy || 'https://api.themoviedb.org';
|
|
||||||
// 使用 multi search 同时搜索电影和电视剧
|
// 使用 multi search 同时搜索电影和电视剧
|
||||||
const url = `${baseUrl}/3/search/multi?api_key=${actualKey}&language=zh-CN&query=${encodeURIComponent(query)}&page=1`;
|
const url = `https://api.themoviedb.org/3/search/multi?api_key=${actualKey}&language=zh-CN&query=${encodeURIComponent(query)}&page=1`;
|
||||||
|
|
||||||
const fetchOptions: any = tmdbProxy
|
const fetchOptions: any = tmdbProxy
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export async function GET(request: NextRequest) {
|
|||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
||||||
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
||||||
const tmdbReverseProxy = config.SiteConfig.TMDBReverseProxy;
|
|
||||||
|
|
||||||
if (!tmdbApiKey) {
|
if (!tmdbApiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -43,7 +42,7 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await getTVSeasons(tmdbApiKey, tvId, tmdbProxy, tmdbReverseProxy);
|
const result = await getTVSeasons(tmdbApiKey, tvId, tmdbProxy);
|
||||||
|
|
||||||
if (result.code === 200 && result.seasons) {
|
if (result.code === 200 && result.seasons) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getTMDBTrendingContent } from '@/lib/tmdb.client';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { fetchDoubanData } from '@/lib/douban';
|
|
||||||
import { getTMDBTrendingContent, getTMDBVideos } from '@/lib/tmdb.client';
|
|
||||||
|
|
||||||
// 缓存配置 - 服务器内存缓存3小时
|
// 缓存配置 - 服务器内存缓存3小时
|
||||||
const CACHE_DURATION = 3 * 60 * 60 * 1000; // 3小时
|
const CACHE_DURATION = 3 * 60 * 60 * 1000; // 3小时
|
||||||
@@ -12,7 +10,6 @@ const CACHE_DURATION = 3 * 60 * 60 * 1000; // 3小时
|
|||||||
// 为不同数据源分别维护缓存
|
// 为不同数据源分别维护缓存
|
||||||
let tmdbCache: { data: any; timestamp: number } | null = null;
|
let tmdbCache: { data: any; timestamp: number } | null = null;
|
||||||
let txCache: { data: any; timestamp: number } | null = null;
|
let txCache: { data: any; timestamp: number } | null = null;
|
||||||
let doubanCache: { data: any; timestamp: number } | null = null;
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -20,10 +17,10 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
// 获取配置
|
// 获取配置
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const bannerDataSource = config.SiteConfig?.BannerDataSource || 'Douban';
|
const bannerDataSource = config.SiteConfig?.BannerDataSource || 'TMDB';
|
||||||
|
|
||||||
// 根据数据源选择对应的缓存
|
// 根据数据源选择对应的缓存
|
||||||
const cache = bannerDataSource === 'TX' ? txCache : bannerDataSource === 'Douban' ? doubanCache : tmdbCache;
|
const cache = bannerDataSource === 'TX' ? txCache : tmdbCache;
|
||||||
|
|
||||||
// 检查缓存
|
// 检查缓存
|
||||||
if (cache && Date.now() - cache.timestamp < CACHE_DURATION) {
|
if (cache && Date.now() - cache.timestamp < CACHE_DURATION) {
|
||||||
@@ -33,17 +30,7 @@ export async function GET() {
|
|||||||
let result: any;
|
let result: any;
|
||||||
|
|
||||||
// 根据配置的数据源获取数据
|
// 根据配置的数据源获取数据
|
||||||
if (bannerDataSource === 'Douban') {
|
if (bannerDataSource === 'TX') {
|
||||||
// 使用豆瓣数据源
|
|
||||||
result = await getDoubanBannerContent();
|
|
||||||
// 添加数据源标识
|
|
||||||
result.source = 'Douban';
|
|
||||||
// 更新豆瓣缓存
|
|
||||||
doubanCache = {
|
|
||||||
data: result,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
} else if (bannerDataSource === 'TX') {
|
|
||||||
// 使用TX数据源
|
// 使用TX数据源
|
||||||
result = await getTXBannerContent();
|
result = await getTXBannerContent();
|
||||||
// 添加数据源标识
|
// 添加数据源标识
|
||||||
@@ -57,7 +44,6 @@ export async function GET() {
|
|||||||
// 使用TMDB数据源(默认)
|
// 使用TMDB数据源(默认)
|
||||||
const apiKey = config.SiteConfig?.TMDBApiKey;
|
const apiKey = config.SiteConfig?.TMDBApiKey;
|
||||||
const proxy = config.SiteConfig?.TMDBProxy;
|
const proxy = config.SiteConfig?.TMDBProxy;
|
||||||
const reverseProxy = config.SiteConfig?.TMDBReverseProxy;
|
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -67,19 +53,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取热门内容
|
// 获取热门内容
|
||||||
result = await getTMDBTrendingContent(apiKey, proxy, reverseProxy);
|
result = await getTMDBTrendingContent(apiKey, proxy);
|
||||||
|
|
||||||
// 为每个项目获取视频数据
|
|
||||||
if (result.code === 200 && result.list) {
|
|
||||||
const itemsWithVideos = await Promise.all(
|
|
||||||
result.list.map(async (item: any) => {
|
|
||||||
const videoKey = await getTMDBVideos(apiKey, item.media_type, item.id, proxy, reverseProxy);
|
|
||||||
return { ...item, video_key: videoKey };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
result.list = itemsWithVideos;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加数据源标识
|
// 添加数据源标识
|
||||||
result.source = 'TMDB';
|
result.source = 'TMDB';
|
||||||
// 更新TMDB缓存
|
// 更新TMDB缓存
|
||||||
@@ -248,151 +222,3 @@ function parseTXBannerData(data: any): any[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取豆瓣轮播图内容
|
|
||||||
*/
|
|
||||||
async function getDoubanBannerContent(): Promise<{ code: number; list: any[] }> {
|
|
||||||
try {
|
|
||||||
// 获取豆瓣热门电影
|
|
||||||
const hotMoviesUrl = 'https://m.douban.com/rexxar/api/v2/subject/recent_hot/movie?start=0&limit=10&category=热门&type=全部';
|
|
||||||
|
|
||||||
interface DoubanHotMovie {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
card_subtitle?: string;
|
|
||||||
pic?: {
|
|
||||||
large: string;
|
|
||||||
normal: string;
|
|
||||||
};
|
|
||||||
rating?: {
|
|
||||||
value: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DoubanHotMoviesResponse {
|
|
||||||
items: DoubanHotMovie[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const hotMoviesData = await fetchDoubanData<DoubanHotMoviesResponse>(hotMoviesUrl);
|
|
||||||
|
|
||||||
if (!hotMoviesData.items || hotMoviesData.items.length === 0) {
|
|
||||||
return { code: 200, list: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取前5个电影
|
|
||||||
const topMovies = hotMoviesData.items.slice(0, 5);
|
|
||||||
|
|
||||||
// 为每个电影获取详情信息
|
|
||||||
const bannerItems = await Promise.all(
|
|
||||||
topMovies.map(async (movie) => {
|
|
||||||
try {
|
|
||||||
const detailUrl = `https://m.douban.com/rexxar/api/v2/subject/${movie.id}`;
|
|
||||||
|
|
||||||
interface DoubanDetailResponse {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
original_title?: string;
|
|
||||||
year: string;
|
|
||||||
rating?: {
|
|
||||||
value: number;
|
|
||||||
};
|
|
||||||
intro?: string;
|
|
||||||
genres?: string[];
|
|
||||||
cover_url?: string;
|
|
||||||
trailers?: Array<{
|
|
||||||
video_url?: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}>;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const detail = await fetchDoubanData<DoubanDetailResponse>(detailUrl);
|
|
||||||
|
|
||||||
// 获取预告片链接(取第一个)- 豆瓣是直链视频URL
|
|
||||||
const trailerUrl = detail.trailers && detail.trailers.length > 0
|
|
||||||
? detail.trailers[0].video_url
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// 获取横屏图片
|
|
||||||
const backdropPath = detail.cover_url || movie.pic?.large || movie.pic?.normal || '';
|
|
||||||
|
|
||||||
// 提取年份
|
|
||||||
const year = detail.year || movie.card_subtitle?.match(/(\d{4})/)?.[1] || '';
|
|
||||||
|
|
||||||
// 从card_subtitle提取标签(只读取第二个部分,通过空格分割)
|
|
||||||
let tags: string[] = [];
|
|
||||||
if (movie.card_subtitle) {
|
|
||||||
const parts = movie.card_subtitle.split('/').map(s => s.trim());
|
|
||||||
// 过滤掉年份(纯数字)和空字符串
|
|
||||||
const filteredParts = parts.filter(part =>
|
|
||||||
part && !/^\d{4}$/.test(part)
|
|
||||||
);
|
|
||||||
// 取第二个部分(类型),通过空格分割
|
|
||||||
if (filteredParts.length >= 2) {
|
|
||||||
tags = filteredParts[1].split(/\s+/).filter(t => t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: movie.id,
|
|
||||||
title: detail.title,
|
|
||||||
backdrop_path: backdropPath,
|
|
||||||
poster_path: backdropPath,
|
|
||||||
release_date: year,
|
|
||||||
overview: detail.intro || '',
|
|
||||||
vote_average: detail.rating?.value || movie.rating?.value || 0,
|
|
||||||
media_type: 'movie',
|
|
||||||
genre_ids: [],
|
|
||||||
genres: tags, // 使用从card_subtitle提取的标签
|
|
||||||
trailer_url: trailerUrl, // 豆瓣预告片直链
|
|
||||||
video_key: null, // 豆瓣不使用YouTube key
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`获取豆瓣电影 ${movie.id} 详情失败:`, error);
|
|
||||||
|
|
||||||
// 从card_subtitle提取标签(只读取第二个部分,通过空格分割)
|
|
||||||
let tags: string[] = [];
|
|
||||||
if (movie.card_subtitle) {
|
|
||||||
const parts = movie.card_subtitle.split('/').map(s => s.trim());
|
|
||||||
// 过滤掉年份(纯数字)和空字符串
|
|
||||||
const filteredParts = parts.filter(part =>
|
|
||||||
part && !/^\d{4}$/.test(part)
|
|
||||||
);
|
|
||||||
// 取第二个部分(类型),通过空格分割
|
|
||||||
if (filteredParts.length >= 2) {
|
|
||||||
tags = filteredParts[1].split(/\s+/).filter(t => t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果获取详情失败,使用基本信息
|
|
||||||
return {
|
|
||||||
id: movie.id,
|
|
||||||
title: movie.title,
|
|
||||||
backdrop_path: movie.pic?.large || movie.pic?.normal || '',
|
|
||||||
poster_path: movie.pic?.large || movie.pic?.normal || '',
|
|
||||||
release_date: movie.card_subtitle?.match(/(\d{4})/)?.[1] || '',
|
|
||||||
overview: '',
|
|
||||||
vote_average: movie.rating?.value || 0,
|
|
||||||
media_type: 'movie',
|
|
||||||
genre_ids: [],
|
|
||||||
genres: tags, // 使用从card_subtitle提取的标签
|
|
||||||
trailer_url: null,
|
|
||||||
video_key: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// 过滤掉没有图片的项目
|
|
||||||
const validBannerItems = bannerItems.filter(item => item.backdrop_path);
|
|
||||||
|
|
||||||
return {
|
|
||||||
code: 200,
|
|
||||||
list: validBannerItems,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取豆瓣轮播图数据失败:', error);
|
|
||||||
return { code: 500, list: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
import { getTMDBUpcomingContent } from '@/lib/tmdb.client';
|
import { getTMDBUpcomingContent } from '@/lib/tmdb.client';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
// 内存缓存对象
|
// 内存缓存对象
|
||||||
interface CacheItem {
|
interface CacheItem {
|
||||||
@@ -29,7 +28,6 @@ export async function GET(request: NextRequest) {
|
|||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const tmdbApiKey = config.SiteConfig?.TMDBApiKey;
|
const tmdbApiKey = config.SiteConfig?.TMDBApiKey;
|
||||||
const tmdbProxy = config.SiteConfig?.TMDBProxy;
|
const tmdbProxy = config.SiteConfig?.TMDBProxy;
|
||||||
const tmdbReverseProxy = config.SiteConfig?.TMDBReverseProxy;
|
|
||||||
|
|
||||||
if (!tmdbApiKey) {
|
if (!tmdbApiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -39,7 +37,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 调用TMDB API获取数据
|
// 调用TMDB API获取数据
|
||||||
const result = await getTMDBUpcomingContent(tmdbApiKey, tmdbProxy, tmdbReverseProxy);
|
const result = await getTMDBUpcomingContent(tmdbApiKey, tmdbProxy);
|
||||||
|
|
||||||
if (result.code !== 200) {
|
if (result.code !== 200) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -71,10 +71,6 @@ export async function GET(request: NextRequest) {
|
|||||||
config.OpenListConfig?.Password
|
config.OpenListConfig?.Password
|
||||||
);
|
);
|
||||||
|
|
||||||
// 获取所有启用的 Emby 源
|
|
||||||
const { embyManager } = await import('@/lib/emby-manager');
|
|
||||||
const embySources = await embyManager.getEnabledSources();
|
|
||||||
|
|
||||||
// 构建 OpenList 站点配置
|
// 构建 OpenList 站点配置
|
||||||
const openlistSites = hasOpenList ? [{
|
const openlistSites = hasOpenList ? [{
|
||||||
key: 'openlist',
|
key: 'openlist',
|
||||||
@@ -87,18 +83,6 @@ export async function GET(request: NextRequest) {
|
|||||||
ext: '',
|
ext: '',
|
||||||
}] : [];
|
}] : [];
|
||||||
|
|
||||||
// 构建 Emby 站点配置(为每个启用的Emby源生成独立站点)
|
|
||||||
const embySites = embySources.map(source => ({
|
|
||||||
key: `emby_${source.key}`,
|
|
||||||
name: source.name || 'Emby媒体库',
|
|
||||||
type: 1,
|
|
||||||
api: `${baseUrl}/api/emby/cms-proxy/${encodeURIComponent(subscribeToken)}?embyKey=${source.key}`,
|
|
||||||
searchable: 1,
|
|
||||||
quickSearch: 1,
|
|
||||||
filterable: 1,
|
|
||||||
ext: '',
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 构建TVBOX订阅数据
|
// 构建TVBOX订阅数据
|
||||||
const tvboxSubscription = {
|
const tvboxSubscription = {
|
||||||
// 站点配置
|
// 站点配置
|
||||||
@@ -106,10 +90,9 @@ export async function GET(request: NextRequest) {
|
|||||||
wallpaper: '',
|
wallpaper: '',
|
||||||
|
|
||||||
// 视频源站点 - 根据 adFilter 参数决定是否使用代理
|
// 视频源站点 - 根据 adFilter 参数决定是否使用代理
|
||||||
// OpenList 和 Emby 源放在最前面
|
// OpenList 源放在最前面
|
||||||
sites: [
|
sites: [
|
||||||
...openlistSites,
|
...openlistSites,
|
||||||
...embySites,
|
|
||||||
...apiSites.map(site => ({
|
...apiSites.map(site => ({
|
||||||
key: site.key,
|
key: site.key,
|
||||||
name: site.name,
|
name: site.name,
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getStorage } from '@/lib/db';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - 获取用户邮箱设置
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storage = getStorage();
|
|
||||||
const username = authInfo.username;
|
|
||||||
|
|
||||||
const email = storage.getUserEmail
|
|
||||||
? await storage.getUserEmail(username)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const emailNotifications = storage.getEmailNotificationPreference
|
|
||||||
? await storage.getEmailNotificationPreference(username)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
email: email || '',
|
|
||||||
emailNotifications,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取用户邮箱设置失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST - 保存用户邮箱设置
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storage = getStorage();
|
|
||||||
const username = authInfo.username;
|
|
||||||
const body = await request.json();
|
|
||||||
const { email, emailNotifications } = body;
|
|
||||||
|
|
||||||
// 验证邮箱格式
|
|
||||||
if (email && typeof email === 'string') {
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(email)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '邮箱格式不正确' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storage.setUserEmail) {
|
|
||||||
await storage.setUserEmail(username, email);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存邮件通知偏好
|
|
||||||
if (typeof emailNotifications === 'boolean') {
|
|
||||||
if (storage.setEmailNotificationPreference) {
|
|
||||||
await storage.setEmailNotificationPreference(username, emailNotifications);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: '邮箱设置保存成功',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('保存用户邮箱设置失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
// 视频代理接口,支持Range请求
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const videoUrl = searchParams.get('url');
|
|
||||||
|
|
||||||
if (!videoUrl) {
|
|
||||||
return NextResponse.json({ error: 'Missing video URL' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 获取客户端的Range请求头
|
|
||||||
const range = request.headers.get('range');
|
|
||||||
|
|
||||||
const fetchHeaders: HeadersInit = {
|
|
||||||
'User-Agent':
|
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
|
||||||
Accept: 'video/mp4,video/*;q=0.9,*/*;q=0.8',
|
|
||||||
Referer: 'https://movie.douban.com/',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 如果客户端发送了Range请求,转发给源服务器
|
|
||||||
if (range) {
|
|
||||||
fetchHeaders['Range'] = range;
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoResponse = await fetch(videoUrl, {
|
|
||||||
headers: fetchHeaders,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!videoResponse.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: videoResponse.statusText },
|
|
||||||
{ status: videoResponse.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!videoResponse.body) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Video response has no body' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建响应头
|
|
||||||
const headers = new Headers();
|
|
||||||
|
|
||||||
// 复制重要的响应头
|
|
||||||
const contentType = videoResponse.headers.get('content-type');
|
|
||||||
if (contentType) {
|
|
||||||
headers.set('Content-Type', contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = videoResponse.headers.get('content-length');
|
|
||||||
if (contentLength) {
|
|
||||||
headers.set('Content-Length', contentLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentRange = videoResponse.headers.get('content-range');
|
|
||||||
if (contentRange) {
|
|
||||||
headers.set('Content-Range', contentRange);
|
|
||||||
}
|
|
||||||
|
|
||||||
const acceptRanges = videoResponse.headers.get('accept-ranges');
|
|
||||||
if (acceptRanges) {
|
|
||||||
headers.set('Accept-Ranges', acceptRanges);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置缓存头
|
|
||||||
headers.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000'); // 缓存1年
|
|
||||||
headers.set('CDN-Cache-Control', 'public, s-maxage=31536000');
|
|
||||||
headers.set('Vercel-CDN-Cache-Control', 'public, s-maxage=31536000');
|
|
||||||
|
|
||||||
// 返回视频流,状态码根据是否有Range请求决定
|
|
||||||
const status = range && contentRange ? 206 : 200;
|
|
||||||
|
|
||||||
return new Response(videoResponse.body, {
|
|
||||||
status,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error proxying video:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Error fetching video' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
function getBaseUrl(url: string): string {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
const pathParts = urlObj.pathname.split('/');
|
|
||||||
pathParts.pop();
|
|
||||||
return `${urlObj.protocol}//${urlObj.host}${pathParts.join('/')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function processM3u8Content(content: string, baseUrl: string): string {
|
|
||||||
const lines = content.split('\n');
|
|
||||||
const processedLines = lines.map(line => {
|
|
||||||
const trimmedLine = line.trim();
|
|
||||||
|
|
||||||
// 跳过注释行和空行
|
|
||||||
if (trimmedLine.startsWith('#') || trimmedLine === '') {
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果已经是完整URL,不处理
|
|
||||||
if (trimmedLine.startsWith('http://') || trimmedLine.startsWith('https://')) {
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理相对路径
|
|
||||||
if (trimmedLine.startsWith('/')) {
|
|
||||||
// 绝对路径(相对于域名)
|
|
||||||
const urlObj = new URL(baseUrl);
|
|
||||||
return `${urlObj.protocol}//${urlObj.host}${trimmedLine}`;
|
|
||||||
} else {
|
|
||||||
// 相对路径
|
|
||||||
return `${baseUrl}/${trimmedLine}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return processedLines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const url = searchParams.get('url');
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return NextResponse.json({ error: '缺少URL参数' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据URL判断Referer
|
|
||||||
let referer = 'https://www.huya.com/';
|
|
||||||
if (url.includes('bilivideo.com') || url.includes('bilibili.com')) {
|
|
||||||
referer = 'https://live.bilibili.com/';
|
|
||||||
} else if (url.includes('douyin.com') || url.includes('douyincdn.com')) {
|
|
||||||
referer = 'https://live.douyin.com/';
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamRes = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
||||||
'Referer': referer
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!streamRes.ok) {
|
|
||||||
return NextResponse.json({ error: '无法获取直播流' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = streamRes.headers.get('Content-Type') || '';
|
|
||||||
|
|
||||||
// 检测是否为m3u8文件
|
|
||||||
const isM3u8 = url.endsWith('.m3u8') ||
|
|
||||||
contentType.includes('application/vnd.apple.mpegurl') ||
|
|
||||||
contentType.includes('application/x-mpegURL');
|
|
||||||
|
|
||||||
if (isM3u8) {
|
|
||||||
// 读取m3u8内容
|
|
||||||
const content = await streamRes.text();
|
|
||||||
const baseUrl = getBaseUrl(url);
|
|
||||||
const processedContent = processM3u8Content(content, baseUrl);
|
|
||||||
|
|
||||||
return new NextResponse(processedContent, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Access-Control-Allow-Origin': '*'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 非m3u8文件,直接返回流
|
|
||||||
return new NextResponse(streamRes.body, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': contentType || 'application/octet-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Access-Control-Allow-Origin': '*'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error instanceof Error ? error.message : '代理失败' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'; // 禁用缓存
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const config = await getConfig();
|
|
||||||
if (!config?.WebLiveConfig) {
|
|
||||||
return NextResponse.json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sources = config.WebLiveConfig.filter(s => !s.disabled);
|
|
||||||
return NextResponse.json(sources);
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error instanceof Error ? error.message : '获取失败' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
import crypto from 'crypto';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
function getAntiCode(oldAntiCode: string, streamName: string): string {
|
|
||||||
const paramsT = 100;
|
|
||||||
const sdkVersion = 2403051612;
|
|
||||||
const t13 = Date.now();
|
|
||||||
const sdkSid = t13;
|
|
||||||
const initUuid = (Math.floor(t13 % 10000000000 * 1000) + Math.floor(1000 * Math.random())) % 4294967295;
|
|
||||||
const uid = Math.floor(Math.random() * (1400009999999 - 1400000000000 + 1)) + 1400000000000;
|
|
||||||
const seqId = uid + sdkSid;
|
|
||||||
const targetUnixTime = Math.floor((t13 + 110624) / 1000);
|
|
||||||
const wsTime = targetUnixTime.toString(16).toLowerCase();
|
|
||||||
|
|
||||||
const urlQuery = new URLSearchParams(oldAntiCode);
|
|
||||||
const fm = urlQuery.get('fm');
|
|
||||||
if (!fm) return oldAntiCode;
|
|
||||||
|
|
||||||
const wsSecretPf = Buffer.from(decodeURIComponent(fm), 'base64').toString().split('_')[0];
|
|
||||||
const wsSecretHash = crypto.createHash('md5').update(`${seqId}|${urlQuery.get('ctype')}|${paramsT}`).digest('hex');
|
|
||||||
const wsSecret = `${wsSecretPf}_${uid}_${streamName}_${wsSecretHash}_${wsTime}`;
|
|
||||||
const wsSecretMd5 = crypto.createHash('md5').update(wsSecret).digest('hex');
|
|
||||||
|
|
||||||
return `wsSecret=${wsSecretMd5}&wsTime=${wsTime}&seqid=${seqId}&ctype=${urlQuery.get('ctype')}&ver=1&fs=${urlQuery.get('fs')}&uuid=${initUuid}&u=${uid}&t=${paramsT}&sv=${sdkVersion}&sdk_sid=${sdkSid}&codec=264`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getBilibiliStream(roomId: string) {
|
|
||||||
const headers = {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
||||||
'Referer': 'https://live.bilibili.com/'
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取房间初始化信息
|
|
||||||
const roomInitRes = await fetch(`https://api.live.bilibili.com/room/v1/Room/room_init?id=${roomId}`, {
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
const roomInitData = await roomInitRes.json();
|
|
||||||
|
|
||||||
if (roomInitData.code !== 0) {
|
|
||||||
throw new Error(roomInitData.message || '获取房间信息失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomData = roomInitData.data;
|
|
||||||
const realRoomId = roomData.room_id;
|
|
||||||
const liveStatus = roomData.live_status; // 0=未开播, 1=直播中, 2=轮播
|
|
||||||
const uid = roomData.uid;
|
|
||||||
|
|
||||||
if (liveStatus !== 1) {
|
|
||||||
throw new Error('直播未开启');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取主播信息
|
|
||||||
let ownerName = '';
|
|
||||||
try {
|
|
||||||
const userRes = await fetch(`https://api.live.bilibili.com/live_user/v1/Master/info?uid=${uid}`, {
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
const userData = await userRes.json();
|
|
||||||
if (userData.code === 0) {
|
|
||||||
ownerName = userData.data?.info?.uname || '';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('获取主播信息失败:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取房间详细信息(包含标题)
|
|
||||||
let title = '';
|
|
||||||
try {
|
|
||||||
const roomInfoRes = await fetch(`https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${realRoomId}`, {
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
const roomInfoData = await roomInfoRes.json();
|
|
||||||
if (roomInfoData.code === 0) {
|
|
||||||
title = roomInfoData.data?.title || '';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('获取房间标题失败:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取播放地址 (原画质量 qn=10000)
|
|
||||||
const playInfoRes = await fetch(
|
|
||||||
`https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?room_id=${realRoomId}&protocol=0,1&format=0,1,2&codec=0,1&qn=10000&platform=web&ptype=8`,
|
|
||||||
{ headers }
|
|
||||||
);
|
|
||||||
const playInfoData = await playInfoRes.json();
|
|
||||||
|
|
||||||
if (playInfoData.code !== 0) {
|
|
||||||
throw new Error(playInfoData.message || '获取播放信息失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
const playurl = playInfoData.data?.playurl_info?.playurl;
|
|
||||||
if (!playurl) {
|
|
||||||
throw new Error('未找到播放地址');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取m3u8地址
|
|
||||||
let m3u8Url = '';
|
|
||||||
const streamList = playurl.stream || [];
|
|
||||||
|
|
||||||
for (const stream of streamList) {
|
|
||||||
const formatList = stream.format || [];
|
|
||||||
for (const fmt of formatList) {
|
|
||||||
if (fmt.format_name === 'ts') {
|
|
||||||
const codecList = fmt.codec || [];
|
|
||||||
for (const codec of codecList) {
|
|
||||||
const urlInfoList = codec.url_info || [];
|
|
||||||
const baseUrl = codec.base_url || '';
|
|
||||||
|
|
||||||
if (urlInfoList.length > 0 && baseUrl) {
|
|
||||||
const host = urlInfoList[0].host || '';
|
|
||||||
const extra = urlInfoList[0].extra || '';
|
|
||||||
m3u8Url = `${host}${baseUrl}${extra}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (m3u8Url) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (m3u8Url) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!m3u8Url) {
|
|
||||||
throw new Error('未找到m3u8地址');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
url: m3u8Url,
|
|
||||||
name: ownerName,
|
|
||||||
title: title
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getDouyinStream(roomId: string) {
|
|
||||||
const cookies = 'ttwid=1%7C2iDIYVmjzMcpZ20fcaFde0VghXAA3NaNXE_SLR68IyE%7C1761045455%7Cab35197d5cfb21df6cbb2fa7ef1c9262206b062c315b9d04da746d0b37dfbc7d';
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.97 Safari/537.36',
|
|
||||||
'Referer': 'https://live.douyin.com/',
|
|
||||||
'Cookie': cookies
|
|
||||||
};
|
|
||||||
|
|
||||||
// 构建API参数
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
aid: '6383',
|
|
||||||
app_name: 'douyin_web',
|
|
||||||
live_id: '1',
|
|
||||||
device_platform: 'web',
|
|
||||||
language: 'zh-CN',
|
|
||||||
browser_language: 'zh-CN',
|
|
||||||
browser_platform: 'Win32',
|
|
||||||
browser_name: 'Chrome',
|
|
||||||
browser_version: '116.0.0.0',
|
|
||||||
web_rid: roomId,
|
|
||||||
msToken: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const apiUrl = `https://live.douyin.com/webcast/room/web/enter/?${params.toString()}`;
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl, { headers });
|
|
||||||
const jsonData = await response.json();
|
|
||||||
|
|
||||||
if (!jsonData.data || !jsonData.data.data) {
|
|
||||||
throw new Error('获取直播间信息失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomData = jsonData.data.data[0];
|
|
||||||
const status = roomData.status;
|
|
||||||
|
|
||||||
if (status !== 2) {
|
|
||||||
throw new Error('直播未开启');
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamUrl = roomData.stream_url;
|
|
||||||
if (!streamUrl) {
|
|
||||||
throw new Error('未找到流地址');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取m3u8地址
|
|
||||||
const hlsPullUrlMap = streamUrl.hls_pull_url_map;
|
|
||||||
if (!hlsPullUrlMap) {
|
|
||||||
throw new Error('未找到m3u8地址');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试获取原画质,如果没有则获取第一个可用的
|
|
||||||
let m3u8Url = hlsPullUrlMap.ORIGIN || hlsPullUrlMap.FULL_HD1 || hlsPullUrlMap.HD1 || hlsPullUrlMap.SD1 || hlsPullUrlMap.SD2;
|
|
||||||
|
|
||||||
if (!m3u8Url) {
|
|
||||||
// 如果上述都没有,获取第一个可用的
|
|
||||||
const urls = Object.values(hlsPullUrlMap);
|
|
||||||
if (urls.length > 0) {
|
|
||||||
m3u8Url = urls[0] as string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!m3u8Url) {
|
|
||||||
throw new Error('未找到可用的m3u8地址');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回流地址和主播信息
|
|
||||||
return {
|
|
||||||
url: m3u8Url,
|
|
||||||
name: jsonData.data.user?.nickname || '',
|
|
||||||
title: roomData.title || ''
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const platform = searchParams.get('platform');
|
|
||||||
const roomId = searchParams.get('roomId');
|
|
||||||
|
|
||||||
if (!platform || !roomId) {
|
|
||||||
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform === 'huya') {
|
|
||||||
const res = await fetch(`https://www.huya.com/${roomId}`, {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const html = await res.text();
|
|
||||||
|
|
||||||
const match = html.match(/stream:\s*(\{"data".*?),"iWebDefaultBitRate"/);
|
|
||||||
if (!match) {
|
|
||||||
return NextResponse.json({ error: '未找到直播数据' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonData = JSON.parse(match[1] + '}');
|
|
||||||
const gameLiveInfo = jsonData.data?.[0]?.gameLiveInfo;
|
|
||||||
const streamInfo = jsonData.data?.[0]?.gameStreamInfoList?.[0];
|
|
||||||
|
|
||||||
if (!streamInfo) {
|
|
||||||
return NextResponse.json({ error: '直播未开启' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { sFlvUrl, sStreamName, sFlvUrlSuffix, sFlvAntiCode } = streamInfo;
|
|
||||||
const newAntiCode = getAntiCode(sFlvAntiCode, sStreamName);
|
|
||||||
const streamUrl = `${sFlvUrl}/${sStreamName}.${sFlvUrlSuffix}?${newAntiCode}`;
|
|
||||||
const proxyUrl = `/api/web-live/proxy/proxy.flv?url=${encodeURIComponent(streamUrl)}`;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
url: proxyUrl,
|
|
||||||
originalUrl: streamUrl,
|
|
||||||
name: gameLiveInfo?.nick || '',
|
|
||||||
title: gameLiveInfo?.introduction || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform === 'bilibili') {
|
|
||||||
const streamData = await getBilibiliStream(roomId);
|
|
||||||
const proxyUrl = `/api/web-live/proxy/proxy.m3u8?url=${encodeURIComponent(streamData.url)}`;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
url: proxyUrl,
|
|
||||||
originalUrl: streamData.url,
|
|
||||||
name: streamData.name,
|
|
||||||
title: streamData.title
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform === 'douyin') {
|
|
||||||
const streamData = await getDouyinStream(roomId);
|
|
||||||
const proxyUrl = `/api/web-live/proxy/proxy.m3u8?url=${encodeURIComponent(streamData.url)}`;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
url: proxyUrl,
|
|
||||||
originalUrl: streamData.url,
|
|
||||||
name: streamData.name,
|
|
||||||
title: streamData.title
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: '不支持的平台' }, { status: 400 });
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error instanceof Error ? error.message : '获取失败' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
import { XiaoyaClient } from '@/lib/xiaoya.client';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/xiaoya/browse?path=<path>
|
|
||||||
* 浏览小雅目录
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const path = searchParams.get('path') || '/';
|
|
||||||
|
|
||||||
const config = await getConfig();
|
|
||||||
const xiaoyaConfig = config.XiaoyaConfig;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!xiaoyaConfig ||
|
|
||||||
!xiaoyaConfig.Enabled ||
|
|
||||||
!xiaoyaConfig.ServerURL
|
|
||||||
) {
|
|
||||||
return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new XiaoyaClient(
|
|
||||||
xiaoyaConfig.ServerURL,
|
|
||||||
xiaoyaConfig.Username,
|
|
||||||
xiaoyaConfig.Password,
|
|
||||||
xiaoyaConfig.Token
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await client.listDirectory(path);
|
|
||||||
|
|
||||||
// 过滤出文件夹和视频文件
|
|
||||||
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
|
|
||||||
|
|
||||||
const folders = result.content
|
|
||||||
.filter(item => item.is_dir)
|
|
||||||
.map(item => ({
|
|
||||||
name: item.name,
|
|
||||||
path: `${path}${path.endsWith('/') ? '' : '/'}${item.name}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const files = result.content
|
|
||||||
.filter(item =>
|
|
||||||
!item.is_dir &&
|
|
||||||
videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext))
|
|
||||||
)
|
|
||||||
.map(item => ({
|
|
||||||
name: item.name,
|
|
||||||
path: `${path}${path.endsWith('/') ? '' : '/'}${item.name}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
folders,
|
|
||||||
files,
|
|
||||||
currentPath: path,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
import { XiaoyaClient } from '@/lib/xiaoya.client';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 使用 HEAD 请求跟随重定向获取最终 URL(直连方法 - 降级使用)
|
|
||||||
*/
|
|
||||||
async function getFinalUrl(url: string, maxRedirects = 5): Promise<string> {
|
|
||||||
let currentUrl = url;
|
|
||||||
let redirectCount = 0;
|
|
||||||
|
|
||||||
while (redirectCount < maxRedirects) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(currentUrl, {
|
|
||||||
method: 'HEAD',
|
|
||||||
redirect: 'manual',
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status >= 300 && response.status < 400) {
|
|
||||||
const location = response.headers.get('location');
|
|
||||||
if (!location) {
|
|
||||||
return currentUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (location.startsWith('http://') || location.startsWith('https://')) {
|
|
||||||
currentUrl = location;
|
|
||||||
} else if (location.startsWith('/')) {
|
|
||||||
const urlObj = new URL(currentUrl);
|
|
||||||
currentUrl = `${urlObj.protocol}//${urlObj.host}${location}`;
|
|
||||||
} else {
|
|
||||||
const urlObj = new URL(currentUrl);
|
|
||||||
const pathParts = urlObj.pathname.split('/');
|
|
||||||
pathParts.pop();
|
|
||||||
pathParts.push(location);
|
|
||||||
currentUrl = `${urlObj.protocol}//${urlObj.host}${pathParts.join('/')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
redirectCount++;
|
|
||||||
} else {
|
|
||||||
return currentUrl;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[xiaoya/play] 获取最终 URL 失败:', error);
|
|
||||||
return currentUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/xiaoya/play?path=<path>&format=json
|
|
||||||
* 获取小雅视频的播放链接(优先使用视频预览流,失败时降级到直连)
|
|
||||||
* path参数为base58编码的路径
|
|
||||||
* format=json: 返回 JSON 格式(用于 play 页面)
|
|
||||||
* 默认: 返回重定向(用于 tvbox 等)
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const encodedPath = searchParams.get('path');
|
|
||||||
const format = searchParams.get('format'); // 新增 format 参数
|
|
||||||
|
|
||||||
if (!encodedPath) {
|
|
||||||
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 对path进行base58解码
|
|
||||||
const { base58Decode } = await import('@/lib/utils');
|
|
||||||
const path = base58Decode(encodedPath);
|
|
||||||
|
|
||||||
const config = await getConfig();
|
|
||||||
const xiaoyaConfig = config.XiaoyaConfig;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!xiaoyaConfig ||
|
|
||||||
!xiaoyaConfig.Enabled ||
|
|
||||||
!xiaoyaConfig.ServerURL
|
|
||||||
) {
|
|
||||||
return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new XiaoyaClient(
|
|
||||||
xiaoyaConfig.ServerURL,
|
|
||||||
xiaoyaConfig.Username,
|
|
||||||
xiaoyaConfig.Password,
|
|
||||||
xiaoyaConfig.Token
|
|
||||||
);
|
|
||||||
|
|
||||||
// 如果启用了禁用预览视频,直接使用直连方法
|
|
||||||
if (xiaoyaConfig.DisableVideoPreview) {
|
|
||||||
const playUrl = await client.getDownloadUrl(path);
|
|
||||||
|
|
||||||
// 如果指定了 format=json,使用 getFinalUrl 并返回 JSON
|
|
||||||
if (format === 'json') {
|
|
||||||
const finalUrl = await getFinalUrl(playUrl);
|
|
||||||
|
|
||||||
// 检查URL是否为空
|
|
||||||
if (!finalUrl || finalUrl.trim() === '') {
|
|
||||||
throw new Error('获取到的播放链接为空');
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ url: finalUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查URL是否为空
|
|
||||||
if (!playUrl || playUrl.trim() === '') {
|
|
||||||
throw new Error('获取到的播放链接为空');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 默认返回重定向(用于 tvbox)
|
|
||||||
return NextResponse.redirect(playUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 优先尝试视频预览流方法
|
|
||||||
try {
|
|
||||||
const token = await client.getToken();
|
|
||||||
|
|
||||||
const response = await fetch(`${xiaoyaConfig.ServerURL}/api/fs/other`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': token,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
path: path,
|
|
||||||
method: 'video_preview',
|
|
||||||
password: '',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`视频预览请求失败: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.code !== 200) {
|
|
||||||
throw new Error(`视频预览失败: ${data.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskList = data.data?.video_preview_play_info?.live_transcoding_task_list;
|
|
||||||
if (!taskList || taskList.length === 0) {
|
|
||||||
throw new Error('未找到可用的播放链接');
|
|
||||||
}
|
|
||||||
|
|
||||||
const qualityOrder: Record<string, number> = {
|
|
||||||
'FHD': 1,
|
|
||||||
'HD': 2,
|
|
||||||
'LD': 3,
|
|
||||||
'SD': 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
const qualities = taskList
|
|
||||||
.filter((task: any) => task.status === 'finished')
|
|
||||||
.map((task: any) => ({
|
|
||||||
name: task.template_id,
|
|
||||||
url: task.url,
|
|
||||||
}))
|
|
||||||
.filter((quality: any) => quality.url && quality.url.trim() !== '') // 过滤空URL
|
|
||||||
.sort((a: any, b: any) => (qualityOrder[a.name] || 999) - (qualityOrder[b.name] || 999));
|
|
||||||
|
|
||||||
if (qualities.length === 0) {
|
|
||||||
throw new Error('未找到已完成的播放链接');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果指定了 format=json,返回 JSON 格式
|
|
||||||
if (format === 'json') {
|
|
||||||
return NextResponse.json({
|
|
||||||
url: qualities[0].url,
|
|
||||||
qualities
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 默认返回重定向(用于 tvbox)
|
|
||||||
return NextResponse.redirect(qualities[0].url);
|
|
||||||
} catch (error) {
|
|
||||||
// 视频预览流失败,降级到直连方法
|
|
||||||
console.log('[xiaoya/play] 视频预览流失败,降级到直连方法:', (error as Error).message);
|
|
||||||
|
|
||||||
const playUrl = await client.getDownloadUrl(path);
|
|
||||||
|
|
||||||
// 如果指定了 format=json,使用 getFinalUrl 并返回 JSON
|
|
||||||
if (format === 'json') {
|
|
||||||
const finalUrl = await getFinalUrl(playUrl);
|
|
||||||
|
|
||||||
// 检查URL是否为空
|
|
||||||
if (!finalUrl || finalUrl.trim() === '') {
|
|
||||||
throw new Error('获取到的播放链接为空');
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ url: finalUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查URL是否为空
|
|
||||||
if (!playUrl || playUrl.trim() === '') {
|
|
||||||
throw new Error('获取到的播放链接为空');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 默认返回重定向(用于 tvbox)
|
|
||||||
return NextResponse.redirect(playUrl);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/xiaoya/search?keyword=<keyword>&type=<type>
|
|
||||||
* 搜索小雅视频(使用小雅的网页搜索引擎)
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
|
||||||
if (!authInfo || !authInfo.username) {
|
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const keyword = searchParams.get('keyword');
|
|
||||||
const type = searchParams.get('type') || 'video'; // video, music, ebook, all
|
|
||||||
|
|
||||||
if (!keyword) {
|
|
||||||
return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = await getConfig();
|
|
||||||
const xiaoyaConfig = config.XiaoyaConfig;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!xiaoyaConfig ||
|
|
||||||
!xiaoyaConfig.Enabled ||
|
|
||||||
!xiaoyaConfig.ServerURL
|
|
||||||
) {
|
|
||||||
return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用小雅的搜索引擎
|
|
||||||
const searchUrl = `${xiaoyaConfig.ServerURL}/search?box=${encodeURIComponent(keyword)}&type=${type}&url=`;
|
|
||||||
|
|
||||||
const response = await fetch(searchUrl, {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`搜索请求失败: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = await response.text();
|
|
||||||
|
|
||||||
// 解析 HTML 中的链接
|
|
||||||
// 格式: <a href=/path/to/file>path/to/file</a>
|
|
||||||
const linkRegex = /<a href=([^>]+)>([^<]+)<\/a>/g;
|
|
||||||
const results: Array<{ name: string; path: string }> = [];
|
|
||||||
|
|
||||||
let match;
|
|
||||||
while ((match = linkRegex.exec(html)) !== null) {
|
|
||||||
let path = match[1];
|
|
||||||
const displayText = match[2];
|
|
||||||
|
|
||||||
// 跳过返回首页和频道链接
|
|
||||||
if (path === '/' || path.startsWith('http')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL 解码路径
|
|
||||||
try {
|
|
||||||
path = decodeURIComponent(path);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('URL 解码失败:', path, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取文件名(路径的最后一部分)
|
|
||||||
const pathParts = displayText.split('/');
|
|
||||||
const fileName = pathParts[pathParts.length - 1];
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
name: fileName,
|
|
||||||
path: path,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
videos: results,
|
|
||||||
total: results.length,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('小雅搜索失败:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: (error as Error).message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,43 +2,6 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
/* NProgress 进度条样式 */
|
|
||||||
#nprogress {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#nprogress .bar {
|
|
||||||
background: #3b82f6;
|
|
||||||
position: fixed;
|
|
||||||
z-index: 9999;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 3px;
|
|
||||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.8), 0 0 5px rgba(59, 130, 246, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#nprogress .peg {
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
right: 0px;
|
|
||||||
width: 100px;
|
|
||||||
height: 100%;
|
|
||||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.8), 0 0 5px rgba(59, 130, 246, 0.6);
|
|
||||||
opacity: 1.0;
|
|
||||||
transform: rotate(3deg) translate(0px, -4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 暗色模式下的进度条样式 */
|
|
||||||
.dark #nprogress .bar {
|
|
||||||
background: #60a5fa;
|
|
||||||
box-shadow: 0 0 10px rgba(96, 165, 250, 0.8), 0 0 5px rgba(96, 165, 250, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark #nprogress .peg {
|
|
||||||
box-shadow: 0 0 10px rgba(96, 165, 250, 0.8), 0 0 5px rgba(96, 165, 250, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.scrollbar-hide {
|
.scrollbar-hide {
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user