Compare commits
218 Commits
Selene_Bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
996b4dcb0a | ||
|
|
5e1df4c21a | ||
|
|
b636051cf9 | ||
|
|
7ea35e423d | ||
|
|
7547f78472 | ||
|
|
8acc50b198 | ||
|
|
068459cc5f | ||
|
|
90b4633f6b | ||
|
|
712cd8ac60 | ||
|
|
8ecb688127 | ||
|
|
39d829eab6 | ||
|
|
08397d323f | ||
|
|
1f2696d9e6 | ||
|
|
2b482a031f | ||
|
|
cbc8271b87 | ||
|
|
6a463f428d | ||
|
|
814e48658c | ||
|
|
a9978779c0 | ||
|
|
cc1e70ad61 | ||
|
|
f24c42cb90 | ||
|
|
1017259249 | ||
|
|
5d75491523 | ||
|
|
4437a4c461 | ||
|
|
52905f8bdc | ||
|
|
2e7f43becf | ||
|
|
3132d7608f | ||
|
|
4bb6cd679d | ||
|
|
691247cb8e | ||
|
|
5b6649ce2f | ||
|
|
e7e4196e2e | ||
|
|
3b5587e388 | ||
|
|
c41955952e | ||
|
|
b66d607675 | ||
|
|
c10e28f5ae | ||
|
|
1c904c0441 | ||
|
|
5a1e578f82 | ||
|
|
dbc32a2a37 | ||
|
|
0322b2650b | ||
|
|
b61865fb49 | ||
|
|
88d453c0f9 | ||
|
|
986ab4b500 | ||
|
|
5ac63ef39b | ||
|
|
02b5c87c01 | ||
|
|
826acf40bf | ||
|
|
fe210c26cb | ||
|
|
3bec268481 | ||
|
|
e8681fd744 | ||
|
|
cdba909716 | ||
|
|
11317abc98 | ||
|
|
ea169593a6 | ||
|
|
976bc7ad46 | ||
|
|
8a4bb4ac0f | ||
|
|
1659804d1a | ||
|
|
11b3c0ca21 | ||
|
|
52166a4a5d | ||
|
|
3e9fdbb7a0 | ||
|
|
3b5a6c9141 | ||
|
|
c2a46e1b95 | ||
|
|
2892819205 | ||
|
|
df48b9daef | ||
|
|
1a79d4930d | ||
|
|
11dd787e73 | ||
|
|
f3877811c5 | ||
|
|
559117076c | ||
|
|
802bf80bef | ||
|
|
7bd10624f7 | ||
|
|
b4320b7028 | ||
|
|
3563d80510 | ||
|
|
fc9d7a67f9 | ||
|
|
bc25791fd9 | ||
|
|
6481772777 | ||
|
|
406d7ce812 | ||
|
|
047691d1a5 | ||
|
|
82e6a33b00 | ||
|
|
cdeddd48c1 | ||
|
|
a2ac524778 | ||
|
|
4da312e8c7 | ||
|
|
4f87e99f52 | ||
|
|
fe1f79b273 | ||
|
|
95c920e565 | ||
|
|
fffced713c | ||
|
|
19251c8f17 | ||
|
|
6bd618e76c | ||
|
|
092645ea8b | ||
|
|
b4cd92899f | ||
|
|
3098c8885b | ||
|
|
2a712f09ef | ||
|
|
b3a460e300 | ||
|
|
e3f4b72fa8 | ||
|
|
17376ff0cd | ||
|
|
11626f77f2 | ||
|
|
0beffc2fbd | ||
|
|
34698e943c | ||
|
|
0745e25ed7 | ||
|
|
9906bb7ea9 | ||
|
|
1f87e69eb9 | ||
|
|
0402828c4c | ||
|
|
6a494f8a1b | ||
|
|
2670e80446 | ||
|
|
21175170a1 | ||
|
|
ac68bca649 | ||
|
|
ef5656cde5 | ||
|
|
7124246538 | ||
|
|
4dfea503d5 | ||
|
|
9a33b90078 | ||
|
|
69efdc2c02 | ||
|
|
68d5546241 | ||
|
|
92a8987a86 | ||
|
|
b93aaa58e2 | ||
|
|
f02dce869c | ||
|
|
d1e3d6a5ef | ||
|
|
5d2f944346 | ||
|
|
71205d98d8 | ||
|
|
bc9e03e101 | ||
|
|
05aa001330 | ||
|
|
8b87c67cf2 | ||
|
|
84fec2eada | ||
|
|
b914133dd7 | ||
|
|
96b08dc0bd | ||
|
|
e4a76b3f9d | ||
|
|
f5d549a617 | ||
|
|
bdb43450a6 | ||
|
|
29a14bf8fa | ||
|
|
54dd196af4 | ||
|
|
6824ad7514 | ||
|
|
258b07780e | ||
|
|
bdd6d2eea8 | ||
|
|
6243bcacd8 | ||
|
|
a46906c157 | ||
|
|
fc7c5e48c5 | ||
|
|
04bb7a1ada | ||
|
|
2c7e461002 | ||
|
|
41f241095e | ||
|
|
7a5acbbe69 | ||
|
|
f0cedc1651 | ||
|
|
25725c75b8 | ||
|
|
7d004ee973 | ||
|
|
5315c34737 | ||
|
|
4595661535 | ||
|
|
84c5d7a9f0 | ||
|
|
22b9242e39 | ||
|
|
e0606d0b8b | ||
|
|
ed17a74a12 | ||
|
|
b5177833c3 | ||
|
|
30c3fe1910 | ||
|
|
4901e2c569 | ||
|
|
c6865b4607 | ||
|
|
a2e07011ec | ||
|
|
1d3a7f2732 | ||
|
|
e619cbe62a | ||
|
|
a63e402b81 | ||
|
|
5e2c1c9e6a | ||
|
|
5c5f9847cb | ||
|
|
575b696c10 | ||
|
|
0eebbf6cb9 | ||
|
|
2d5aa76f5d | ||
|
|
dbc581a9f7 | ||
|
|
f43bdaa9e3 | ||
|
|
d148491526 | ||
|
|
deb887c6e1 | ||
|
|
161243b0a7 | ||
|
|
050989dcfb | ||
|
|
b8c31d12ab | ||
|
|
d772051213 | ||
|
|
521205a8c2 | ||
|
|
500d5604d9 | ||
|
|
815f5daa2e | ||
|
|
33420f02e5 | ||
|
|
b48c573e28 | ||
|
|
dd306bcebb | ||
|
|
ec1ebb750c | ||
|
|
54cdd87bcc | ||
|
|
0bdab3745c | ||
|
|
2155929f30 | ||
|
|
59a4891719 | ||
|
|
525c6f77aa | ||
|
|
a447a41920 | ||
|
|
68b1332df1 | ||
|
|
4168228647 | ||
|
|
e2fe2058e9 | ||
|
|
821baff2be | ||
|
|
790523ce95 | ||
|
|
6477230d4e | ||
|
|
34b62d6297 | ||
|
|
9b217b8eae | ||
|
|
e08e953047 | ||
|
|
e915a97a70 | ||
|
|
46220ee927 | ||
|
|
4cb5bc27c6 | ||
|
|
2ef04bc434 | ||
|
|
19be34c6fe | ||
|
|
74dc91d59e | ||
|
|
9829e867b2 | ||
|
|
8be31af615 | ||
|
|
cfa50165a0 | ||
|
|
5288951f13 | ||
|
|
f527ec849f | ||
|
|
3feaac91d4 | ||
|
|
ca18aef7c4 | ||
|
|
cbc52adc0e | ||
|
|
aa2fd66420 | ||
|
|
882f254432 | ||
|
|
0237b77447 | ||
|
|
465821a6c9 | ||
|
|
c86f9cdca7 | ||
|
|
c4198e0954 | ||
|
|
1ad91eeebd | ||
|
|
2d0df4bb80 | ||
|
|
5b6d6fbdff | ||
|
|
38dab1a8c2 | ||
|
|
d6c0642ffc | ||
|
|
92526e2d24 | ||
|
|
32724f2d0e | ||
|
|
f1d3f805ec | ||
|
|
ada7d12fa3 | ||
|
|
7c62e1612c | ||
|
|
6494c80159 | ||
|
|
1a7005a8f9 |
@@ -1,2 +1,5 @@
|
|||||||
.env
|
.env
|
||||||
.env*.local
|
.env*.local
|
||||||
|
public/screenshot1.png
|
||||||
|
public/screenshot2.png
|
||||||
|
public/screenshot3.png
|
||||||
|
|||||||
64
.github/workflows/cloudflare-deploy.yml
vendored
@@ -66,6 +66,31 @@ jobs:
|
|||||||
echo "KVROCKS_URL=${{ secrets.KVROCKS_URL }}" >> $GITHUB_ENV
|
echo "KVROCKS_URL=${{ secrets.KVROCKS_URL }}" >> $GITHUB_ENV
|
||||||
echo "INIT_CONFIG=${{ secrets.INIT_CONFIG }}" >> $GITHUB_ENV
|
echo "INIT_CONFIG=${{ secrets.INIT_CONFIG }}" >> $GITHUB_ENV
|
||||||
echo "CONFIG_SUBSCRIPTION_URL=${{ secrets.CONFIG_SUBSCRIPTION_URL }}" >> $GITHUB_ENV
|
echo "CONFIG_SUBSCRIPTION_URL=${{ secrets.CONFIG_SUBSCRIPTION_URL }}" >> $GITHUB_ENV
|
||||||
|
echo "ENABLE_TVBOX_SUBSCRIBE=${{ secrets.ENABLE_TVBOX_SUBSCRIBE }}" >> $GITHUB_ENV
|
||||||
|
echo "TVBOX_SUBSCRIBE_TOKEN=${{ secrets.TVBOX_SUBSCRIBE_TOKEN }}" >> $GITHUB_ENV
|
||||||
|
echo "WATCH_ROOM_ENABLED=${{ secrets.WATCH_ROOM_ENABLED }}" >> $GITHUB_ENV
|
||||||
|
echo "WATCH_ROOM_EXTERNAL_SERVER_AUTH=${{ secrets.WATCH_ROOM_EXTERNAL_SERVER_AUTH }}" >> $GITHUB_ENV
|
||||||
|
echo "WATCH_ROOM_EXTERNAL_SERVER_URL=${{ secrets.WATCH_ROOM_EXTERNAL_SERVER_URL }}" >> $GITHUB_ENV
|
||||||
|
echo "WATCH_ROOM_SERVER_TYPE=${{ secrets.WATCH_ROOM_SERVER_TYPE }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Configure D1 Database in wrangler.toml
|
||||||
|
run: |
|
||||||
|
STORAGE_TYPE="${{ secrets.NEXT_PUBLIC_STORAGE_TYPE }}"
|
||||||
|
|
||||||
|
if [ "$STORAGE_TYPE" != "d1" ]; then
|
||||||
|
echo "Storage type is not d1 (current: $STORAGE_TYPE), removing D1 database configuration"
|
||||||
|
# Remove the entire [[d1_databases]] section including the comment
|
||||||
|
sed -i '/^# D1 数据库绑定$/,/^database_id = .*$/d' wrangler.toml
|
||||||
|
# Also remove any remaining [[d1_databases]] block
|
||||||
|
sed -i '/^\[\[d1_databases\]\]$/,/^$/{ /^\[\[d1_databases\]\]$/d; /^binding = /d; /^database_name = /d; /^database_id = /d; }' wrangler.toml
|
||||||
|
echo "D1 configuration removed from wrangler.toml"
|
||||||
|
elif [ -n "${{ secrets.D1_DATABASE_ID }}" ]; then
|
||||||
|
sed -i 's/REPLACE_WITH_YOUR_D1_DATABASE_ID/${{ secrets.D1_DATABASE_ID }}/g' wrangler.toml
|
||||||
|
echo "D1 Database ID replaced successfully"
|
||||||
|
else
|
||||||
|
echo "⚠️ Storage type is d1 but D1_DATABASE_ID secret not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Update wrangler.toml with environment variables
|
- name: Update wrangler.toml with environment variables
|
||||||
run: |
|
run: |
|
||||||
@@ -109,13 +134,50 @@ jobs:
|
|||||||
append_var "KVROCKS_URL" "${{ secrets.KVROCKS_URL }}"
|
append_var "KVROCKS_URL" "${{ secrets.KVROCKS_URL }}"
|
||||||
append_var "INIT_CONFIG" "${{ secrets.INIT_CONFIG }}"
|
append_var "INIT_CONFIG" "${{ secrets.INIT_CONFIG }}"
|
||||||
append_var "CONFIG_SUBSCRIPTION_URL" "${{ secrets.CONFIG_SUBSCRIPTION_URL }}"
|
append_var "CONFIG_SUBSCRIPTION_URL" "${{ secrets.CONFIG_SUBSCRIPTION_URL }}"
|
||||||
|
append_var ENABLE_TVBOX_SUBSCRIBE "${{ secrets.ENABLE_TVBOX_SUBSCRIBE }}"
|
||||||
|
append_var TVBOX_SUBSCRIBE_TOKEN "${{ secrets.TVBOX_SUBSCRIBE_TOKEN }}"
|
||||||
|
append_var WATCH_ROOM_ENABLED "${{ secrets.WATCH_ROOM_ENABLED }}"
|
||||||
|
append_var WATCH_ROOM_EXTERNAL_SERVER_AUTH "${{ secrets.WATCH_ROOM_EXTERNAL_SERVER_AUTH }}"
|
||||||
|
append_var WATCH_ROOM_EXTERNAL_SERVER_URL "${{ secrets.WATCH_ROOM_EXTERNAL_SERVER_URL }}"
|
||||||
|
append_var WATCH_ROOM_SERVER_TYPE "${{ secrets.WATCH_ROOM_SERVER_TYPE }}"
|
||||||
|
|
||||||
echo "Updated wrangler.toml:"
|
echo "Updated wrangler.toml:"
|
||||||
cat wrangler.toml
|
cat wrangler.toml
|
||||||
|
|
||||||
- name: Build for Cloudflare
|
- name: Build for Cloudflare
|
||||||
run: pnpm run build:cloudflare
|
run: pnpm run build:cloudflare
|
||||||
|
|
||||||
|
- name: Install Wrangler
|
||||||
|
run: pnpm add -g wrangler@4.60.0
|
||||||
|
|
||||||
|
- name: Run D1 Database Migrations
|
||||||
|
run: |
|
||||||
|
STORAGE_TYPE="${{ secrets.NEXT_PUBLIC_STORAGE_TYPE }}"
|
||||||
|
|
||||||
|
if [ "$STORAGE_TYPE" != "d1" ]; then
|
||||||
|
echo "Storage type is not d1 (current: $STORAGE_TYPE), skipping D1 migrations"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${{ secrets.D1_DATABASE_ID }}" ]; then
|
||||||
|
echo "D1_DATABASE_ID not set, skipping migrations"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running D1 database migrations..."
|
||||||
|
|
||||||
|
for migration_file in ./migrations/*.sql; do
|
||||||
|
if [ -f "$migration_file" ]; then
|
||||||
|
echo "Executing migration: $(basename $migration_file)"
|
||||||
|
wrangler d1 execute moontvplus --remote --file="$migration_file" || echo "⚠️ Migration may have been skipped (already applied)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "✅ Migrations completed"
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
|
||||||
- name: Deploy to Cloudflare
|
- name: Deploy to Cloudflare
|
||||||
uses: cloudflare/wrangler-action@v3
|
uses: cloudflare/wrangler-action@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
125
.github/workflows/docker-image-lite.yml
vendored
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
name: Build & Push Docker lite image
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Docker 标签'
|
||||||
|
required: false
|
||||||
|
default: 'latest'
|
||||||
|
type: string
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
actions: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
os: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
os: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Prepare platform name
|
||||||
|
run: |
|
||||||
|
echo "PLATFORM_NAME=${{ matrix.platform }}" | sed 's|/|-|g' >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Checkout source code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/mtvpls/moontvplus-lite
|
||||||
|
tags: |
|
||||||
|
type=raw,value=${{ github.event.inputs.tag || 'latest' }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.lite
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
tags: ghcr.io/mtvpls/moontvplus-lite:${{ github.event.inputs.tag || 'latest' }}
|
||||||
|
outputs: type=image,name=ghcr.io/mtvpls/moontvplus-lite,name-canonical=true,push=true
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ env.PLATFORM_NAME }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create manifest list and push
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create -t ghcr.io/mtvpls/moontvplus-lite:${{ github.event.inputs.tag || 'latest' }} \
|
||||||
|
$(printf 'ghcr.io/mtvpls/moontvplus-lite@sha256:%s ' *)
|
||||||
|
|
||||||
|
cleanup-refresh:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- merge
|
||||||
|
if: always()
|
||||||
|
steps:
|
||||||
|
- name: Delete workflow runs
|
||||||
|
uses: Mattraks/delete-workflow-runs@main
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
retain_days: 0
|
||||||
|
keep_minimum_runs: 2
|
||||||
7
.gitignore
vendored
@@ -57,4 +57,9 @@ public/workbox-*.js
|
|||||||
public/workbox-*.js.map
|
public/workbox-*.js.map
|
||||||
|
|
||||||
/.claude
|
/.claude
|
||||||
/.vscode
|
/.vscode
|
||||||
|
# SQLite 开发数据库
|
||||||
|
.data/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
npx --no-install commitlint --edit "$1"
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
pnpm install
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
npx lint-staged
|
|
||||||
174
CHANGELOG
@@ -1,3 +1,175 @@
|
|||||||
|
## [216.0.0] - 2026-03-30
|
||||||
|
### Added
|
||||||
|
- 新增视频源脚本
|
||||||
|
- 私人影库增加本机转码功能
|
||||||
|
- tvbox订阅增加黄色过滤
|
||||||
|
- 播放记录显示直链播放的链接
|
||||||
|
- 磁链增加代理配置
|
||||||
|
- 弹幕选集面板增强
|
||||||
|
- 电视直播聚合同名节目
|
||||||
|
- douban页面大屏自动预加载第二页
|
||||||
|
- 增加docker lite镜像
|
||||||
|
- 详情面板增加外部跳转
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- GlobalError自动消失
|
||||||
|
- 站点配置子服务配置项折叠
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 修复手动选择弹幕因缓存问题无法变更弹幕集数
|
||||||
|
- 修复当前集拉回开头显示恢复进度按钮
|
||||||
|
- 修复视频源权重的一些问题
|
||||||
|
|
||||||
|
## [215.0.0] - 2026-03-20
|
||||||
|
### Added
|
||||||
|
- 增加主动恢复进度按钮
|
||||||
|
- 新增播放记录面板
|
||||||
|
- 弹幕搜索面板标题增加tooltip
|
||||||
|
- 影视搜索新增列表视图
|
||||||
|
- pansou增加重试按钮
|
||||||
|
- 新增ai评论生成
|
||||||
|
- 增加一键render部署
|
||||||
|
- 电视直播增加三种代理模式
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 优化直链播放m3u8体验
|
||||||
|
- 搜索页面海量数据下使用虚拟滚动提高性能
|
||||||
|
- 优化emby代理内存泄漏问题
|
||||||
|
- 电视直播代理控制权从用户端改为管理端
|
||||||
|
- 获取视频源详情不再依赖title
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 修复search页面僵尸历史记录tag
|
||||||
|
- 修复弹幕搜索框挤压
|
||||||
|
- 修复搜索页面加载条显示顺序错误
|
||||||
|
- 修复继续观看渐进式加载的一些问题
|
||||||
|
|
||||||
|
## [214.1.0] - 2026-03-10
|
||||||
|
### Changed
|
||||||
|
- emby兼容jellyfin
|
||||||
|
- emby支持无密码登录
|
||||||
|
- ai问片移除think块
|
||||||
|
- ai问片可点击标题播放
|
||||||
|
- 优化ai问片流式处理,系统提示词增加日期,优化决策json提取
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 修复filesystem模式取消下载不删除文件
|
||||||
|
- 修复未配置TVBOX_SUBSCRIBE_TOKEN不代理emby图片
|
||||||
|
|
||||||
|
## [214.0.0] - 2026-03-04
|
||||||
|
### Added
|
||||||
|
- 下载新增File System Api模式,实现浏览器本地播放
|
||||||
|
- emby增加自定义ua和图片代理
|
||||||
|
- tvbox支持根据用户细分视频源
|
||||||
|
- 新增下载线程数调整设置
|
||||||
|
- DetailPanel选集自动跟随
|
||||||
|
- 源名字中增加分辨率显示
|
||||||
|
- 换源增加全部重试
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 数据迁移支持并发提升性能
|
||||||
|
- 首页信息空数据不缓存
|
||||||
|
- 播放页面的弹窗在大屏下使用抽屉式
|
||||||
|
- 电视直播放行mp4
|
||||||
|
- 定时任务支持控制是否同步返回
|
||||||
|
- 下载按钮支持拖动
|
||||||
|
- 网络设置改名数据源设置并关闭默认折叠
|
||||||
|
- cloudflare添加minify参数以压缩大小
|
||||||
|
- videocard直播不显示详情
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 修复集数屏蔽设置面板层级
|
||||||
|
- 修复侧边栏私人影库选项不高亮
|
||||||
|
|
||||||
|
## [213.0.0] - 2026-02-24
|
||||||
|
### Added
|
||||||
|
- 私人影库新加追番订阅功能
|
||||||
|
- 新增进度条图标个性化功能
|
||||||
|
- 新增下载任务数设置
|
||||||
|
- 播放页面显示详情按钮
|
||||||
|
- 详情面板增加演员数据
|
||||||
|
- 继续播放增加剧集更新提示
|
||||||
|
- tvbox订阅增加默认jar包
|
||||||
|
- 定时任务增加冷却
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 详情面板支持切换数据源
|
||||||
|
- AI问片关闭弹窗不中断
|
||||||
|
- 定时任务性能优化,支持并发处理
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 修复通知已读不更新菜单状态
|
||||||
|
- 修正生态应用selene下载链接
|
||||||
|
|
||||||
|
## [212.2.0] - 2026-02-13
|
||||||
|
### Added
|
||||||
|
- 新增生态应用模块
|
||||||
|
- 新增精确搜索功能
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 求片按钮位置调整
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 修复音乐模块移动端无法调节音量
|
||||||
|
- 修复emby无法删除最后一个服务
|
||||||
|
- 修复cloudflare非d1部署报错
|
||||||
|
- 修复音乐模块openlist缓存未启用时仍走代理
|
||||||
|
- 修复postgrep数据方式导入导出无法使用
|
||||||
|
- 修复openlist扫描目录包含文件时报错
|
||||||
|
|
||||||
|
## [212.1.0] - 2026-02-08
|
||||||
|
### Added
|
||||||
|
- 新增 Vercel Postgres 数据库支持
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 自动创建站长账号,避免外键约束导致无法操作关联表
|
||||||
|
- 即将上映恢复长按菜单,上映天数改为点击显示
|
||||||
|
|
||||||
|
## [212.0.0] - 2026-02-07
|
||||||
|
### Added
|
||||||
|
- 接入Tunehub实现音乐播放
|
||||||
|
- 网络直播支持观影室同步
|
||||||
|
- 详情图片点击可预览
|
||||||
|
- oidc登录图标自动识别多平台
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 获取用户站长无信息时返回默认值兜底
|
||||||
|
- 继续观看使用渐进式加载模式
|
||||||
|
- openlist根路径主动移除bom防止获取根目录失效
|
||||||
|
- webgpu缓冲区大小调整
|
||||||
|
- 设备管理可识别OrionTv
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 修复与upstash合并数据源时丢失的用户缓存信息
|
||||||
|
- 修复登录页面不处理error参数
|
||||||
|
- 修复safari视频切换集数无法清理旧视频
|
||||||
|
- 修复部分tmdb接口在cloudflare下无法使用
|
||||||
|
|
||||||
|
## [211.0.0] - 2026-02-01
|
||||||
|
### Added
|
||||||
|
- cloudflare部署增加d1支持
|
||||||
|
- 电视直播支持flv播放
|
||||||
|
- 登录注册页面输入框增加图标
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 豆瓣预告片改为前端获取
|
||||||
|
- 豆瓣预告片可播放声音
|
||||||
|
- 轮播图缓存采用乐观更新策略
|
||||||
|
- 管理面板无权限时显示错误提示
|
||||||
|
|
||||||
|
## [210.2.0] - 2026-01-29
|
||||||
|
### Added
|
||||||
|
- 外部播放器增加app打开
|
||||||
|
- 新增tvbox屏蔽源配置
|
||||||
|
- 新增视频源权重功能
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 设备管理当前设备置顶且显示黄色边框
|
||||||
|
- live分片代理移除content-length发送
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 修复tmdbkey错误和未登录时获取外部观影室密钥无限重定向
|
||||||
|
|
||||||
## [210.1.3] - 2026-01-28
|
## [210.1.3] - 2026-01-28
|
||||||
### Fixed
|
### Fixed
|
||||||
- 修复跳转登录不登出
|
- 修复跳转登录不登出
|
||||||
@@ -726,4 +898,4 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 基于 Semantic Versioning 的版本号机制
|
- 基于 Semantic Versioning 的版本号机制
|
||||||
- 版本信息面板,展示本地变更日志和远程更新日志
|
- 版本信息面板,展示本地变更日志和远程更新日志
|
||||||
|
|||||||
52
Dockerfile.lite
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# ---- 第 1 阶段:安装依赖 ----
|
||||||
|
FROM node:24-alpine AS deps
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# ---- 第 2 阶段:构建项目 ----
|
||||||
|
FROM node:24-alpine AS builder
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV DOCKER_ENV=true
|
||||||
|
ENV MOONTV_LITE=true
|
||||||
|
ENV WATCH_ROOM_ENABLED=false
|
||||||
|
ENV WATCH_ROOM_SERVER_TYPE=external
|
||||||
|
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# ---- 第 3 阶段:生成 lite 运行时镜像 ----
|
||||||
|
FROM node:24-alpine AS runner
|
||||||
|
|
||||||
|
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV DOCKER_ENV=true
|
||||||
|
ENV MOONTV_LITE=true
|
||||||
|
ENV WATCH_ROOM_ENABLED=false
|
||||||
|
ENV WATCH_ROOM_SERVER_TYPE=external
|
||||||
|
|
||||||
|
# standalone 输出自带运行所需的最小服务端依赖
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "start.js"]
|
||||||
80
README.md
@@ -4,17 +4,18 @@
|
|||||||
<img src="public/logo.png" alt="MoonTVPlus Logo" width="120">
|
<img src="public/logo.png" alt="MoonTVPlus Logo" width="120">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
## ⚠️ 请某些人停止你的抄袭行为,不要我上什么功能你就抄什么,借鉴≠抄袭
|
||||||
|
|
||||||
> 🎬 **MoonTVPlus** 是基于 [MoonTV v100](https://github.com/MoonTechLab/LunaTV) 二次开发的增强版影视聚合播放器。它在原版基础上新增了外部播放器支持、视频超分、弹幕系统、评论抓取等实用功能,提供更强大的观影体验。
|
> 🎬 **MoonTVPlus** 是基于 [MoonTV v100](https://github.com/MoonTechLab/LunaTV) 二次开发的增强版影视聚合播放器。它在原版基础上新增了外部播放器支持、视频超分、弹幕系统、评论抓取等实用功能,提供更强大的观影体验。
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -26,11 +27,13 @@
|
|||||||
- ✨ **视频超分 (Anime4K)**:使用 WebGPU 技术实现实时视频画质增强(支持 1.5x/2x/3x/4x 超分)
|
- ✨ **视频超分 (Anime4K)**:使用 WebGPU 技术实现实时视频画质增强(支持 1.5x/2x/3x/4x 超分)
|
||||||
- 💬 **弹幕系统**:完整的弹幕搜索、匹配、加载功能,支持弹幕设置持久化、弹幕屏蔽
|
- 💬 **弹幕系统**:完整的弹幕搜索、匹配、加载功能,支持弹幕设置持久化、弹幕屏蔽
|
||||||
- 📝 **豆瓣评论抓取**:自动抓取并展示豆瓣电影短评,支持分页加载
|
- 📝 **豆瓣评论抓取**:自动抓取并展示豆瓣电影短评,支持分页加载
|
||||||
|
- 🧩 **视频源脚本**:支持通过脚本自定义视频源、搜索、详情与播放解析逻辑(实验性)
|
||||||
- 🪒**自定义去广告**:你可以自定义你的去广告代码,实现更强力的去广告功能
|
- 🪒**自定义去广告**:你可以自定义你的去广告代码,实现更强力的去广告功能
|
||||||
|
- 🚀 **更快更顺滑**:相较原版项目整体速度更快,交互体验更好
|
||||||
- 🎭 **观影室**:支持多人同步观影、实时聊天、语音通话等功能(实验性)。
|
- 🎭 **观影室**:支持多人同步观影、实时聊天、语音通话等功能(实验性)。
|
||||||
- 📥 **M3U8完整下载**:通过合并m3u8片段实现完整视频下载。
|
- 📥 **M3U8完整下载**:支持浏览器内合并 m3u8 片段下载,也支持下载到本地文件夹并无感播放本地视频。
|
||||||
- 💾 **服务器离线下载**:支持在服务器端下载视频文件,支持断点续传,提前下载到家秒加载 。
|
- 💾 **服务器离线下载**:支持在服务器端下载视频文件,支持断点续传,提前下载到家秒加载 。
|
||||||
- 📚 **私人影库**:接入 OpenList或Emby,可打造专属私人影库,亦可观看网盘资源。
|
- 📚 **私人影库**:接入 OpenList、Emby 或小雅,可打造专属私人影库,亦可观看网盘资源。
|
||||||
|
|
||||||
## ✨ 功能特性
|
## ✨ 功能特性
|
||||||
|
|
||||||
@@ -89,10 +92,14 @@
|
|||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus)
|
[](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus)
|
||||||
|
|
||||||
**一键部署到zeabur**
|
**一键部署到 Zeabur**
|
||||||
|
|
||||||
[](https://zeabur.com/templates/SCHCAY/deploy)
|
[](https://zeabur.com/templates/SCHCAY/deploy)
|
||||||
|
|
||||||
|
**一键部署到 Render**
|
||||||
|
|
||||||
|
[](https://render.com/deploy?repo=https://github.com/mtvpls/MoonTVPlus)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Cloudflare Workers 部署(通过 GitHub Actions)
|
### Cloudflare Workers 部署(通过 GitHub Actions)
|
||||||
@@ -114,7 +121,7 @@ Cloudflare Workers 提供免费的边缘计算服务,通过 GitHub Actions 可
|
|||||||
- 点击 "Create Token",选择 "Edit Cloudflare Workers" 模板
|
- 点击 "Create Token",选择 "Edit Cloudflare Workers" 模板
|
||||||
- 或使用自定义 Token,需要以下权限:
|
- 或使用自定义 Token,需要以下权限:
|
||||||
- Account - Cloudflare Workers Scripts - Edit
|
- Account - Cloudflare Workers Scripts - Edit
|
||||||
- Account - Cloudflare Workers KV Storage - Edit
|
- Account - D1 - Edit(仅在使用 D1 数据库时需要)
|
||||||
- 创建后复制生成的 API Token
|
- 创建后复制生成的 API Token
|
||||||
- 在 Dashboard 首页右侧可以看到你的 Account ID
|
- 在 Dashboard 首页右侧可以看到你的 Account ID
|
||||||
|
|
||||||
@@ -170,7 +177,18 @@ on:
|
|||||||
- 点击 Settings > Triggers > Custom Domains
|
- 点击 Settings > Triggers > Custom Domains
|
||||||
- 添加你的自定义域名
|
- 添加你的自定义域名
|
||||||
|
|
||||||
**6.配置外部定时任务(可选)**
|
**6. 使用 D1 数据库(可选)**
|
||||||
|
|
||||||
|
如果想使用 Cloudflare D1 数据库代替 Upstash Redis,需要进行以下配置:
|
||||||
|
|
||||||
|
1. 在 Cloudflare Dashboard 中创建一个 D1 数据库
|
||||||
|
2. 复制数据库 ID
|
||||||
|
3. 在 GitHub Secrets 中配置:
|
||||||
|
- 将 `NEXT_PUBLIC_STORAGE_TYPE` 设置为 `d1`
|
||||||
|
- 添加 `D1_DATABASE_ID` 并填入你的数据库 ID
|
||||||
|
- 无需配置 `UPSTASH_URL` 和 `UPSTASH_TOKEN`
|
||||||
|
|
||||||
|
**7. 配置外部定时任务(可选)**
|
||||||
|
|
||||||
可使用外部定时请求/api/cron/mtvpls端点以触发定时任务,或新建一个workers请求触发,推荐每小时请求一次。
|
可使用外部定时请求/api/cron/mtvpls端点以触发定时任务,或新建一个workers请求触发,推荐每小时请求一次。
|
||||||
|
|
||||||
@@ -202,7 +220,7 @@ services:
|
|||||||
container_name: moontv-kvrocks
|
container_name: moontv-kvrocks
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- kvrocks-data:/var/lib/kvrocks
|
- kvrocks-data:/var/lib/kvrocks/data
|
||||||
networks:
|
networks:
|
||||||
- moontv-network
|
- moontv-network
|
||||||
networks:
|
networks:
|
||||||
@@ -267,6 +285,44 @@ services:
|
|||||||
- UPSTASH_TOKEN=上面的 TOKEN
|
- UPSTASH_TOKEN=上面的 TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Lite 镜像说明
|
||||||
|
|
||||||
|
`ghcr.io/mtvpls/moontvplus-lite:latest` 为更小的镜像,但不支持启动内置观影室服务。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```yml
|
||||||
|
services:
|
||||||
|
moontv-core:
|
||||||
|
image: ghcr.io/mtvpls/moontvplus-lite:latest
|
||||||
|
container_name: moontv-core
|
||||||
|
restart: on-failure
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
||||||
|
environment:
|
||||||
|
- USERNAME=admin
|
||||||
|
- PASSWORD=admin_password
|
||||||
|
- NEXT_PUBLIC_STORAGE_TYPE=kvrocks
|
||||||
|
- KVROCKS_URL=redis://moontv-kvrocks:6666
|
||||||
|
networks:
|
||||||
|
- moontv-network
|
||||||
|
depends_on:
|
||||||
|
- moontv-kvrocks
|
||||||
|
moontv-kvrocks:
|
||||||
|
image: apache/kvrocks
|
||||||
|
container_name: moontv-kvrocks
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- kvrocks-data:/var/lib/kvrocks/data
|
||||||
|
networks:
|
||||||
|
- moontv-network
|
||||||
|
networks:
|
||||||
|
moontv-network:
|
||||||
|
driver: bridge
|
||||||
|
volumes:
|
||||||
|
kvrocks-data:
|
||||||
|
```
|
||||||
|
|
||||||
## 配置文件
|
## 配置文件
|
||||||
|
|
||||||
完成部署后为空壳应用,无播放源,需要站长在管理后台的配置文件设置中填写配置文件,本版本已不支持无数据库运行。
|
完成部署后为空壳应用,无播放源,需要站长在管理后台的配置文件设置中填写配置文件,本版本已不支持无数据库运行。
|
||||||
@@ -327,10 +383,12 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
|||||||
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
||||||
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
||||||
| CRON_PASSWORD | 定时任务 API 访问密码(用于保护 /api/cron 端点) | 任意字符串 | mtvpls |
|
| CRON_PASSWORD | 定时任务 API 访问密码(用于保护 /api/cron 端点) | 任意字符串 | mtvpls |
|
||||||
|
| CRON_WAIT_FOR_COMPLETION | 定时任务接口是否等待任务完全结束后再返回响应(true 时返回 200,false 时立即返回 202) | true/false | false |
|
||||||
|
| CRON_USER_BATCH_SIZE | 定时任务用户批处理大小(控制并发处理的用户数量,影响播放记录和收藏更新任务的并发性能) | 正整数 | 3 |
|
||||||
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
|
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
|
||||||
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
||||||
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||||
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
|
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash、d1 | 无默认,必填字段 |
|
||||||
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
|
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
|
||||||
| REDIS_URL | redis 连接 url | 连接 url | 空 |
|
| REDIS_URL | redis 连接 url | 连接 url | 空 |
|
||||||
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
|
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
|
||||||
@@ -346,6 +404,7 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
|||||||
| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) |
|
| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) |
|
||||||
| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false |
|
| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false |
|
||||||
| TVBOX_SUBSCRIBE_TOKEN | TVBOX 订阅 API 访问 Token,如启用TVBOX功能必须设置该项 | 任意字符串 | (空) |
|
| TVBOX_SUBSCRIBE_TOKEN | TVBOX 订阅 API 访问 Token,如启用TVBOX功能必须设置该项 | 任意字符串 | (空) |
|
||||||
|
| TVBOX_BLOCKED_SOURCES | TVBOX 订阅屏蔽源列表(多个源用逗号分隔,匹配视频源的 key) | 逗号分隔的源 key | (空) |
|
||||||
| WATCH_ROOM_ENABLED | 是否启用观影室功能(vercel部署不支持该功能,可使用外部服务器) | true/false | false |
|
| WATCH_ROOM_ENABLED | 是否启用观影室功能(vercel部署不支持该功能,可使用外部服务器) | true/false | false |
|
||||||
| WATCH_ROOM_SERVER_TYPE | 观影室服务器类型 | internal/external | internal |
|
| WATCH_ROOM_SERVER_TYPE | 观影室服务器类型 | internal/external | internal |
|
||||||
| WATCH_ROOM_EXTERNAL_SERVER_URL | 外部观影室服务器地址(当 SERVER_TYPE 为 external 时必填) | WebSocket URL | (空) |
|
| WATCH_ROOM_EXTERNAL_SERVER_URL | 外部观影室服务器地址(当 SERVER_TYPE 为 external 时必填) | WebSocket URL | (空) |
|
||||||
@@ -363,6 +422,7 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
|||||||
| TMDB_REVERSE_PROXY | TMDB 反向代理地址 | URL | (空) |
|
| TMDB_REVERSE_PROXY | TMDB 反向代理地址 | URL | (空) |
|
||||||
| DANMAKU_API_BASE | 弹幕 API 地址 | URL | http://localhost:9321 |
|
| DANMAKU_API_BASE | 弹幕 API 地址 | URL | http://localhost:9321 |
|
||||||
| DANMAKU_API_TOKEN | 弹幕 API Token | 任意字符串 | 87654321 |
|
| DANMAKU_API_TOKEN | 弹幕 API Token | 任意字符串 | 87654321 |
|
||||||
|
| DATA_MIGRATION_CHUNK_SIZE | 数据迁移批处理大小(控制导入导出时每批处理的用户数量和数据条数) | 正整数 | 10 |
|
||||||
|
|
||||||
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
||||||
|
|
||||||
@@ -448,6 +508,8 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
|
|||||||
ENABLE_TVBOX_SUBSCRIBE=true
|
ENABLE_TVBOX_SUBSCRIBE=true
|
||||||
# 设置订阅访问 Token(请使用强密码)
|
# 设置订阅访问 Token(请使用强密码)
|
||||||
TVBOX_SUBSCRIBE_TOKEN=your_secure_random_token
|
TVBOX_SUBSCRIBE_TOKEN=your_secure_random_token
|
||||||
|
# 可选:屏蔽特定视频源(多个源用逗号分隔,填写视频源的 key)
|
||||||
|
TVBOX_BLOCKED_SOURCES=source1,source2
|
||||||
```
|
```
|
||||||
|
|
||||||
2. 重启应用后,登录网站,点击用户菜单中的"订阅"按钮
|
2. 重启应用后,登录网站,点击用户菜单中的"订阅"按钮
|
||||||
@@ -491,4 +553,4 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#mtvpls/moontvplus&Date)
|
[](https://www.star-history.com/#mtvpls/moontvplus&Date)
|
||||||
|
|||||||
203
SOURCE_SCRIPT.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# 视频源脚本编写教程
|
||||||
|
|
||||||
|
## 最小模板
|
||||||
|
|
||||||
|
```js
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
name: '示例脚本',
|
||||||
|
author: 'admin'
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSources(ctx) {
|
||||||
|
return [{ id: 'default', name: '默认源' }];
|
||||||
|
},
|
||||||
|
|
||||||
|
async search(ctx, { keyword, page, sourceId }) {
|
||||||
|
return {
|
||||||
|
list: [],
|
||||||
|
page,
|
||||||
|
pageCount: 1,
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async recommend(ctx, { page }) {
|
||||||
|
return {
|
||||||
|
list: [],
|
||||||
|
page: page || 1,
|
||||||
|
pageCount: 1,
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async detail(ctx, { id, sourceId }) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: '',
|
||||||
|
poster: '',
|
||||||
|
year: '',
|
||||||
|
desc: '',
|
||||||
|
playbacks: [
|
||||||
|
{
|
||||||
|
sourceId,
|
||||||
|
sourceName: '默认源',
|
||||||
|
episodes: [],
|
||||||
|
episodes_titles: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async resolvePlayUrl(ctx, { playUrl, sourceId, episodeIndex }) {
|
||||||
|
return {
|
||||||
|
url: playUrl,
|
||||||
|
type: 'auto',
|
||||||
|
headers: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 支持的 hook
|
||||||
|
|
||||||
|
1. `getSources()`
|
||||||
|
返回脚本管理的子源列表
|
||||||
|
2. `search({ keyword, page, sourceId })`
|
||||||
|
搜索
|
||||||
|
3. `recommend({ page })`
|
||||||
|
推荐
|
||||||
|
4. `detail({ id, sourceId })`
|
||||||
|
详情
|
||||||
|
5. `resolvePlayUrl({ playUrl, sourceId, episodeIndex })`
|
||||||
|
播放前解析最终地址
|
||||||
|
|
||||||
|
## `ctx` 里能用什么
|
||||||
|
|
||||||
|
1. `ctx.fetch(...)`
|
||||||
|
发请求
|
||||||
|
2. `ctx.request.get/getJson/getHtml/post`
|
||||||
|
快捷请求
|
||||||
|
3. `ctx.html.load(html)`
|
||||||
|
`cheerio` 风格解析
|
||||||
|
4. `ctx.cache.get/set/del`
|
||||||
|
脚本缓存
|
||||||
|
5. `ctx.log.info/warn/error`
|
||||||
|
输出测试日志
|
||||||
|
6. `ctx.utils.buildUrl/joinUrl/randomUA/sleep/base64Encode/base64Decode/now`
|
||||||
|
常用工具
|
||||||
|
7. `ctx.config.get/require/all`
|
||||||
|
读脚本配置
|
||||||
|
8. `ctx.runtime`
|
||||||
|
当前脚本信息
|
||||||
|
|
||||||
|
## `search` 返回格式
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: '123',
|
||||||
|
title: '凡人修仙传',
|
||||||
|
poster: 'https://...',
|
||||||
|
year: '2025',
|
||||||
|
desc: '简介',
|
||||||
|
type_name: '动漫',
|
||||||
|
douban_id: 0,
|
||||||
|
vod_remarks: '更新至10集'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageCount: 1,
|
||||||
|
total: 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `detail` 返回格式
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id: '123',
|
||||||
|
title: '凡人修仙传',
|
||||||
|
poster: 'https://...',
|
||||||
|
year: '2025',
|
||||||
|
desc: '简介',
|
||||||
|
playbacks: [
|
||||||
|
{
|
||||||
|
sourceId: 'default',
|
||||||
|
sourceName: '默认源',
|
||||||
|
episodes: [
|
||||||
|
'https://example.com/play/1',
|
||||||
|
{
|
||||||
|
playUrl: 'https://example.com/play/2',
|
||||||
|
needResolve: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
episodes_titles: ['第1集', '第2集']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `episodes` 可以是字符串,默认等价于 `{ playUrl: '...', needResolve: true }`
|
||||||
|
- `needResolve` 默认为 `true`
|
||||||
|
- 显式写 `needResolve: false` 时,播放页会直接使用该地址,不再调用 `resolvePlayUrl`
|
||||||
|
|
||||||
|
## `resolvePlayUrl` 返回格式
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
url: 'https://real-url.m3u8',
|
||||||
|
type: 'auto',
|
||||||
|
headers: {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最简单的搜索例子
|
||||||
|
|
||||||
|
```js
|
||||||
|
async search(ctx, { keyword, page, sourceId }) {
|
||||||
|
const data = await ctx.request.getJson('https://example.com/api/search', {
|
||||||
|
query: { wd: keyword, pg: page }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: (data.list || []).map((item) => ({
|
||||||
|
id: String(item.id),
|
||||||
|
title: item.title,
|
||||||
|
poster: item.pic || '',
|
||||||
|
year: item.year || '',
|
||||||
|
desc: item.desc || ''
|
||||||
|
})),
|
||||||
|
page,
|
||||||
|
pageCount: data.pagecount || 1,
|
||||||
|
total: data.total || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最简单的播放解析例子
|
||||||
|
|
||||||
|
```js
|
||||||
|
async resolvePlayUrl(ctx, { playUrl }) {
|
||||||
|
return {
|
||||||
|
url: playUrl,
|
||||||
|
type: 'auto',
|
||||||
|
headers: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 导入格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "demo",
|
||||||
|
"name": "演示脚本",
|
||||||
|
"description": "test",
|
||||||
|
"enabled": true,
|
||||||
|
"code": "return { ... }"
|
||||||
|
}
|
||||||
|
```
|
||||||
88
VERCEL_DEPLOYMENT.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Vercel 部署指南
|
||||||
|
|
||||||
|
本项目已支持使用 **Vercel Postgres** 作为数据库后端,可在 Vercel 平台上稳定部署。
|
||||||
|
|
||||||
|
## 环境变量配置
|
||||||
|
|
||||||
|
在 Vercel 项目设置中添加以下环境变量:
|
||||||
|
|
||||||
|
### 必需的环境变量
|
||||||
|
|
||||||
|
| 变量名 | 说明 | 示例值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| `NEXT_PUBLIC_STORAGE_TYPE` | 存储类型 | `postgres` |
|
||||||
|
| `POSTGRES_URL` | Vercel Postgres 连接字符串 | `postgres://...` |
|
||||||
|
| `USERNAME` | 管理员用户名 | `admin` |
|
||||||
|
| `PASSWORD` | 管理员密码 | `your_password` |
|
||||||
|
|
||||||
|
### Vercel Postgres 连接字符串
|
||||||
|
|
||||||
|
Vercel Postgres 会自动提供以下环境变量:
|
||||||
|
- `POSTGRES_URL` - 完整连接字符串
|
||||||
|
- `POSTGRES_PRISMA_URL` - Prisma 兼容连接字符串
|
||||||
|
- `POSTGRES_URL_NON_POOLING` - 无连接池连接字符串
|
||||||
|
- `POSTGRES_USER` - 数据库用户名
|
||||||
|
- `POSTGRES_HOST` - 数据库主机
|
||||||
|
- `POSTGRES_PASSWORD` - 数据库密码
|
||||||
|
- `POSTGRES_DATABASE` - 数据库名称
|
||||||
|
|
||||||
|
通常只需要使用 `POSTGRES_URL` 即可。
|
||||||
|
|
||||||
|
## 部署步骤
|
||||||
|
|
||||||
|
### 1. 创建 Vercel Postgres 数据库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 Vercel CLI
|
||||||
|
npm i -g vercel
|
||||||
|
|
||||||
|
# 登录 Vercel
|
||||||
|
vercel login
|
||||||
|
|
||||||
|
# 创建 Postgres 数据库
|
||||||
|
vercel postgres create
|
||||||
|
|
||||||
|
# 选择数据库并连接到项目
|
||||||
|
vercel postgres connect
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 初始化数据库表结构
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行数据库初始化脚本
|
||||||
|
pnpm init:postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 部署到 Vercel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 部署项目
|
||||||
|
vercel --prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能限制
|
||||||
|
|
||||||
|
由于 Vercel 的 serverless 环境限制,以下功能不可用:
|
||||||
|
|
||||||
|
- **观影室** (Watch Room) - 需要 WebSocket 支持,Vercel serverless 不支持长时间运行的连接
|
||||||
|
|
||||||
|
## 存储类型对比
|
||||||
|
|
||||||
|
| 存储类型 | 部署平台 | 数据持久化 | 说明 |
|
||||||
|
|---------|---------|-----------|------|
|
||||||
|
| `localstorage` | 任意 | ❌ 浏览器本地 | 仅用于测试 |
|
||||||
|
| `d1` | Cloudflare | ✅ | Cloudflare D1 数据库 |
|
||||||
|
| `postgres` | Vercel | ✅ | Vercel Postgres 数据库 |
|
||||||
|
| `redis` | 自建服务器 | ✅ | Redis 数据库 |
|
||||||
|
| `upstash` | Vercel | ✅ | Upstash Redis |
|
||||||
|
| `kvrocks` | 自建服务器 | ✅ | Kvrocks 数据库 |
|
||||||
|
|
||||||
|
## 数据迁移
|
||||||
|
|
||||||
|
如果需要从其他存储类型迁移数据到 Vercel Postgres,请使用管理后台的数据迁移功能。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **数据库配额**:Vercel Postgres 免费版有 256 MB 存储限制
|
||||||
|
2. **连接池**:Vercel Postgres 自动管理连接池,无需手动配置
|
||||||
|
3. **冷启动**:首次请求可能需要几秒钟冷启动时间
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
210.1.3
|
216.0.0
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
extends: ['@commitlint/config-conventional'],
|
|
||||||
rules: {
|
|
||||||
// TODO Add Scope Enum Here
|
|
||||||
// 'scope-enum': [2, 'always', ['yourscope', 'yourscope']],
|
|
||||||
'type-enum': [
|
|
||||||
2,
|
|
||||||
'always',
|
|
||||||
[
|
|
||||||
'feat',
|
|
||||||
'fix',
|
|
||||||
'docs',
|
|
||||||
'chore',
|
|
||||||
'style',
|
|
||||||
'refactor',
|
|
||||||
'ci',
|
|
||||||
'test',
|
|
||||||
'perf',
|
|
||||||
'revert',
|
|
||||||
'vercel',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
170
migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- MoonTV Plus - Cloudflare D1 数据库结构
|
||||||
|
-- 版本: 1.0.0
|
||||||
|
-- 创建时间: 2026-01-30
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 1. 用户表
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('owner', 'admin', 'user')),
|
||||||
|
banned INTEGER DEFAULT 0,
|
||||||
|
tags TEXT, -- JSON array: ["vip", "premium"]
|
||||||
|
oidc_sub TEXT UNIQUE,
|
||||||
|
enabled_apis TEXT, -- JSON array: ["api1", "api2"]
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
playrecord_migrated INTEGER DEFAULT 0,
|
||||||
|
favorite_migrated INTEGER DEFAULT 0,
|
||||||
|
skip_migrated INTEGER DEFAULT 0,
|
||||||
|
last_movie_request_time INTEGER,
|
||||||
|
email TEXT,
|
||||||
|
email_notifications INTEGER DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_oidc_sub ON users(oidc_sub) WHERE oidc_sub IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
|
||||||
|
|
||||||
|
-- 2. 播放记录表
|
||||||
|
CREATE TABLE IF NOT EXISTS play_records (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL, -- format: "source+id" (e.g., "tmdb+12345")
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
cover TEXT,
|
||||||
|
year TEXT,
|
||||||
|
episode_index INTEGER NOT NULL,
|
||||||
|
total_episodes INTEGER NOT NULL,
|
||||||
|
play_time INTEGER NOT NULL, -- 播放进度(秒)
|
||||||
|
total_time INTEGER NOT NULL, -- 总时长(秒)
|
||||||
|
save_time INTEGER NOT NULL, -- 保存时间戳
|
||||||
|
search_title TEXT,
|
||||||
|
PRIMARY KEY (username, key),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_play_records_save_time ON play_records(username, save_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_play_records_source ON play_records(username, source_name);
|
||||||
|
|
||||||
|
-- 3. 收藏表
|
||||||
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL, -- format: "source+id"
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
total_episodes INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
year TEXT,
|
||||||
|
cover TEXT,
|
||||||
|
save_time INTEGER NOT NULL,
|
||||||
|
search_title TEXT,
|
||||||
|
origin TEXT CHECK(origin IN ('vod', 'live')),
|
||||||
|
is_completed INTEGER DEFAULT 0,
|
||||||
|
vod_remarks TEXT,
|
||||||
|
PRIMARY KEY (username, key),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_favorites_save_time ON favorites(username, save_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_favorites_source ON favorites(username, source_name);
|
||||||
|
|
||||||
|
-- 4. 搜索历史表
|
||||||
|
CREATE TABLE IF NOT EXISTS search_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
keyword TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE,
|
||||||
|
UNIQUE(username, keyword)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_search_history_user_time ON search_history(username, timestamp DESC);
|
||||||
|
|
||||||
|
-- 5. 跳过配置表(片头片尾)
|
||||||
|
CREATE TABLE IF NOT EXISTS skip_configs (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL, -- format: "source+id"
|
||||||
|
enable INTEGER NOT NULL DEFAULT 1,
|
||||||
|
intro_time INTEGER NOT NULL DEFAULT 0, -- 片头时长(秒)
|
||||||
|
outro_time INTEGER NOT NULL DEFAULT 0, -- 片尾时长(秒)
|
||||||
|
PRIMARY KEY (username, key),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. 弹幕过滤配置表
|
||||||
|
CREATE TABLE IF NOT EXISTS danmaku_filter_configs (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
rules TEXT NOT NULL, -- JSON array: [{"keyword": "xxx", "type": "normal", "enabled": true}]
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 7. 通知表
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('favorite_update', 'system', 'announcement', 'movie_request', 'request_fulfilled')),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
read INTEGER DEFAULT 0,
|
||||||
|
metadata TEXT, -- JSON object
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_time ON notifications(username, timestamp DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(username, read, timestamp DESC);
|
||||||
|
|
||||||
|
-- 8. 求片请求表
|
||||||
|
CREATE TABLE IF NOT EXISTS movie_requests (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tmdb_id INTEGER,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
year TEXT,
|
||||||
|
media_type TEXT NOT NULL CHECK(media_type IN ('movie', 'tv')),
|
||||||
|
season INTEGER,
|
||||||
|
poster TEXT,
|
||||||
|
overview TEXT,
|
||||||
|
requested_by TEXT NOT NULL, -- JSON array: ["user1", "user2"]
|
||||||
|
request_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pending', 'fulfilled')) DEFAULT 'pending',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
fulfilled_at INTEGER,
|
||||||
|
fulfilled_source TEXT,
|
||||||
|
fulfilled_id TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_movie_requests_status ON movie_requests(status, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_movie_requests_tmdb ON movie_requests(tmdb_id) WHERE tmdb_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- 9. 用户求片关联表(用于快速查询用户的求片记录)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_movie_requests (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
request_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (username, request_id),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (request_id) REFERENCES movie_requests(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_movie_requests_user ON user_movie_requests(username);
|
||||||
|
|
||||||
|
-- 10. 全局配置表(键值对存储)
|
||||||
|
CREATE TABLE IF NOT EXISTS global_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 11. 管理员配置表(单例)
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_config (
|
||||||
|
id INTEGER PRIMARY KEY CHECK(id = 1), -- 确保只有一条记录
|
||||||
|
config TEXT NOT NULL, -- JSON object
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 12. 收藏更新检查时间表
|
||||||
|
CREATE TABLE IF NOT EXISTS favorite_check_times (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
last_check_time INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
66
migrations/002_add_music_play_records.sql
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- MoonTV Plus - 音乐模块数据表
|
||||||
|
-- 版本: 1.2.0
|
||||||
|
-- 创建时间: 2026-02-01
|
||||||
|
-- 更新时间: 2026-02-02
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 音乐播放记录表
|
||||||
|
CREATE TABLE IF NOT EXISTS music_play_records (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL, -- format: "platform+id" (e.g., "netease+12345")
|
||||||
|
platform TEXT NOT NULL CHECK(platform IN ('netease', 'qq', 'kuwo')), -- 音乐平台
|
||||||
|
song_id TEXT NOT NULL, -- 歌曲ID
|
||||||
|
name TEXT NOT NULL, -- 歌曲名
|
||||||
|
artist TEXT NOT NULL, -- 艺术家
|
||||||
|
album TEXT, -- 专辑(可选)
|
||||||
|
pic TEXT, -- 封面图URL(可选)
|
||||||
|
play_time REAL NOT NULL DEFAULT 0, -- 播放进度(秒)
|
||||||
|
duration REAL NOT NULL DEFAULT 0, -- 总时长(秒)
|
||||||
|
save_time INTEGER NOT NULL, -- 保存时间戳
|
||||||
|
PRIMARY KEY (username, key),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建索引以提高查询性能
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_music_play_records_username ON music_play_records(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_music_play_records_save_time ON music_play_records(username, save_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_music_play_records_platform ON music_play_records(username, platform);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 音乐歌单表
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 音乐歌单表
|
||||||
|
CREATE TABLE IF NOT EXISTS music_playlists (
|
||||||
|
id TEXT NOT NULL, -- 歌单ID (UUID)
|
||||||
|
username TEXT NOT NULL, -- 用户名
|
||||||
|
name TEXT NOT NULL, -- 歌单名称
|
||||||
|
description TEXT, -- 歌单描述(可选)
|
||||||
|
cover TEXT, -- 歌单封面(可选,使用第一首歌的封面)
|
||||||
|
created_at INTEGER NOT NULL, -- 创建时间戳
|
||||||
|
updated_at INTEGER NOT NULL, -- 更新时间戳
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 音乐歌单歌曲关联表
|
||||||
|
CREATE TABLE IF NOT EXISTS music_playlist_songs (
|
||||||
|
playlist_id TEXT NOT NULL, -- 歌单ID
|
||||||
|
platform TEXT NOT NULL CHECK(platform IN ('netease', 'qq', 'kuwo')), -- 音乐平台
|
||||||
|
song_id TEXT NOT NULL, -- 歌曲ID
|
||||||
|
name TEXT NOT NULL, -- 歌曲名
|
||||||
|
artist TEXT NOT NULL, -- 艺术家
|
||||||
|
album TEXT, -- 专辑(可选)
|
||||||
|
pic TEXT, -- 封面图URL(可选)
|
||||||
|
duration REAL NOT NULL DEFAULT 0, -- 总时长(秒)
|
||||||
|
added_at INTEGER NOT NULL, -- 添加时间戳
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0, -- 排序顺序
|
||||||
|
PRIMARY KEY (playlist_id, platform, song_id),
|
||||||
|
FOREIGN KEY (playlist_id) REFERENCES music_playlists(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建索引以提高查询性能
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_music_playlists_username ON music_playlists(username, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_music_playlist_songs_playlist ON music_playlist_songs(playlist_id, sort_order ASC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_music_playlist_songs_added_at ON music_playlist_songs(playlist_id, added_at DESC);
|
||||||
8
migrations/003_add_new_episodes_to_play_records.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 添加 new_episodes 字段到播放记录表
|
||||||
|
-- 版本: 1.0.1
|
||||||
|
-- 创建时间: 2026-02-19
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 为播放记录表添加 new_episodes 字段(用于显示新增剧集提示)
|
||||||
|
ALTER TABLE play_records ADD COLUMN new_episodes INTEGER;
|
||||||
11
migrations/004_add_tvbox_subscribe_token.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 添加 TVBox 订阅 token 字段
|
||||||
|
-- 版本: 004
|
||||||
|
-- 创建时间: 2026-02-25
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 为 users 表添加 tvbox_subscribe_token 字段
|
||||||
|
ALTER TABLE users ADD COLUMN tvbox_subscribe_token TEXT;
|
||||||
|
|
||||||
|
-- 创建索引以加速 token 查询
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_tvbox_token ON users(tvbox_subscribe_token) WHERE tvbox_subscribe_token IS NOT NULL;
|
||||||
170
migrations/postgres/001_initial_schema.sql
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- MoonTV Plus - Vercel Postgres 数据库结构
|
||||||
|
-- 版本: 1.0.0
|
||||||
|
-- 创建时间: 2026-02-07
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 1. 用户表
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('owner', 'admin', 'user')),
|
||||||
|
banned INTEGER DEFAULT 0,
|
||||||
|
tags TEXT, -- JSON array: ["vip", "premium"]
|
||||||
|
oidc_sub TEXT UNIQUE,
|
||||||
|
enabled_apis TEXT, -- JSON array: ["api1", "api2"]
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
playrecord_migrated INTEGER DEFAULT 0,
|
||||||
|
favorite_migrated INTEGER DEFAULT 0,
|
||||||
|
skip_migrated INTEGER DEFAULT 0,
|
||||||
|
last_movie_request_time BIGINT,
|
||||||
|
email TEXT,
|
||||||
|
email_notifications INTEGER DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_oidc_sub ON users(oidc_sub) WHERE oidc_sub IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
|
||||||
|
|
||||||
|
-- 2. 播放记录表
|
||||||
|
CREATE TABLE IF NOT EXISTS play_records (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL, -- format: "source+id" (e.g., "tmdb+12345")
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
cover TEXT,
|
||||||
|
year TEXT,
|
||||||
|
episode_index INTEGER NOT NULL,
|
||||||
|
total_episodes INTEGER NOT NULL,
|
||||||
|
play_time BIGINT NOT NULL, -- 播放进度(秒)
|
||||||
|
total_time BIGINT NOT NULL, -- 总时长(秒)
|
||||||
|
save_time BIGINT NOT NULL, -- 保存时间戳
|
||||||
|
search_title TEXT,
|
||||||
|
PRIMARY KEY (username, key),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_play_records_save_time ON play_records(username, save_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_play_records_source ON play_records(username, source_name);
|
||||||
|
|
||||||
|
-- 3. 收藏表
|
||||||
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL, -- format: "source+id"
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
total_episodes INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
year TEXT,
|
||||||
|
cover TEXT,
|
||||||
|
save_time BIGINT NOT NULL,
|
||||||
|
search_title TEXT,
|
||||||
|
origin TEXT CHECK(origin IN ('vod', 'live')),
|
||||||
|
is_completed INTEGER DEFAULT 0,
|
||||||
|
vod_remarks TEXT,
|
||||||
|
PRIMARY KEY (username, key),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_favorites_save_time ON favorites(username, save_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_favorites_source ON favorites(username, source_name);
|
||||||
|
|
||||||
|
-- 4. 搜索历史表
|
||||||
|
CREATE TABLE IF NOT EXISTS search_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
keyword TEXT NOT NULL,
|
||||||
|
timestamp BIGINT NOT NULL,
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE,
|
||||||
|
UNIQUE(username, keyword)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_search_history_user_time ON search_history(username, timestamp DESC);
|
||||||
|
|
||||||
|
-- 5. 跳过配置表(片头片尾)
|
||||||
|
CREATE TABLE IF NOT EXISTS skip_configs (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL, -- format: "source+id"
|
||||||
|
enable INTEGER NOT NULL DEFAULT 1,
|
||||||
|
intro_time INTEGER NOT NULL DEFAULT 0, -- 片头时长(秒)
|
||||||
|
outro_time INTEGER NOT NULL DEFAULT 0, -- 片尾时长(秒)
|
||||||
|
PRIMARY KEY (username, key),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. 弹幕过滤配置表
|
||||||
|
CREATE TABLE IF NOT EXISTS danmaku_filter_configs (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
rules TEXT NOT NULL, -- JSON array: [{"keyword": "xxx", "type": "normal", "enabled": true}]
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 7. 通知表
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('favorite_update', 'system', 'announcement', 'movie_request', 'request_fulfilled')),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
timestamp BIGINT NOT NULL,
|
||||||
|
read INTEGER DEFAULT 0,
|
||||||
|
metadata TEXT, -- JSON object
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_time ON notifications(username, timestamp DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(username, read, timestamp DESC);
|
||||||
|
|
||||||
|
-- 8. 求片请求表
|
||||||
|
CREATE TABLE IF NOT EXISTS movie_requests (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tmdb_id INTEGER,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
year TEXT,
|
||||||
|
media_type TEXT NOT NULL CHECK(media_type IN ('movie', 'tv')),
|
||||||
|
season INTEGER,
|
||||||
|
poster TEXT,
|
||||||
|
overview TEXT,
|
||||||
|
requested_by TEXT NOT NULL, -- JSON array: ["user1", "user2"]
|
||||||
|
request_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pending', 'fulfilled')) DEFAULT 'pending',
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
fulfilled_at BIGINT,
|
||||||
|
fulfilled_source TEXT,
|
||||||
|
fulfilled_id TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_movie_requests_status ON movie_requests(status, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_movie_requests_tmdb ON movie_requests(tmdb_id) WHERE tmdb_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- 9. 用户求片关联表(用于快速查询用户的求片记录)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_movie_requests (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
request_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (username, request_id),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (request_id) REFERENCES movie_requests(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_movie_requests_user ON user_movie_requests(username);
|
||||||
|
|
||||||
|
-- 10. 全局配置表(键值对存储)
|
||||||
|
CREATE TABLE IF NOT EXISTS global_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 11. 管理员配置表(单例)
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_config (
|
||||||
|
id INTEGER PRIMARY KEY CHECK(id = 1), -- 确保只有一条记录
|
||||||
|
config TEXT NOT NULL, -- JSON object
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 12. 收藏更新检查时间表
|
||||||
|
CREATE TABLE IF NOT EXISTS favorite_check_times (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
last_check_time BIGINT NOT NULL,
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
65
migrations/postgres/002_add_music.sql
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- MoonTV Plus - 音乐模块数据表 (PostgreSQL)
|
||||||
|
-- 版本: 1.2.0
|
||||||
|
-- 创建时间: 2026-02-08
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 音乐播放记录表
|
||||||
|
CREATE TABLE IF NOT EXISTS music_play_records (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL, -- format: "platform+id" (e.g., "netease+12345")
|
||||||
|
platform TEXT NOT NULL CHECK(platform IN ('netease', 'qq', 'kuwo')), -- 音乐平台
|
||||||
|
song_id TEXT NOT NULL, -- 歌曲ID
|
||||||
|
name TEXT NOT NULL, -- 歌曲名
|
||||||
|
artist TEXT NOT NULL, -- 艺术家
|
||||||
|
album TEXT, -- 专辑(可选)
|
||||||
|
pic TEXT, -- 封面图URL(可选)
|
||||||
|
play_time REAL NOT NULL DEFAULT 0, -- 播放进度(秒)
|
||||||
|
duration REAL NOT NULL DEFAULT 0, -- 总时长(秒)
|
||||||
|
save_time BIGINT NOT NULL, -- 保存时间戳
|
||||||
|
PRIMARY KEY (username, key),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建索引以提高查询性能
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_music_play_records_username ON music_play_records(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_music_play_records_save_time ON music_play_records(username, save_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_music_play_records_platform ON music_play_records(username, platform);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 音乐歌单表
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 音乐歌单表
|
||||||
|
CREATE TABLE IF NOT EXISTS music_playlists (
|
||||||
|
id TEXT NOT NULL, -- 歌单ID (UUID)
|
||||||
|
username TEXT NOT NULL, -- 用户名
|
||||||
|
name TEXT NOT NULL, -- 歌单名称
|
||||||
|
description TEXT, -- 歌单描述(可选)
|
||||||
|
cover TEXT, -- 歌单封面(可选,使用第一首歌的封面)
|
||||||
|
created_at BIGINT NOT NULL, -- 创建时间戳
|
||||||
|
updated_at BIGINT NOT NULL, -- 更新时间戳
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 音乐歌单歌曲关联表
|
||||||
|
CREATE TABLE IF NOT EXISTS music_playlist_songs (
|
||||||
|
playlist_id TEXT NOT NULL, -- 歌单ID
|
||||||
|
platform TEXT NOT NULL CHECK(platform IN ('netease', 'qq', 'kuwo')), -- 音乐平台
|
||||||
|
song_id TEXT NOT NULL, -- 歌曲ID
|
||||||
|
name TEXT NOT NULL, -- 歌曲名
|
||||||
|
artist TEXT NOT NULL, -- 艺术家
|
||||||
|
album TEXT, -- 专辑(可选)
|
||||||
|
pic TEXT, -- 封面图URL(可选)
|
||||||
|
duration REAL NOT NULL DEFAULT 0, -- 总时长(秒)
|
||||||
|
added_at BIGINT NOT NULL, -- 添加时间戳
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0, -- 排序顺序
|
||||||
|
PRIMARY KEY (playlist_id, platform, song_id),
|
||||||
|
FOREIGN KEY (playlist_id) REFERENCES music_playlists(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建索引以提高查询性能
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_music_playlists_username ON music_playlists(username, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_music_playlist_songs_playlist ON music_playlist_songs(playlist_id, sort_order ASC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_music_playlist_songs_added_at ON music_playlist_songs(playlist_id, added_at DESC);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 添加 new_episodes 字段到播放记录表
|
||||||
|
-- 版本: 1.0.1
|
||||||
|
-- 创建时间: 2026-02-19
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 为播放记录表添加 new_episodes 字段(用于显示新增剧集提示)
|
||||||
|
ALTER TABLE play_records ADD COLUMN new_episodes BIGINT;
|
||||||
14
migrations/postgres/004_add_tvbox_subscribe_token.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 添加 TVBox 订阅 token 字段
|
||||||
|
-- 版本: 004
|
||||||
|
-- 创建时间: 2026-02-25
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 为 users 表添加 tvbox_subscribe_token 字段
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS tvbox_subscribe_token TEXT;
|
||||||
|
|
||||||
|
-- 创建索引以加速 token 查询
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_tvbox_token ON users(tvbox_subscribe_token) WHERE tvbox_subscribe_token IS NOT NULL;
|
||||||
|
|
||||||
|
-- 添加注释
|
||||||
|
COMMENT ON COLUMN users.tvbox_subscribe_token IS 'TVBox订阅token,用于用户独立订阅';
|
||||||
@@ -35,7 +35,7 @@ const nextConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
webpack(config) {
|
webpack(config, { isServer }) {
|
||||||
// Grab the existing rule that handles SVG imports
|
// Grab the existing rule that handles SVG imports
|
||||||
const fileLoaderRule = config.module.rules.find((rule) =>
|
const fileLoaderRule = config.module.rules.find((rule) =>
|
||||||
rule.test?.test?.('.svg')
|
rule.test?.test?.('.svg')
|
||||||
@@ -71,6 +71,25 @@ const nextConfig = {
|
|||||||
crypto: false,
|
crypto: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Exclude better-sqlite3, D1, and Postgres modules from client-side bundle
|
||||||
|
if (!isServer) {
|
||||||
|
config.externals = config.externals || [];
|
||||||
|
config.externals.push({
|
||||||
|
'better-sqlite3': 'commonjs better-sqlite3',
|
||||||
|
'@vercel/postgres': 'commonjs @vercel/postgres',
|
||||||
|
'pg': 'commonjs pg',
|
||||||
|
});
|
||||||
|
|
||||||
|
config.resolve.alias = {
|
||||||
|
...config.resolve.alias,
|
||||||
|
'better-sqlite3': false,
|
||||||
|
'@/lib/d1.db': false,
|
||||||
|
'@/lib/d1-adapter': false,
|
||||||
|
'@/lib/postgres.db': false,
|
||||||
|
'@/lib/postgres-adapter': false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
28
package.json
@@ -19,8 +19,10 @@
|
|||||||
"format:check": "prettier -c .",
|
"format:check": "prettier -c .",
|
||||||
"gen:manifest": "node scripts/generate-manifest.js",
|
"gen:manifest": "node scripts/generate-manifest.js",
|
||||||
"postbuild": "echo 'Build completed - sitemap generation disabled'",
|
"postbuild": "echo 'Build completed - sitemap generation disabled'",
|
||||||
"prepare": "husky install",
|
"watch-room:server": "node server/watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY",
|
||||||
"watch-room:server": "node server/watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY"
|
"init:sqlite": "node scripts/init-sqlite.js",
|
||||||
|
"init:postgres": "node scripts/init-postgres.js",
|
||||||
|
"db:reset": "rm -f .data/moontv.db .data/moontv.db-shm .data/moontv.db-wal && node scripts/init-sqlite.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -32,10 +34,12 @@
|
|||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/nprogress": "^0.2.3",
|
"@types/nprogress": "^0.2.3",
|
||||||
"@upstash/redis": "^1.25.0",
|
"@upstash/redis": "^1.25.0",
|
||||||
|
"@vercel/postgres": "^0.10.0",
|
||||||
"@vidstack/react": "^1.12.13",
|
"@vidstack/react": "^1.12.13",
|
||||||
"anime4k-webgpu": "^1.0.0",
|
"anime4k-webgpu": "^1.0.0",
|
||||||
"artplayer": "^5.3.0",
|
"artplayer": "^5.3.0",
|
||||||
"artplayer-plugin-danmuku": "^5.2.0",
|
"artplayer-plugin-danmuku": "^5.2.0",
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
"bs58": "^6.0.0",
|
"bs58": "^6.0.0",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
@@ -61,9 +65,9 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-window": "^2.2.3",
|
|
||||||
"redis": "^4.6.7",
|
"redis": "^4.6.7",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^3.0.1",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"swiper": "^11.2.8",
|
"swiper": "^11.2.8",
|
||||||
@@ -73,22 +77,21 @@
|
|||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^16.3.0",
|
|
||||||
"@commitlint/config-conventional": "^16.2.4",
|
|
||||||
"@opennextjs/cloudflare": "^1.15.1",
|
"@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",
|
||||||
"@testing-library/jest-dom": "^5.17.0",
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
"@testing-library/react": "^15.0.7",
|
"@testing-library/react": "^15.0.7",
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
"@types/bs58": "^5.0.0",
|
"@types/bs58": "^5.0.0",
|
||||||
"@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/nodemailer": "^7.0.5",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@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/testing-library__jest-dom": "^5.14.9",
|
"@types/testing-library__jest-dom": "^5.14.9",
|
||||||
"@types/xml2js": "^0.4.14",
|
"@types/xml2js": "^0.4.14",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
@@ -99,9 +102,7 @@
|
|||||||
"eslint-config-prettier": "^8.10.0",
|
"eslint-config-prettier": "^8.10.0",
|
||||||
"eslint-plugin-simple-import-sort": "^7.0.0",
|
"eslint-plugin-simple-import-sort": "^7.0.0",
|
||||||
"eslint-plugin-unused-imports": "^2.0.0",
|
"eslint-plugin-unused-imports": "^2.0.0",
|
||||||
"husky": "^7.0.4",
|
|
||||||
"jest": "^27.5.1",
|
"jest": "^27.5.1",
|
||||||
"lint-staged": "^12.5.0",
|
|
||||||
"next-router-mock": "^0.9.0",
|
"next-router-mock": "^0.9.0",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"prettier": "^2.8.8",
|
"prettier": "^2.8.8",
|
||||||
@@ -111,14 +112,5 @@
|
|||||||
"vercel": "^50.4.10",
|
"vercel": "^50.4.10",
|
||||||
"webpack-obfuscator": "^3.5.1"
|
"webpack-obfuscator": "^3.5.1"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
|
||||||
"**/*.{js,jsx,ts,tsx}": [
|
|
||||||
"eslint --max-warnings=0",
|
|
||||||
"prettier -w"
|
|
||||||
],
|
|
||||||
"**/*.{json,css,scss,md,webmanifest}": [
|
|
||||||
"prettier -w"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"packageManager": "pnpm@10.14.0"
|
"packageManager": "pnpm@10.14.0"
|
||||||
}
|
}
|
||||||
|
|||||||
1433
pnpm-lock.yaml
generated
BIN
public/icons/OrionTV.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/icons/Selene.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/icons/github.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/icons/gitlab.png
Normal file
|
After Width: | Height: | Size: 591 B |
BIN
public/icons/google.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/icons/linuxdo.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/icons/microsoft.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/icons/q/emilia.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/icons/q/irena.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
public/icons/q/renako.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
public/tvbox/custom_spider.jar
Normal file
16
render.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
- type: web
|
||||||
|
name: moontvplus
|
||||||
|
runtime: image
|
||||||
|
plan: free
|
||||||
|
image:
|
||||||
|
url: ghcr.io/mtvpls/moontvplus:latest
|
||||||
|
envVars:
|
||||||
|
- key: USERNAME
|
||||||
|
sync: false
|
||||||
|
- key: PASSWORD
|
||||||
|
sync: false
|
||||||
|
- key: NEXT_PUBLIC_SITE_NAME
|
||||||
|
value: MoonTVPlus
|
||||||
|
- key: CRON_PASSWORD
|
||||||
|
value: mtvpls
|
||||||
@@ -11,7 +11,7 @@ const publicDir = path.join(projectRoot, 'public');
|
|||||||
const manifestPath = path.join(publicDir, 'manifest.json');
|
const manifestPath = path.join(publicDir, 'manifest.json');
|
||||||
|
|
||||||
// 从环境变量获取站点名称
|
// 从环境变量获取站点名称
|
||||||
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
|
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTVPlus';
|
||||||
|
|
||||||
// manifest.json 模板
|
// manifest.json 模板
|
||||||
const manifestTemplate = {
|
const manifestTemplate = {
|
||||||
|
|||||||
92
scripts/init-postgres.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Vercel Postgres 数据库初始化脚本
|
||||||
|
*
|
||||||
|
* 创建数据库表结构并初始化默认管理员用户
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { sql } = require('@vercel/postgres');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// SHA-256 加密密码
|
||||||
|
function hashPassword(password) {
|
||||||
|
return crypto.createHash('sha256').update(password).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📦 Initializing Vercel Postgres database...');
|
||||||
|
|
||||||
|
// 读取迁移脚本
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// 获取所有迁移文件
|
||||||
|
const migrationsDir = path.join(__dirname, '../migrations/postgres');
|
||||||
|
if (!fs.existsSync(migrationsDir)) {
|
||||||
|
console.error('❌ Migrations directory not found:', migrationsDir);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取并排序所有 .sql 文件
|
||||||
|
const migrationFiles = fs.readdirSync(migrationsDir)
|
||||||
|
.filter(file => file.endsWith('.sql'))
|
||||||
|
.sort(); // 按文件名排序,确保按顺序执行
|
||||||
|
|
||||||
|
if (migrationFiles.length === 0) {
|
||||||
|
console.error('❌ No migration files found in:', migrationsDir);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📄 Found ${migrationFiles.length} migration file(s):`, migrationFiles.join(', '));
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
// 执行所有迁移脚本
|
||||||
|
console.log('🔧 Running database migrations...');
|
||||||
|
|
||||||
|
for (const migrationFile of migrationFiles) {
|
||||||
|
const sqlPath = path.join(migrationsDir, migrationFile);
|
||||||
|
console.log(` ⏳ Executing ${migrationFile}...`);
|
||||||
|
|
||||||
|
const schemaSql = fs.readFileSync(sqlPath, 'utf8');
|
||||||
|
|
||||||
|
// 将 SQL 脚本按语句分割并逐个执行
|
||||||
|
const statements = schemaSql
|
||||||
|
.split(';')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(s => s.length > 0);
|
||||||
|
|
||||||
|
for (const statement of statements) {
|
||||||
|
await sql.query(statement);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ ${migrationFile} executed successfully`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ All migrations completed successfully!');
|
||||||
|
|
||||||
|
// 创建默认管理员用户
|
||||||
|
const username = process.env.USERNAME || 'admin';
|
||||||
|
const password = process.env.PASSWORD || '123456789';
|
||||||
|
const passwordHash = hashPassword(password);
|
||||||
|
|
||||||
|
console.log('👤 Creating default admin user...');
|
||||||
|
await sql`
|
||||||
|
INSERT INTO users (username, password_hash, role, created_at, playrecord_migrated, favorite_migrated, skip_migrated)
|
||||||
|
VALUES (${username}, ${passwordHash}, 'owner', ${Date.now()}, 1, 1, 1)
|
||||||
|
ON CONFLICT (username) DO NOTHING
|
||||||
|
`;
|
||||||
|
console.log(`✅ Default admin user created: ${username}`);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('🎉 Vercel Postgres database initialized successfully!');
|
||||||
|
console.log('');
|
||||||
|
console.log('Next steps:');
|
||||||
|
console.log('1. Set NEXT_PUBLIC_STORAGE_TYPE=postgres in .env');
|
||||||
|
console.log('2. Set POSTGRES_URL environment variable');
|
||||||
|
console.log('3. Run: npm run dev');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Initialization failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
62
scripts/init-sqlite.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// SHA-256 加密密码(与 Redis 保持一致)
|
||||||
|
function hashPassword(password) {
|
||||||
|
return crypto.createHash('sha256').update(password).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 .data 目录存在
|
||||||
|
const dataDir = path.join(__dirname, '../.data');
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建数据库
|
||||||
|
const dbPath = path.join(dataDir, 'moontv.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
console.log('📦 Initializing SQLite database for development...');
|
||||||
|
console.log('📍 Database location:', dbPath);
|
||||||
|
|
||||||
|
// 读取迁移脚本
|
||||||
|
const migrationPath = path.join(__dirname, '../migrations/001_initial_schema.sql');
|
||||||
|
if (!fs.existsSync(migrationPath)) {
|
||||||
|
console.error('❌ Migration file not found:', migrationPath);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = fs.readFileSync(migrationPath, 'utf8');
|
||||||
|
|
||||||
|
// 执行迁移
|
||||||
|
try {
|
||||||
|
db.exec(sql);
|
||||||
|
console.log('✅ Database schema created successfully!');
|
||||||
|
|
||||||
|
// 创建默认管理员用户(可选)
|
||||||
|
const username = process.env.USERNAME || 'admin';
|
||||||
|
const password = process.env.PASSWORD || '123456789';
|
||||||
|
const passwordHash = hashPassword(password);
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO users (username, password_hash, role, created_at, playrecord_migrated, favorite_migrated, skip_migrated)
|
||||||
|
VALUES (?, ?, 'owner', ?, 1, 1, 1)
|
||||||
|
`);
|
||||||
|
|
||||||
|
stmt.run(username, passwordHash, Date.now());
|
||||||
|
console.log(`✅ Default admin user created: ${username}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Migration failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('🎉 SQLite database initialized successfully!');
|
||||||
|
console.log('');
|
||||||
|
console.log('Next steps:');
|
||||||
|
console.log('1. Set NEXT_PUBLIC_STORAGE_TYPE=d1 in .env');
|
||||||
|
console.log('2. Run: npm run dev');
|
||||||
229
src/app/advanced-recommendation/page.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Blend, Loader2 } from 'lucide-react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { SearchResult } from '@/lib/types';
|
||||||
|
|
||||||
|
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||||
|
import PageLayout from '@/components/PageLayout';
|
||||||
|
import VideoCard from '@/components/VideoCard';
|
||||||
|
|
||||||
|
interface ScriptSourceOption {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdvancedRecommendationPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
|
const initialUrlSourceRef = useRef(searchParams.get('source') || '');
|
||||||
|
|
||||||
|
const [sources, setSources] = useState<ScriptSourceOption[]>([]);
|
||||||
|
const [selectedSource, setSelectedSource] = useState('');
|
||||||
|
const [videos, setVideos] = useState<SearchResult[]>([]);
|
||||||
|
const [isLoadingSources, setIsLoadingSources] = useState(true);
|
||||||
|
const [isLoadingVideos, setIsLoadingVideos] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
const hasSyncedUrlRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSources = async () => {
|
||||||
|
setIsLoadingSources(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/advanced-recommendation/sources');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || '获取脚本源失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSources: ScriptSourceOption[] = Array.isArray(data.sources)
|
||||||
|
? data.sources
|
||||||
|
: [];
|
||||||
|
setSources(nextSources);
|
||||||
|
|
||||||
|
const initialSource =
|
||||||
|
nextSources.find((item) => item.key === initialUrlSourceRef.current)
|
||||||
|
?.key ||
|
||||||
|
nextSources[0]?.key ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
setSelectedSource(initialSource);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '获取脚本源失败');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingSources(false);
|
||||||
|
initializedRef.current = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSources();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initializedRef.current || !selectedSource) return;
|
||||||
|
if (!hasSyncedUrlRef.current) {
|
||||||
|
hasSyncedUrlRef.current = true;
|
||||||
|
if (initialUrlSourceRef.current === selectedSource) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('source', selectedSource);
|
||||||
|
router.replace(`/advanced-recommendation?${params.toString()}`, {
|
||||||
|
scroll: false,
|
||||||
|
});
|
||||||
|
}, [selectedSource, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedSource) return;
|
||||||
|
|
||||||
|
setVideos([]);
|
||||||
|
setPage(1);
|
||||||
|
setHasMore(true);
|
||||||
|
setError('');
|
||||||
|
}, [selectedSource]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedSource) return;
|
||||||
|
|
||||||
|
const fetchVideos = async () => {
|
||||||
|
setIsLoadingVideos(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/advanced-recommendation/videos?source=${encodeURIComponent(selectedSource)}&page=${page}`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || '获取推荐失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextResults = Array.isArray(data.results) ? data.results : [];
|
||||||
|
setVideos((prev) => (page === 1 ? nextResults : [...prev, ...nextResults]));
|
||||||
|
setHasMore(Number(data.page || page) < Number(data.pageCount || 1));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '获取推荐失败');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingVideos(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchVideos();
|
||||||
|
}, [selectedSource, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loadMoreRef.current || !hasMore || isLoadingVideos) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) {
|
||||||
|
setPage((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(loadMoreRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasMore, isLoadingVideos]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout activePath='/advanced-recommendation'>
|
||||||
|
<div className='px-4 sm:px-10 py-4 sm:py-8 mb-10'>
|
||||||
|
<div className='mb-6'>
|
||||||
|
<h1 className='text-2xl font-bold text-gray-800 dark:text-gray-200 flex items-center gap-2'>
|
||||||
|
<Blend className='w-6 h-6 text-green-500' />
|
||||||
|
高级推荐
|
||||||
|
</h1>
|
||||||
|
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
浏览视频源脚本提供的推荐内容
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='max-w-6xl mx-auto space-y-6'>
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3'>
|
||||||
|
选择脚本源
|
||||||
|
</label>
|
||||||
|
{isLoadingSources ? (
|
||||||
|
<div className='flex items-center justify-center h-12 bg-gray-50/80 rounded-lg border border-gray-200/50 dark:bg-gray-800 dark:border-gray-700'>
|
||||||
|
<Loader2 className='h-5 w-5 animate-spin text-gray-400' />
|
||||||
|
<span className='ml-2 text-sm text-gray-500 dark:text-gray-400'>
|
||||||
|
加载脚本源中...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : sources.length === 0 ? (
|
||||||
|
<div className='flex items-center justify-center h-24 rounded-xl border border-dashed border-gray-300 bg-gray-50/70 text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400'>
|
||||||
|
暂无可用的视频源脚本
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='flex justify-center'>
|
||||||
|
<CapsuleSwitch
|
||||||
|
options={sources.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.key,
|
||||||
|
}))}
|
||||||
|
active={selectedSource}
|
||||||
|
onChange={setSelectedSource}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!!error && (
|
||||||
|
<div className='rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600 dark:border-red-900/50 dark:bg-red-900/10 dark:text-red-300'>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoadingSources && sources.length > 0 && (
|
||||||
|
<>
|
||||||
|
{videos.length > 0 ? (
|
||||||
|
<div className='grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4'>
|
||||||
|
{videos.map((video, index) => (
|
||||||
|
<VideoCard
|
||||||
|
key={`${video.source}-${video.id}-${index}`}
|
||||||
|
id={video.id}
|
||||||
|
source={video.source}
|
||||||
|
source_name={video.source_name}
|
||||||
|
title={video.title}
|
||||||
|
poster={video.poster}
|
||||||
|
year={video.year}
|
||||||
|
rate={
|
||||||
|
typeof video.rating === 'number'
|
||||||
|
? String(video.rating)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
douban_id={video.douban_id}
|
||||||
|
tmdb_id={video.tmdb_id}
|
||||||
|
from='source-search'
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !isLoadingVideos && !error ? (
|
||||||
|
<div className='flex items-center justify-center h-32 rounded-xl border border-dashed border-gray-300 bg-gray-50/70 text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400'>
|
||||||
|
当前脚本暂无推荐内容
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoadingVideos && (
|
||||||
|
<div className='flex justify-center py-6'>
|
||||||
|
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={loadMoreRef} className='h-10' />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { parseStringPromise } from 'xml2js';
|
import { parseStringPromise } from 'xml2js';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -48,11 +50,17 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 请求 acg.rip RSS
|
// 请求 acg.rip RSS
|
||||||
const searchUrl = `https://acg.rip/page/${pageNum}.xml?term=${encodeURIComponent(trimmedKeyword)}`;
|
const config = await getConfig();
|
||||||
|
const searchBaseUrl = getMagnetBaseUrl(
|
||||||
|
'https://acg.rip',
|
||||||
|
config.SiteConfig.MagnetAcgripReverseProxy
|
||||||
|
);
|
||||||
|
const searchUrl = `${searchBaseUrl}/page/${pageNum}.xml?term=${encodeURIComponent(trimmedKeyword)}`;
|
||||||
|
|
||||||
const response = await fetch(searchUrl, {
|
const response = await universalMagnetFetch(searchUrl, config.SiteConfig.MagnetProxy, {
|
||||||
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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,4 +131,3 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { parseStringPromise } from 'xml2js';
|
import { parseStringPromise } from 'xml2js';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -56,11 +58,15 @@ export async function POST(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = 'http://share.dmhy.org/topics/rss/rss.xml';
|
const config = await getConfig();
|
||||||
|
const baseUrl = `${getMagnetBaseUrl(
|
||||||
|
'http://share.dmhy.org',
|
||||||
|
config.SiteConfig.MagnetDmhyReverseProxy
|
||||||
|
)}/topics/rss/rss.xml`;
|
||||||
const params = new URLSearchParams({ keyword: trimmedKeyword });
|
const params = new URLSearchParams({ keyword: trimmedKeyword });
|
||||||
const searchUrl = `${baseUrl}?${params.toString()}`;
|
const searchUrl = `${baseUrl}?${params.toString()}`;
|
||||||
|
|
||||||
const response = await fetch(searchUrl, {
|
const response = await universalMagnetFetch(searchUrl, config.SiteConfig.MagnetProxy, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent':
|
'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',
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { parseStringPromise } from 'xml2js';
|
import { parseStringPromise } from 'xml2js';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -62,11 +64,17 @@ export async function POST(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchUrl = `https://mikanani.me/RSS/Search?searchstr=${encodeURIComponent(trimmedKeyword)}`;
|
const config = await getConfig();
|
||||||
|
const searchBaseUrl = getMagnetBaseUrl(
|
||||||
|
'https://mikanani.me',
|
||||||
|
config.SiteConfig.MagnetMikanReverseProxy
|
||||||
|
);
|
||||||
|
const searchUrl = `${searchBaseUrl}/RSS/Search?searchstr=${encodeURIComponent(trimmedKeyword)}`;
|
||||||
|
|
||||||
const response = await fetch(searchUrl, {
|
const response = await universalMagnetFetch(searchUrl, config.SiteConfig.MagnetProxy, {
|
||||||
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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,4 +152,3 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export async function POST(request: NextRequest) {
|
|||||||
EnableHomepageEntry,
|
EnableHomepageEntry,
|
||||||
EnableVideoCardEntry,
|
EnableVideoCardEntry,
|
||||||
EnablePlayPageEntry,
|
EnablePlayPageEntry,
|
||||||
|
EnableAIComments,
|
||||||
AllowRegularUsers,
|
AllowRegularUsers,
|
||||||
Temperature,
|
Temperature,
|
||||||
MaxTokens,
|
MaxTokens,
|
||||||
@@ -93,6 +94,7 @@ export async function POST(request: NextRequest) {
|
|||||||
EnableHomepageEntry: boolean;
|
EnableHomepageEntry: boolean;
|
||||||
EnableVideoCardEntry: boolean;
|
EnableVideoCardEntry: boolean;
|
||||||
EnablePlayPageEntry: boolean;
|
EnablePlayPageEntry: boolean;
|
||||||
|
EnableAIComments: boolean;
|
||||||
AllowRegularUsers: boolean;
|
AllowRegularUsers: boolean;
|
||||||
Temperature?: number;
|
Temperature?: number;
|
||||||
MaxTokens?: number;
|
MaxTokens?: number;
|
||||||
@@ -132,6 +134,7 @@ export async function POST(request: NextRequest) {
|
|||||||
typeof EnableHomepageEntry !== 'boolean' ||
|
typeof EnableHomepageEntry !== 'boolean' ||
|
||||||
typeof EnableVideoCardEntry !== 'boolean' ||
|
typeof EnableVideoCardEntry !== 'boolean' ||
|
||||||
typeof EnablePlayPageEntry !== 'boolean' ||
|
typeof EnablePlayPageEntry !== 'boolean' ||
|
||||||
|
typeof EnableAIComments !== 'boolean' ||
|
||||||
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') ||
|
||||||
@@ -181,6 +184,7 @@ export async function POST(request: NextRequest) {
|
|||||||
EnableHomepageEntry,
|
EnableHomepageEntry,
|
||||||
EnableVideoCardEntry,
|
EnableVideoCardEntry,
|
||||||
EnablePlayPageEntry,
|
EnablePlayPageEntry,
|
||||||
|
EnableAIComments,
|
||||||
AllowRegularUsers,
|
AllowRegularUsers,
|
||||||
Temperature,
|
Temperature,
|
||||||
MaxTokens,
|
MaxTokens,
|
||||||
|
|||||||
51
src/app/api/admin/anime-subscription/[id]/check/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { checkSubscription } from '@/lib/anime-subscription';
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/anime-subscription/[id]/check
|
||||||
|
* 手动触发检查单个订阅
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// 权限检查
|
||||||
|
const authInfo = getAuthInfoFromCookie(req);
|
||||||
|
if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) {
|
||||||
|
return NextResponse.json({ error: '无权限访问' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const subscriptions = config.AnimeSubscriptionConfig?.Subscriptions || [];
|
||||||
|
|
||||||
|
const subscription = subscriptions.find((sub) => sub.id === params.id);
|
||||||
|
if (!subscription) {
|
||||||
|
return NextResponse.json({ error: '订阅不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行检查逻辑(忽略时间间隔限制)
|
||||||
|
const result = await checkSubscription(subscription);
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
await db.saveAdminConfig(config);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('检查追番订阅失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || '检查失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/app/api/admin/anime-subscription/[id]/route.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/* 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';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/anime-subscription/[id]
|
||||||
|
* 更新订阅
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// 权限检查
|
||||||
|
const authInfo = getAuthInfoFromCookie(req);
|
||||||
|
if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) {
|
||||||
|
return NextResponse.json({ error: '无权限访问' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const subscriptions = config.AnimeSubscriptionConfig?.Subscriptions || [];
|
||||||
|
|
||||||
|
const index = subscriptions.findIndex((sub) => sub.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return NextResponse.json({ error: '订阅不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = await req.json();
|
||||||
|
const subscription = subscriptions[index];
|
||||||
|
|
||||||
|
// 更新字段
|
||||||
|
if (updates.title !== undefined) {
|
||||||
|
subscription.title = updates.title.trim();
|
||||||
|
}
|
||||||
|
if (updates.filterText !== undefined) {
|
||||||
|
subscription.filterText = updates.filterText.trim();
|
||||||
|
}
|
||||||
|
if (updates.source !== undefined) {
|
||||||
|
if (!['acgrip', 'mikan', 'dmhy'].includes(updates.source)) {
|
||||||
|
return NextResponse.json({ error: '无效的搜索源' }, { status: 400 });
|
||||||
|
}
|
||||||
|
subscription.source = updates.source;
|
||||||
|
}
|
||||||
|
if (updates.enabled !== undefined) {
|
||||||
|
subscription.enabled = updates.enabled;
|
||||||
|
}
|
||||||
|
if (updates.lastEpisode !== undefined) {
|
||||||
|
// 验证集数为非负整数
|
||||||
|
const episode = parseInt(String(updates.lastEpisode), 10);
|
||||||
|
if (isNaN(episode) || episode < 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '集数必须是非负整数' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
subscription.lastEpisode = episode;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.updatedAt = Date.now();
|
||||||
|
|
||||||
|
await db.saveAdminConfig(config);
|
||||||
|
|
||||||
|
return NextResponse.json(subscription);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('更新追番订阅失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || '更新订阅失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/anime-subscription/[id]
|
||||||
|
* 删除订阅
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// 权限检查
|
||||||
|
const authInfo = getAuthInfoFromCookie(req);
|
||||||
|
if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) {
|
||||||
|
return NextResponse.json({ error: '无权限访问' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const subscriptions = config.AnimeSubscriptionConfig?.Subscriptions || [];
|
||||||
|
|
||||||
|
const index = subscriptions.findIndex((sub) => sub.id === params.id);
|
||||||
|
if (index === -1) {
|
||||||
|
return NextResponse.json({ error: '订阅不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptions.splice(index, 1);
|
||||||
|
await db.saveAdminConfig(config);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('删除追番订阅失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || '删除订阅失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/app/api/admin/anime-subscription/route.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/* 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 { AnimeSubscription } from '@/types/anime-subscription';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/anime-subscription
|
||||||
|
* 获取订阅列表和配置
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 权限检查
|
||||||
|
const authInfo = getAuthInfoFromCookie(req);
|
||||||
|
if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) {
|
||||||
|
return NextResponse.json({ error: '无权限访问' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const animeConfig = config.AnimeSubscriptionConfig || {
|
||||||
|
Enabled: false,
|
||||||
|
Subscriptions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(animeConfig);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取追番订阅配置失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || '获取配置失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/anime-subscription
|
||||||
|
* 创建新订阅
|
||||||
|
*/
|
||||||
|
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 { title, filterText, source, enabled, lastEpisode } =
|
||||||
|
await req.json();
|
||||||
|
|
||||||
|
// 验证必填字段
|
||||||
|
if (!title || !filterText || !source) {
|
||||||
|
return NextResponse.json({ error: '缺少必填字段' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 source
|
||||||
|
if (!['acgrip', 'mikan', 'dmhy'].includes(source)) {
|
||||||
|
return NextResponse.json({ error: '无效的搜索源' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
if (!config.AnimeSubscriptionConfig) {
|
||||||
|
config.AnimeSubscriptionConfig = { Enabled: false, Subscriptions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证集数
|
||||||
|
let episodeNum = 0;
|
||||||
|
if (lastEpisode !== undefined) {
|
||||||
|
episodeNum = parseInt(String(lastEpisode), 10);
|
||||||
|
if (isNaN(episodeNum) || episodeNum < 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '集数必须是非负整数' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新订阅
|
||||||
|
const newSubscription: AnimeSubscription = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: title.trim(),
|
||||||
|
filterText: filterText.trim(),
|
||||||
|
source,
|
||||||
|
enabled: enabled ?? true,
|
||||||
|
lastCheckTime: 0,
|
||||||
|
lastEpisode: episodeNum,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
createdBy: authInfo.username || 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
config.AnimeSubscriptionConfig.Subscriptions.push(newSubscription);
|
||||||
|
await db.saveAdminConfig(config);
|
||||||
|
|
||||||
|
return NextResponse.json(newSubscription);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('创建追番订阅失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || '创建订阅失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/api/admin/anime-subscription/toggle/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/* 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';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/anime-subscription/toggle
|
||||||
|
* 切换追番功能启用状态
|
||||||
|
*/
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 权限检查
|
||||||
|
const authInfo = getAuthInfoFromCookie(req);
|
||||||
|
if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) {
|
||||||
|
return NextResponse.json({ error: '无权限访问' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { enabled } = await req.json();
|
||||||
|
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'enabled 必须是布尔值' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
if (!config.AnimeSubscriptionConfig) {
|
||||||
|
config.AnimeSubscriptionConfig = { Enabled: false, Subscriptions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
config.AnimeSubscriptionConfig.Enabled = enabled;
|
||||||
|
await db.saveAdminConfig(config);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, enabled });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('切换追番功能状态失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || '切换状态失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { getAuthInfoFromCookie } from '@/lib/auth';
|
|||||||
import { SimpleCrypto } from '@/lib/crypto';
|
import { SimpleCrypto } from '@/lib/crypto';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { CURRENT_VERSION } from '@/lib/version';
|
import { CURRENT_VERSION } from '@/lib/version';
|
||||||
|
import { updateProgress, clearProgress } from '@/lib/data-migration-progress';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: '权限不足,只有站长可以导出数据' }, { status: 401 });
|
return NextResponse.json({ error: '权限不足,只有站长可以导出数据' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const username = authInfo.username; // 存储到局部变量以便 TypeScript 类型推断
|
||||||
|
|
||||||
const config = await db.getAdminConfig();
|
const config = await db.getAdminConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return NextResponse.json({ error: '无法获取配置' }, { status: 500 });
|
return NextResponse.json({ error: '无法获取配置' }, { status: 500 });
|
||||||
@@ -77,49 +80,113 @@ export async function POST(req: NextRequest) {
|
|||||||
allUsers = Array.from(new Set(allUsers));
|
allUsers = Array.from(new Set(allUsers));
|
||||||
console.log(`准备导出 ${allUsers.length} 个V2用户(包括站长)`);
|
console.log(`准备导出 ${allUsers.length} 个V2用户(包括站长)`);
|
||||||
|
|
||||||
// 为每个用户收集数据(只导出V2用户)
|
// 为每个用户收集数据(只导出V2用户)- 使用并行处理
|
||||||
|
console.log(`开始并行导出 ${allUsers.length} 个用户的数据...`);
|
||||||
|
updateProgress(username, 'export', 'collecting', 0, allUsers.length, '开始收集用户数据...');
|
||||||
|
|
||||||
|
// 分块处理用户,每批处理数量可通过环境变量配置
|
||||||
|
const CHUNK_SIZE = parseInt(process.env.DATA_MIGRATION_CHUNK_SIZE || '10', 10);
|
||||||
let exportedCount = 0;
|
let exportedCount = 0;
|
||||||
for (const username of allUsers) {
|
|
||||||
// 站长特殊处理:使用环境变量密码
|
|
||||||
let finalPasswordV2 = username === process.env.USERNAME ? process.env.PASSWORD : null;
|
|
||||||
|
|
||||||
// 如果不是站长,获取V2密码
|
for (let i = 0; i < allUsers.length; i += CHUNK_SIZE) {
|
||||||
if (!finalPasswordV2) {
|
const chunk = allUsers.slice(i, i + CHUNK_SIZE);
|
||||||
finalPasswordV2 = await getUserPasswordV2(username);
|
console.log(`处理第 ${Math.floor(i / CHUNK_SIZE) + 1} 批用户 (${chunk.length} 个)`);
|
||||||
|
|
||||||
|
// 并行处理当前批次的用户
|
||||||
|
const userDataPromises = chunk.map(async (username) => {
|
||||||
|
try {
|
||||||
|
// 站长特殊处理:使用环境变量密码
|
||||||
|
let finalPasswordV2 = username === process.env.USERNAME ? process.env.PASSWORD : null;
|
||||||
|
|
||||||
|
// 如果不是站长,获取V2密码
|
||||||
|
if (!finalPasswordV2) {
|
||||||
|
finalPasswordV2 = await getUserPasswordV2(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过没有V2密码的用户
|
||||||
|
if (!finalPasswordV2) {
|
||||||
|
console.log(`跳过用户 ${username}:没有V2密码`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并行获取用户的所有数据
|
||||||
|
const [
|
||||||
|
playRecords,
|
||||||
|
favorites,
|
||||||
|
searchHistory,
|
||||||
|
skipConfigs,
|
||||||
|
musicPlayRecords,
|
||||||
|
playlists
|
||||||
|
] = await Promise.all([
|
||||||
|
db.getAllPlayRecords(username),
|
||||||
|
db.getAllFavorites(username),
|
||||||
|
db.getSearchHistory(username),
|
||||||
|
db.getAllSkipConfigs(username),
|
||||||
|
db.getAllMusicPlayRecords(username),
|
||||||
|
db.getUserMusicPlaylists(username)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 并行获取所有歌单的歌曲
|
||||||
|
const playlistsWithSongs = await Promise.all(
|
||||||
|
playlists.map(async (playlist) => {
|
||||||
|
const songs = await db.getPlaylistSongs(playlist.id);
|
||||||
|
return { ...playlist, songs };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
userData: {
|
||||||
|
playRecords,
|
||||||
|
favorites,
|
||||||
|
searchHistory,
|
||||||
|
skipConfigs,
|
||||||
|
musicPlayRecords,
|
||||||
|
musicPlaylists: playlistsWithSongs,
|
||||||
|
passwordV2: finalPasswordV2
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`导出用户 ${username} 数据失败:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等待当前批次完成
|
||||||
|
const results = await Promise.all(userDataPromises);
|
||||||
|
|
||||||
|
// 将结果添加到导出数据中,并实时更新进度
|
||||||
|
for (const result of results) {
|
||||||
|
if (result) {
|
||||||
|
exportData.data.userData[result.username] = result.userData;
|
||||||
|
exportedCount++;
|
||||||
|
// 每处理完一个用户就更新进度
|
||||||
|
updateProgress(
|
||||||
|
username,
|
||||||
|
'export',
|
||||||
|
'collecting',
|
||||||
|
exportedCount,
|
||||||
|
allUsers.length,
|
||||||
|
`正在收集用户数据 (${exportedCount}/${allUsers.length})...`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳过没有V2密码的用户
|
console.log(`已完成 ${exportedCount}/${allUsers.length} 个用户`);
|
||||||
if (!finalPasswordV2) {
|
|
||||||
console.log(`跳过用户 ${username}:没有V2密码`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userData = {
|
|
||||||
// 播放记录
|
|
||||||
playRecords: await db.getAllPlayRecords(username),
|
|
||||||
// 收藏夹
|
|
||||||
favorites: await db.getAllFavorites(username),
|
|
||||||
// 搜索历史
|
|
||||||
searchHistory: await db.getSearchHistory(username),
|
|
||||||
// 跳过片头片尾配置
|
|
||||||
skipConfigs: await db.getAllSkipConfigs(username),
|
|
||||||
// V2用户的加密密码
|
|
||||||
passwordV2: finalPasswordV2
|
|
||||||
};
|
|
||||||
|
|
||||||
exportData.data.userData[username] = userData;
|
|
||||||
exportedCount++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`成功导出 ${exportedCount} 个用户的数据`);
|
console.log(`成功导出 ${exportedCount} 个用户的数据`);
|
||||||
|
|
||||||
// 将数据转换为JSON字符串
|
// 将数据转换为JSON字符串
|
||||||
|
updateProgress(username, 'export', 'serializing', exportedCount, exportedCount, '正在序列化数据...');
|
||||||
const jsonData = JSON.stringify(exportData);
|
const jsonData = JSON.stringify(exportData);
|
||||||
|
|
||||||
// 先压缩数据
|
// 先压缩数据
|
||||||
|
updateProgress(username, 'export', 'compressing', exportedCount, exportedCount, '正在压缩数据...');
|
||||||
const compressedData = await gzipAsync(jsonData);
|
const compressedData = await gzipAsync(jsonData);
|
||||||
|
|
||||||
// 使用提供的密码加密压缩后的数据
|
// 使用提供的密码加密压缩后的数据
|
||||||
|
updateProgress(username, 'export', 'encrypting', exportedCount, exportedCount, '正在加密数据...');
|
||||||
const encryptedData = SimpleCrypto.encrypt(compressedData.toString('base64'), password);
|
const encryptedData = SimpleCrypto.encrypt(compressedData.toString('base64'), password);
|
||||||
|
|
||||||
// 生成文件名
|
// 生成文件名
|
||||||
@@ -127,6 +194,10 @@ export async function POST(req: NextRequest) {
|
|||||||
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
||||||
const filename = `moontv-backup-${timestamp}.dat`;
|
const filename = `moontv-backup-${timestamp}.dat`;
|
||||||
|
|
||||||
|
// 清除进度信息
|
||||||
|
updateProgress(username, 'export', 'completed', exportedCount, exportedCount, '导出完成!');
|
||||||
|
setTimeout(() => clearProgress(username, 'export'), 3000);
|
||||||
|
|
||||||
// 返回加密的数据作为文件下载
|
// 返回加密的数据作为文件下载
|
||||||
return new NextResponse(encryptedData, {
|
return new NextResponse(encryptedData, {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -139,6 +210,11 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('数据导出失败:', error);
|
console.error('数据导出失败:', error);
|
||||||
|
// 清除进度信息
|
||||||
|
const authInfo = getAuthInfoFromCookie(req);
|
||||||
|
if (authInfo?.username) {
|
||||||
|
clearProgress(authInfo.username, 'export');
|
||||||
|
}
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error instanceof Error ? error.message : '导出失败' },
|
{ error: error instanceof Error ? error.message : '导出失败' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
@@ -152,7 +228,26 @@ async function getUserPasswordV2(username: string): Promise<string | null> {
|
|||||||
const storage = (db as any).storage;
|
const storage = (db as any).storage;
|
||||||
if (!storage) return null;
|
if (!storage) return null;
|
||||||
|
|
||||||
// 直接调用hGetAll获取完整用户信息(包括密码)
|
// 检查存储类型
|
||||||
|
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||||
|
|
||||||
|
// PostgreSQL 存储:使用 getUserPasswordHash 方法
|
||||||
|
if (storageType === 'postgres') {
|
||||||
|
if (typeof storage.getUserPasswordHash === 'function') {
|
||||||
|
return await storage.getUserPasswordHash(username);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// D1 存储:使用 getUserPasswordHash 方法
|
||||||
|
if (storageType === 'd1') {
|
||||||
|
if (typeof storage.getUserPasswordHash === 'function') {
|
||||||
|
return await storage.getUserPasswordHash(username);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis 存储:直接调用hGetAll获取完整用户信息(包括密码)
|
||||||
const userInfoKey = `user:${username}:info`;
|
const userInfoKey = `user:${username}:info`;
|
||||||
|
|
||||||
if (typeof storage.withRetry === 'function' && storage.client?.hgetall) {
|
if (typeof storage.withRetry === 'function' && storage.client?.hgetall) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getAuthInfoFromCookie } from '@/lib/auth';
|
|||||||
import { configSelfCheck, setCachedConfig } from '@/lib/config';
|
import { configSelfCheck, setCachedConfig } from '@/lib/config';
|
||||||
import { SimpleCrypto } from '@/lib/crypto';
|
import { SimpleCrypto } from '@/lib/crypto';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { updateProgress, clearProgress } from '@/lib/data-migration-progress';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: '权限不足,只有站长可以导入数据' }, { status: 401 });
|
return NextResponse.json({ error: '权限不足,只有站长可以导入数据' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const username = authInfo.username; // 存储到局部变量以便 TypeScript 类型推断
|
||||||
|
|
||||||
// 解析表单数据
|
// 解析表单数据
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
@@ -78,6 +81,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 开始导入数据 - 先清空现有数据
|
// 开始导入数据 - 先清空现有数据
|
||||||
|
updateProgress(username, 'import', 'clearing', 0, 1, '正在清空现有数据...');
|
||||||
await db.clearAllData();
|
await db.clearAllData();
|
||||||
|
|
||||||
// 额外清除所有V2用户(clearAllData可能只清除旧版用户)
|
// 额外清除所有V2用户(clearAllData可能只清除旧版用户)
|
||||||
@@ -104,104 +108,272 @@ export async function POST(req: NextRequest) {
|
|||||||
// 导入用户数据和user:info
|
// 导入用户数据和user:info
|
||||||
const userData = importData.data.userData;
|
const userData = importData.data.userData;
|
||||||
const storage = (db as any).storage;
|
const storage = (db as any).storage;
|
||||||
|
// 使用前面已声明的 storageType 变量
|
||||||
const usersV2Map = new Map((importData.data.usersV2 || []).map((u: any) => [u.username, u]));
|
const usersV2Map = new Map((importData.data.usersV2 || []).map((u: any) => [u.username, u]));
|
||||||
|
|
||||||
const userCount = Object.keys(userData).length;
|
const userCount = Object.keys(userData).length;
|
||||||
console.log(`准备导入 ${userCount} 个用户的数据`);
|
console.log(`准备导入 ${userCount} 个用户的数据`);
|
||||||
|
updateProgress(username, 'import', 'importing', 0, userCount, '开始导入用户数据...');
|
||||||
|
|
||||||
|
// 分块处理用户,每批处理数量可通过环境变量配置
|
||||||
|
const CHUNK_SIZE = parseInt(process.env.DATA_MIGRATION_CHUNK_SIZE || '10', 10);
|
||||||
|
const usernames = Object.keys(userData);
|
||||||
let importedCount = 0;
|
let importedCount = 0;
|
||||||
for (const username in userData) {
|
|
||||||
const user = userData[username];
|
|
||||||
|
|
||||||
// 为所有有passwordV2的用户创建user:info
|
for (let i = 0; i < usernames.length; i += CHUNK_SIZE) {
|
||||||
if (user.passwordV2) {
|
const chunk = usernames.slice(i, i + CHUNK_SIZE);
|
||||||
const userV2 = usersV2Map.get(username) as any;
|
console.log(`处理第 ${Math.floor(i / CHUNK_SIZE) + 1} 批用户 (${chunk.length} 个)`);
|
||||||
|
updateProgress(
|
||||||
|
username,
|
||||||
|
'import',
|
||||||
|
'importing',
|
||||||
|
importedCount,
|
||||||
|
userCount,
|
||||||
|
`正在导入用户数据 (${importedCount}/${userCount})...`
|
||||||
|
);
|
||||||
|
|
||||||
// 确定角色:站长为owner,其他用户从usersV2获取或默认为user
|
// 并行导入当前批次的用户
|
||||||
let role: 'owner' | 'admin' | 'user' = 'user';
|
const importPromises = chunk.map(async (username) => {
|
||||||
if (username === process.env.USERNAME) {
|
try {
|
||||||
role = 'owner';
|
const user = userData[username];
|
||||||
} else if (userV2) {
|
// 数据批处理大小(用于播放记录、收藏夹等)
|
||||||
role = userV2.role === 'owner' ? 'user' : userV2.role;
|
const DATA_BATCH_SIZE = parseInt(process.env.DATA_MIGRATION_CHUNK_SIZE || '10', 10);
|
||||||
}
|
|
||||||
|
|
||||||
const createdAt = userV2?.created_at || Date.now();
|
// 为所有有passwordV2的用户创建user:info
|
||||||
|
if (user.passwordV2) {
|
||||||
|
const userV2 = usersV2Map.get(username) as any;
|
||||||
|
|
||||||
// 直接设置用户信息(不经过createUserV2,避免密码被再次hash)
|
// 确定角色:站长为owner,其他用户从usersV2获取或默认为user
|
||||||
const userInfoKey = `user:${username}:info`;
|
let role: 'owner' | 'admin' | 'user' = 'user';
|
||||||
const userInfo: Record<string, string> = {
|
if (username === process.env.USERNAME) {
|
||||||
role,
|
role = 'owner';
|
||||||
banned: String(userV2?.banned || false),
|
} else if (userV2) {
|
||||||
password: user.passwordV2, // 已经是hash过的密码,直接使用
|
role = userV2.role === 'owner' ? 'user' : userV2.role;
|
||||||
created_at: createdAt.toString(),
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (userV2?.tags && userV2.tags.length > 0) {
|
const createdAt = userV2?.created_at || Date.now();
|
||||||
userInfo.tags = JSON.stringify(userV2.tags);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userV2?.oidcSub) {
|
// 根据存储类型使用不同的导入方法
|
||||||
userInfo.oidcSub = userV2.oidcSub;
|
if (storageType === 'd1') {
|
||||||
}
|
// D1 存储:使用 createUserWithHashedPassword 方法
|
||||||
|
if (typeof storage.createUserWithHashedPassword === 'function') {
|
||||||
|
await storage.createUserWithHashedPassword(
|
||||||
|
username,
|
||||||
|
user.passwordV2,
|
||||||
|
role,
|
||||||
|
createdAt,
|
||||||
|
userV2?.tags,
|
||||||
|
userV2?.oidcSub,
|
||||||
|
userV2?.enabledApis,
|
||||||
|
userV2?.banned
|
||||||
|
);
|
||||||
|
console.log(`用户 ${username} 导入成功 (D1)`);
|
||||||
|
} else {
|
||||||
|
console.error(`D1 storage 缺少 createUserWithHashedPassword 方法`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (storageType === 'postgres') {
|
||||||
|
// Postgres 存储:使用 createUserWithHashedPassword 方法
|
||||||
|
if (typeof storage.createUserWithHashedPassword === 'function') {
|
||||||
|
await storage.createUserWithHashedPassword(
|
||||||
|
username,
|
||||||
|
user.passwordV2,
|
||||||
|
role,
|
||||||
|
createdAt,
|
||||||
|
userV2?.tags,
|
||||||
|
userV2?.oidcSub,
|
||||||
|
userV2?.enabledApis,
|
||||||
|
userV2?.banned
|
||||||
|
);
|
||||||
|
console.log(`用户 ${username} 导入成功 (Postgres)`);
|
||||||
|
} else {
|
||||||
|
console.error(`Postgres storage 缺少 createUserWithHashedPassword 方法`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Redis 存储:直接设置用户信息
|
||||||
|
const userInfoKey = `user:${username}:info`;
|
||||||
|
const userInfo: Record<string, string> = {
|
||||||
|
role,
|
||||||
|
banned: String(userV2?.banned || false),
|
||||||
|
password: user.passwordV2,
|
||||||
|
created_at: createdAt.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
if (userV2?.enabledApis && userV2.enabledApis.length > 0) {
|
if (userV2?.tags && userV2.tags.length > 0) {
|
||||||
userInfo.enabledApis = JSON.stringify(userV2.enabledApis);
|
userInfo.tags = JSON.stringify(userV2.tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用storage.withRetry直接设置用户信息
|
if (userV2?.oidcSub) {
|
||||||
await storage.withRetry(() => storage.client.hSet(userInfoKey, userInfo));
|
userInfo.oidcSub = userV2.oidcSub;
|
||||||
|
}
|
||||||
|
|
||||||
// 添加到用户列表
|
if (userV2?.enabledApis && userV2.enabledApis.length > 0) {
|
||||||
await storage.withRetry(() => storage.client.zAdd('user:list', {
|
userInfo.enabledApis = JSON.stringify(userV2.enabledApis);
|
||||||
score: createdAt,
|
}
|
||||||
value: username,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 如果有oidcSub,创建映射
|
await storage.withRetry(() => storage.client.hSet(userInfoKey, userInfo));
|
||||||
if (userV2?.oidcSub) {
|
await storage.withRetry(() => storage.client.zAdd('user:list', {
|
||||||
const oidcSubKey = `oidc:sub:${userV2.oidcSub}`;
|
score: createdAt,
|
||||||
await storage.withRetry(() => storage.client.set(oidcSubKey, username));
|
value: username,
|
||||||
}
|
}));
|
||||||
|
|
||||||
importedCount++;
|
if (userV2?.oidcSub) {
|
||||||
console.log(`用户 ${username} 导入成功`);
|
const oidcSubKey = `oidc:sub:${userV2.oidcSub}`;
|
||||||
} else {
|
await storage.withRetry(() => storage.client.set(oidcSubKey, username));
|
||||||
console.log(`跳过用户 ${username}:没有passwordV2`);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 导入播放记录
|
console.log(`用户 ${username} 导入成功 (Redis)`);
|
||||||
if (user.playRecords) {
|
}
|
||||||
for (const [key, record] of Object.entries(user.playRecords)) {
|
} else {
|
||||||
await (db as any).storage.setPlayRecord(username, key, record);
|
console.log(`跳过用户 ${username}:没有passwordV2`);
|
||||||
}
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
// 导入收藏夹
|
|
||||||
if (user.favorites) {
|
|
||||||
for (const [key, favorite] of Object.entries(user.favorites)) {
|
|
||||||
await (db as any).storage.setFavorite(username, key, favorite);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导入搜索历史
|
|
||||||
if (user.searchHistory && Array.isArray(user.searchHistory)) {
|
|
||||||
for (const keyword of user.searchHistory.reverse()) { // 反转以保持顺序
|
|
||||||
await db.addSearchHistory(username, keyword);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导入跳过片头片尾配置
|
|
||||||
if (user.skipConfigs) {
|
|
||||||
for (const [key, skipConfig] of Object.entries(user.skipConfigs)) {
|
|
||||||
const [source, id] = key.split('+');
|
|
||||||
if (source && id) {
|
|
||||||
await db.setSkipConfig(username, source, id, skipConfig as any);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 并行导入用户的各类数据
|
||||||
|
await Promise.all([
|
||||||
|
// 导入播放记录(批量)
|
||||||
|
(async () => {
|
||||||
|
if (user.playRecords) {
|
||||||
|
const entries = Object.entries(user.playRecords);
|
||||||
|
// 使用配置的批处理大小
|
||||||
|
for (let j = 0; j < entries.length; j += DATA_BATCH_SIZE) {
|
||||||
|
const batch = entries.slice(j, j + DATA_BATCH_SIZE);
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(([key, record]) =>
|
||||||
|
(db as any).storage.setPlayRecord(username, key, record)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// 导入收藏夹(批量)
|
||||||
|
(async () => {
|
||||||
|
if (user.favorites) {
|
||||||
|
const entries = Object.entries(user.favorites);
|
||||||
|
for (let j = 0; j < entries.length; j += DATA_BATCH_SIZE) {
|
||||||
|
const batch = entries.slice(j, j + DATA_BATCH_SIZE);
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(([key, favorite]) =>
|
||||||
|
(db as any).storage.setFavorite(username, key, favorite)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// 导入搜索历史(批量)
|
||||||
|
(async () => {
|
||||||
|
if (user.searchHistory && Array.isArray(user.searchHistory)) {
|
||||||
|
const reversed = user.searchHistory.reverse();
|
||||||
|
for (let j = 0; j < reversed.length; j += DATA_BATCH_SIZE) {
|
||||||
|
const batch = reversed.slice(j, j + DATA_BATCH_SIZE);
|
||||||
|
await Promise.all(
|
||||||
|
batch.map((keyword: string) => db.addSearchHistory(username, keyword))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// 导入跳过片头片尾配置(批量)
|
||||||
|
(async () => {
|
||||||
|
if (user.skipConfigs) {
|
||||||
|
const entries = Object.entries(user.skipConfigs);
|
||||||
|
for (let j = 0; j < entries.length; j += DATA_BATCH_SIZE) {
|
||||||
|
const batch = entries.slice(j, j + DATA_BATCH_SIZE);
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(([key, skipConfig]) => {
|
||||||
|
const [source, id] = key.split('+');
|
||||||
|
if (source && id) {
|
||||||
|
return db.setSkipConfig(username, source, id, skipConfig as any);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// 导入音乐播放记录(批量)
|
||||||
|
(async () => {
|
||||||
|
if (user.musicPlayRecords) {
|
||||||
|
const entries = Object.entries(user.musicPlayRecords);
|
||||||
|
for (let j = 0; j < entries.length; j += DATA_BATCH_SIZE) {
|
||||||
|
const batch = entries.slice(j, j + DATA_BATCH_SIZE);
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(([key, record]) => {
|
||||||
|
const [platform, id] = key.split('+');
|
||||||
|
if (platform && id) {
|
||||||
|
return db.saveMusicPlayRecord(username, platform, id, record as any);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// 导入音乐歌单
|
||||||
|
(async () => {
|
||||||
|
if (user.musicPlaylists && Array.isArray(user.musicPlaylists)) {
|
||||||
|
for (const playlist of user.musicPlaylists) {
|
||||||
|
await db.createMusicPlaylist(username, {
|
||||||
|
id: playlist.id,
|
||||||
|
name: playlist.name,
|
||||||
|
description: playlist.description,
|
||||||
|
cover: playlist.cover,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 批量导入歌单中的歌曲
|
||||||
|
if (playlist.songs && Array.isArray(playlist.songs)) {
|
||||||
|
for (let j = 0; j < playlist.songs.length; j += DATA_BATCH_SIZE) {
|
||||||
|
const batch = playlist.songs.slice(j, j + DATA_BATCH_SIZE);
|
||||||
|
await Promise.all(
|
||||||
|
batch.map((song: any) =>
|
||||||
|
db.addSongToPlaylist(playlist.id, {
|
||||||
|
platform: song.platform,
|
||||||
|
id: song.id,
|
||||||
|
name: song.name,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
pic: song.pic,
|
||||||
|
duration: song.duration || 0,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`导入用户 ${username} 失败:`, error);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// 等待当前批次完成
|
||||||
|
const results = await Promise.all(importPromises);
|
||||||
|
importedCount += results.filter(r => r).length;
|
||||||
|
|
||||||
|
console.log(`已完成 ${importedCount}/${userCount} 个用户`);
|
||||||
|
updateProgress(
|
||||||
|
username,
|
||||||
|
'import',
|
||||||
|
'importing',
|
||||||
|
importedCount,
|
||||||
|
userCount,
|
||||||
|
`已导入 ${importedCount}/${userCount} 个用户`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`成功导入 ${importedCount} 个用户的user:info`);
|
console.log(`成功导入 ${importedCount} 个用户的user:info`);
|
||||||
|
updateProgress(username, 'import', 'completed', importedCount, userCount, '导入完成!');
|
||||||
|
setTimeout(() => clearProgress(username, 'import'), 3000);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: '数据导入成功',
|
message: '数据导入成功',
|
||||||
@@ -213,6 +385,11 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('数据导入失败:', error);
|
console.error('数据导入失败:', error);
|
||||||
|
// 清除进度信息
|
||||||
|
const authInfo = getAuthInfoFromCookie(req);
|
||||||
|
if (authInfo?.username) {
|
||||||
|
clearProgress(authInfo.username, 'import');
|
||||||
|
}
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error instanceof Error ? error.message : '导入失败' },
|
{ error: error instanceof Error ? error.message : '导入失败' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|||||||
81
src/app/api/admin/data_migration/progress/route.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getProgress } from '@/lib/data-migration-progress';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
// 验证身份和权限
|
||||||
|
const authInfo = getAuthInfoFromCookie(req);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
return new Response('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = authInfo.username; // 存储到局部变量以便 TypeScript 类型推断
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const operation = searchParams.get('operation'); // 'export' or 'import'
|
||||||
|
|
||||||
|
if (!operation) {
|
||||||
|
return new Response('Missing operation parameter', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 SSE 响应
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let interval: NodeJS.Timeout | null = null;
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const sendProgress = () => {
|
||||||
|
try {
|
||||||
|
const progress = getProgress(username, operation as 'export' | 'import');
|
||||||
|
if (progress) {
|
||||||
|
const data = JSON.stringify(progress);
|
||||||
|
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 如果控制器已关闭,清理定时器
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 立即发送一次
|
||||||
|
sendProgress();
|
||||||
|
|
||||||
|
// 每秒发送一次进度更新
|
||||||
|
interval = setInterval(sendProgress, 1000);
|
||||||
|
|
||||||
|
// 30秒后自动关闭连接
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch (error) {
|
||||||
|
// 控制器可能已经关闭
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
// 当客户端断开连接时清理
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +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 { getConfig } from '@/lib/config';
|
import { getConfig, setCachedConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -64,6 +64,9 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
await db.saveAdminConfig(adminConfig);
|
await db.saveAdminConfig(adminConfig);
|
||||||
|
|
||||||
|
// 更新内存缓存
|
||||||
|
await setCachedConfig(adminConfig);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: '导入成功',
|
message: '导入成功',
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export const runtime = 'nodejs';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/emby
|
* POST /api/admin/emby
|
||||||
* 保存 Emby 配置
|
* Emby 配置管理接口
|
||||||
|
* - test: 测试 Emby 连接
|
||||||
|
* - clearCache: 清除 Emby 缓存
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||||
@@ -25,7 +27,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { action, Enabled, ServerURL, ApiKey, Username, Password, UserId, Libraries } = body;
|
const { action, ServerURL, ApiKey, Username, Password } = body;
|
||||||
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
@@ -44,99 +46,15 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (action === 'test') {
|
||||||
// 测试连接
|
// 测试连接
|
||||||
if (!ServerURL) {
|
if (!ServerURL) {
|
||||||
return NextResponse.json({ error: '请填写 Emby 服务器地址' }, { status: 400 });
|
return NextResponse.json({ error: '请填写 Emby 服务器地址' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ApiKey && (!Username || !Password)) {
|
if (!ApiKey && !Username) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '请填写 API Key 或用户名密码' },
|
{ error: '请填写 API Key 或用户名' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -151,9 +69,9 @@ export async function POST(request: NextRequest) {
|
|||||||
const client = new EmbyClient(testConfig);
|
const client = new EmbyClient(testConfig);
|
||||||
|
|
||||||
// 如果使用用户名密码,先认证
|
// 如果使用用户名密码,先认证
|
||||||
if (!ApiKey && Username && Password) {
|
if (!ApiKey && Username) {
|
||||||
try {
|
try {
|
||||||
await client.authenticate(Username, Password);
|
await client.authenticate(Username, Password || '');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, message: 'Emby 认证失败: ' + (error as Error).message },
|
{ success: false, message: 'Emby 认证失败: ' + (error as Error).message },
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { action, key, name, url, ua, epg } = body;
|
const { action, key, name, url, ua, epg, proxyMode } = body;
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
|
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
|
||||||
@@ -153,6 +153,18 @@ export async function POST(request: NextRequest) {
|
|||||||
config.LiveConfig = sortedLiveConfig;
|
config.LiveConfig = sortedLiveConfig;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'set_proxy_mode':
|
||||||
|
// 设置代理模式
|
||||||
|
const setProxySource = config.LiveConfig.find((l) => l.key === key);
|
||||||
|
if (!setProxySource) {
|
||||||
|
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!proxyMode || !['full', 'm3u8-only', 'direct'].includes(proxyMode)) {
|
||||||
|
return NextResponse.json({ error: '无效的代理模式' }, { status: 400 });
|
||||||
|
}
|
||||||
|
setProxySource.proxyMode = proxyMode as 'full' | 'm3u8-only' | 'direct';
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
112
src/app/api/admin/music/route.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/* 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';
|
||||||
|
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 body = await request.json();
|
||||||
|
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const username = authInfo.username;
|
||||||
|
|
||||||
|
const {
|
||||||
|
TuneHubEnabled,
|
||||||
|
TuneHubBaseUrl,
|
||||||
|
TuneHubApiKey,
|
||||||
|
OpenListCacheEnabled,
|
||||||
|
OpenListCacheURL,
|
||||||
|
OpenListCacheUsername,
|
||||||
|
OpenListCachePassword,
|
||||||
|
OpenListCachePath,
|
||||||
|
OpenListCacheProxyEnabled,
|
||||||
|
} = body as {
|
||||||
|
TuneHubEnabled?: boolean;
|
||||||
|
TuneHubBaseUrl?: string;
|
||||||
|
TuneHubApiKey?: string;
|
||||||
|
OpenListCacheEnabled?: boolean;
|
||||||
|
OpenListCacheURL?: string;
|
||||||
|
OpenListCacheUsername?: string;
|
||||||
|
OpenListCachePassword?: string;
|
||||||
|
OpenListCachePath?: string;
|
||||||
|
OpenListCacheProxyEnabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 参数校验
|
||||||
|
if (
|
||||||
|
(TuneHubEnabled !== undefined && typeof TuneHubEnabled !== 'boolean') ||
|
||||||
|
(TuneHubBaseUrl !== undefined && typeof TuneHubBaseUrl !== 'string') ||
|
||||||
|
(TuneHubApiKey !== undefined && typeof TuneHubApiKey !== 'string') ||
|
||||||
|
(OpenListCacheEnabled !== undefined && typeof OpenListCacheEnabled !== 'boolean') ||
|
||||||
|
(OpenListCacheURL !== undefined && typeof OpenListCacheURL !== 'string') ||
|
||||||
|
(OpenListCacheUsername !== undefined && typeof OpenListCacheUsername !== 'string') ||
|
||||||
|
(OpenListCachePassword !== undefined && typeof OpenListCachePassword !== 'string') ||
|
||||||
|
(OpenListCachePath !== undefined && typeof OpenListCachePath !== 'string') ||
|
||||||
|
(OpenListCacheProxyEnabled !== undefined && typeof OpenListCacheProxyEnabled !== 'boolean')
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminConfig = await getConfig();
|
||||||
|
|
||||||
|
// 权限校验 - 使用v2用户系统
|
||||||
|
if (username !== process.env.USERNAME) {
|
||||||
|
const userInfo = await db.getUserInfoV2(username);
|
||||||
|
if (!userInfo || userInfo.role !== 'admin' || userInfo.banned) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新缓存中的音乐配置
|
||||||
|
adminConfig.MusicConfig = {
|
||||||
|
TuneHubEnabled,
|
||||||
|
TuneHubBaseUrl,
|
||||||
|
TuneHubApiKey,
|
||||||
|
OpenListCacheEnabled,
|
||||||
|
OpenListCacheURL,
|
||||||
|
OpenListCacheUsername,
|
||||||
|
OpenListCachePassword,
|
||||||
|
OpenListCachePath,
|
||||||
|
OpenListCacheProxyEnabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 写入数据库
|
||||||
|
await db.saveAdminConfig(adminConfig);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: true },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store', // 不缓存结果
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新音乐配置失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: '更新音乐配置失败',
|
||||||
|
details: (error as Error).message,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,25 @@ import { OpenListClient } from '@/lib/openlist.client';
|
|||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理字符串中的 BOM 和其他不可见字符
|
||||||
|
*/
|
||||||
|
function cleanPath(path: string): string {
|
||||||
|
// 移除 UTF-8 BOM (U+FEFF) 和其他零宽度字符
|
||||||
|
let cleaned = path
|
||||||
|
.replace(/^\uFEFF/, '') // 移除开头的 BOM
|
||||||
|
.replace(/\uFEFF/g, '') // 移除所有 BOM
|
||||||
|
.replace(/[\u200B-\u200D\uFEFF]/g, '') // 移除零宽度字符
|
||||||
|
.trim(); // 移除首尾空白
|
||||||
|
|
||||||
|
// 移除末尾的 /(除非路径就是 /)
|
||||||
|
if (cleaned.length > 1 && cleaned.endsWith('/')) {
|
||||||
|
cleaned = cleaned.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/openlist
|
* POST /api/admin/openlist
|
||||||
* 保存 OpenList 配置
|
* 保存 OpenList 配置
|
||||||
@@ -86,6 +105,9 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理 RootPaths 中的 BOM 和不可见字符
|
||||||
|
const cleanedRootPaths = RootPaths.map(cleanPath);
|
||||||
|
|
||||||
// 验证扫描间隔
|
// 验证扫描间隔
|
||||||
const scanInterval = parseInt(ScanInterval) || 0;
|
const scanInterval = parseInt(ScanInterval) || 0;
|
||||||
if (scanInterval > 0 && scanInterval < 60) {
|
if (scanInterval > 0 && scanInterval < 60) {
|
||||||
@@ -113,7 +135,7 @@ export async function POST(request: NextRequest) {
|
|||||||
URL,
|
URL,
|
||||||
Username,
|
Username,
|
||||||
Password,
|
Password,
|
||||||
RootPaths,
|
RootPaths: cleanedRootPaths,
|
||||||
OfflineDownloadPath: OfflineDownloadPath || '/',
|
OfflineDownloadPath: OfflineDownloadPath || '/',
|
||||||
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||||
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export async function POST(request: NextRequest) {
|
|||||||
PansouUsername,
|
PansouUsername,
|
||||||
PansouPassword,
|
PansouPassword,
|
||||||
PansouKeywordBlocklist,
|
PansouKeywordBlocklist,
|
||||||
|
MagnetProxy,
|
||||||
|
MagnetMikanReverseProxy,
|
||||||
|
MagnetDmhyReverseProxy,
|
||||||
|
MagnetAcgripReverseProxy,
|
||||||
EnableComments,
|
EnableComments,
|
||||||
CustomAdFilterCode,
|
CustomAdFilterCode,
|
||||||
CustomAdFilterVersion,
|
CustomAdFilterVersion,
|
||||||
@@ -91,6 +95,10 @@ export async function POST(request: NextRequest) {
|
|||||||
PansouUsername?: string;
|
PansouUsername?: string;
|
||||||
PansouPassword?: string;
|
PansouPassword?: string;
|
||||||
PansouKeywordBlocklist?: string;
|
PansouKeywordBlocklist?: string;
|
||||||
|
MagnetProxy?: string;
|
||||||
|
MagnetMikanReverseProxy?: string;
|
||||||
|
MagnetDmhyReverseProxy?: string;
|
||||||
|
MagnetAcgripReverseProxy?: string;
|
||||||
EnableComments: boolean;
|
EnableComments: boolean;
|
||||||
CustomAdFilterCode?: string;
|
CustomAdFilterCode?: string;
|
||||||
CustomAdFilterVersion?: number;
|
CustomAdFilterVersion?: number;
|
||||||
@@ -132,6 +140,10 @@ export async function POST(request: NextRequest) {
|
|||||||
(BannerDataSource !== undefined && typeof BannerDataSource !== 'string') ||
|
(BannerDataSource !== undefined && typeof BannerDataSource !== 'string') ||
|
||||||
(RecommendationDataSource !== undefined && typeof RecommendationDataSource !== 'string') ||
|
(RecommendationDataSource !== undefined && typeof RecommendationDataSource !== 'string') ||
|
||||||
(PansouKeywordBlocklist !== undefined && typeof PansouKeywordBlocklist !== 'string') ||
|
(PansouKeywordBlocklist !== undefined && typeof PansouKeywordBlocklist !== 'string') ||
|
||||||
|
(MagnetProxy !== undefined && typeof MagnetProxy !== 'string') ||
|
||||||
|
(MagnetMikanReverseProxy !== undefined && typeof MagnetMikanReverseProxy !== 'string') ||
|
||||||
|
(MagnetDmhyReverseProxy !== undefined && typeof MagnetDmhyReverseProxy !== 'string') ||
|
||||||
|
(MagnetAcgripReverseProxy !== undefined && typeof MagnetAcgripReverseProxy !== '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') ||
|
||||||
@@ -188,6 +200,10 @@ export async function POST(request: NextRequest) {
|
|||||||
PansouUsername,
|
PansouUsername,
|
||||||
PansouPassword,
|
PansouPassword,
|
||||||
PansouKeywordBlocklist,
|
PansouKeywordBlocklist,
|
||||||
|
MagnetProxy,
|
||||||
|
MagnetMikanReverseProxy,
|
||||||
|
MagnetDmhyReverseProxy,
|
||||||
|
MagnetAcgripReverseProxy,
|
||||||
EnableComments,
|
EnableComments,
|
||||||
CustomAdFilterCode,
|
CustomAdFilterCode,
|
||||||
CustomAdFilterVersion,
|
CustomAdFilterVersion,
|
||||||
|
|||||||
164
src/app/api/admin/source-script/route.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import {
|
||||||
|
deleteSourceScript,
|
||||||
|
getDefaultSourceScriptTemplate,
|
||||||
|
importSourceScripts,
|
||||||
|
listSourceScripts,
|
||||||
|
saveSourceScript,
|
||||||
|
testSourceScript,
|
||||||
|
toggleSourceScriptEnabled,
|
||||||
|
} from '@/lib/source-script';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
async function assertAdmin(request: NextRequest) {
|
||||||
|
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||||
|
if (storageType === 'localstorage') {
|
||||||
|
throw new Error('不支持本地存储进行管理员配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo?.username) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authInfo.username === process.env.USERNAME) {
|
||||||
|
return authInfo.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfoV2 || (userInfoV2.role !== 'admin' && userInfoV2.role !== 'owner') || userInfoV2.banned) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return authInfo.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const username = await assertAdmin(request);
|
||||||
|
if (!username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await listSourceScripts();
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
items,
|
||||||
|
template: getDefaultSourceScriptTemplate(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message || '获取脚本列表失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const username = await assertAdmin(request);
|
||||||
|
if (!username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as Record<string, any>;
|
||||||
|
const action = body.action as string;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'save': {
|
||||||
|
const saved = await saveSourceScript({
|
||||||
|
id: body.id,
|
||||||
|
key: body.key,
|
||||||
|
name: body.name,
|
||||||
|
description: body.description,
|
||||||
|
code: body.code,
|
||||||
|
enabled: body.enabled,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: true, item: saved },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'delete': {
|
||||||
|
await deleteSourceScript(body.id);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: true },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'toggle_enabled': {
|
||||||
|
const item = await toggleSourceScriptEnabled(body.id);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: true, item },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'test': {
|
||||||
|
const result = await testSourceScript({
|
||||||
|
code: body.code,
|
||||||
|
hook: body.hook,
|
||||||
|
payload: body.payload || {},
|
||||||
|
name: body.name,
|
||||||
|
key: body.key,
|
||||||
|
configValues: body.configValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json(result, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'import': {
|
||||||
|
const imported = await importSourceScripts(
|
||||||
|
Array.isArray(body.items) ? body.items : []
|
||||||
|
);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: true, items: imported },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: (error as Error).message || '脚本操作失败',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { db } from '@/lib/db';
|
|||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
// 支持的操作类型
|
// 支持的操作类型
|
||||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete' | 'toggle_proxy_mode';
|
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete' | 'toggle_proxy_mode' | 'update_weight';
|
||||||
|
|
||||||
interface BaseBody {
|
interface BaseBody {
|
||||||
action?: Action;
|
action?: Action;
|
||||||
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const username = authInfo.username;
|
const username = authInfo.username;
|
||||||
|
|
||||||
// 基础校验
|
// 基础校验
|
||||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete', 'toggle_proxy_mode'];
|
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete', 'toggle_proxy_mode', 'update_weight'];
|
||||||
if (!username || !action || !ACTIONS.includes(action)) {
|
if (!username || !action || !ACTIONS.includes(action)) {
|
||||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -254,6 +254,20 @@ export async function POST(request: NextRequest) {
|
|||||||
entry.proxyMode = !entry.proxyMode;
|
entry.proxyMode = !entry.proxyMode;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'update_weight': {
|
||||||
|
const { key, weight } = body as { key?: string; weight?: number };
|
||||||
|
if (!key)
|
||||||
|
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||||
|
if (weight === undefined || weight === null)
|
||||||
|
return NextResponse.json({ error: '缺少 weight 参数' }, { status: 400 });
|
||||||
|
if (typeof weight !== 'number' || weight < 0 || weight > 100)
|
||||||
|
return NextResponse.json({ error: '权重必须是 0-100 之间的数字' }, { status: 400 });
|
||||||
|
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||||
|
if (!entry)
|
||||||
|
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
|
entry.weight = weight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export async function POST(request: NextRequest) {
|
|||||||
cacheMinutes,
|
cacheMinutes,
|
||||||
loginBackgroundImage,
|
loginBackgroundImage,
|
||||||
registerBackgroundImage,
|
registerBackgroundImage,
|
||||||
|
progressThumbType,
|
||||||
|
progressThumbPresetId,
|
||||||
|
progressThumbCustomUrl,
|
||||||
} = body as {
|
} = body as {
|
||||||
enableBuiltInTheme: boolean;
|
enableBuiltInTheme: boolean;
|
||||||
builtInTheme: string;
|
builtInTheme: string;
|
||||||
@@ -44,6 +47,9 @@ export async function POST(request: NextRequest) {
|
|||||||
cacheMinutes: number;
|
cacheMinutes: number;
|
||||||
loginBackgroundImage?: string;
|
loginBackgroundImage?: string;
|
||||||
registerBackgroundImage?: string;
|
registerBackgroundImage?: string;
|
||||||
|
progressThumbType?: 'default' | 'preset' | 'custom';
|
||||||
|
progressThumbPresetId?: string;
|
||||||
|
progressThumbCustomUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 参数校验
|
// 参数校验
|
||||||
@@ -118,6 +124,9 @@ export async function POST(request: NextRequest) {
|
|||||||
cacheVersion: cssChanged ? currentVersion + 1 : currentVersion,
|
cacheVersion: cssChanged ? currentVersion + 1 : currentVersion,
|
||||||
loginBackgroundImage: loginBackgroundImage?.trim() || undefined,
|
loginBackgroundImage: loginBackgroundImage?.trim() || undefined,
|
||||||
registerBackgroundImage: registerBackgroundImage?.trim() || undefined,
|
registerBackgroundImage: registerBackgroundImage?.trim() || undefined,
|
||||||
|
progressThumbType: progressThumbType || 'default',
|
||||||
|
progressThumbPresetId: progressThumbPresetId?.trim() || undefined,
|
||||||
|
progressThumbCustomUrl: progressThumbCustomUrl?.trim() || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 写入数据库
|
// 写入数据库
|
||||||
|
|||||||
30
src/app/api/advanced-recommendation/sources/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { listEnabledSourceScripts } from '@/lib/source-script';
|
||||||
|
|
||||||
|
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 scripts = await listEnabledSourceScripts();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
sources: scripts.map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取高级推荐脚本失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/app/api/advanced-recommendation/videos/route.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import {
|
||||||
|
executeSavedSourceScript,
|
||||||
|
normalizeScriptRecommendResults,
|
||||||
|
normalizeScriptSources,
|
||||||
|
} from '@/lib/source-script';
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const sourceKey = searchParams.get('source');
|
||||||
|
const page = Number(searchParams.get('page') || '1');
|
||||||
|
|
||||||
|
if (!sourceKey) {
|
||||||
|
return NextResponse.json({ error: '缺少参数: source' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let sources = [{ id: 'default', name: '默认源' }];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourcesExecution = await executeSavedSourceScript({
|
||||||
|
key: sourceKey,
|
||||||
|
hook: 'getSources',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
sources = normalizeScriptSources(sourcesExecution.result);
|
||||||
|
} catch {
|
||||||
|
// 允许脚本未实现 getSources,继续使用默认源
|
||||||
|
}
|
||||||
|
|
||||||
|
const execution = await executeSavedSourceScript({
|
||||||
|
key: sourceKey,
|
||||||
|
hook: 'recommend',
|
||||||
|
payload: { page },
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = normalizeScriptRecommendResults({
|
||||||
|
scriptKey: sourceKey,
|
||||||
|
scriptName: execution.meta?.name || sourceKey,
|
||||||
|
result: execution.result,
|
||||||
|
sources,
|
||||||
|
defaultSourceId: sources[0]?.id || 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
results,
|
||||||
|
page: Number(execution.result?.page || page),
|
||||||
|
pageCount: Number(execution.result?.pageCount || 1),
|
||||||
|
total: Number(execution.result?.total || results.length),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message || '获取高级推荐失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/app/api/ai-comments/route.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { generateAIComments, AIComment } from '@/lib/ai-comment-generator';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
interface AICommentsResponse {
|
||||||
|
comments: AIComment[];
|
||||||
|
total: number;
|
||||||
|
movieName: string;
|
||||||
|
isAiGenerated: true;
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const movieName = searchParams.get('name');
|
||||||
|
const movieInfo = searchParams.get('info');
|
||||||
|
const count = parseInt(searchParams.get('count') || '10');
|
||||||
|
|
||||||
|
// 参数验证
|
||||||
|
if (!movieName) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '缺少影片名称参数' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count < 1 || count > 50) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '评论数量必须在1-50之间' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取AI配置
|
||||||
|
const config = await getConfig();
|
||||||
|
const aiConfig = config.AIConfig;
|
||||||
|
|
||||||
|
// 检查AI功能是否启用
|
||||||
|
if (!aiConfig?.Enabled) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'AI功能未启用' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查AI评论功能是否启用
|
||||||
|
if (!aiConfig?.EnableAIComments) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'AI评论功能未启用' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查必要的配置
|
||||||
|
if (!aiConfig.CustomApiKey || !aiConfig.CustomBaseURL || !aiConfig.CustomModel) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'AI配置不完整,请在管理面板配置' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成AI评论
|
||||||
|
const comments = await generateAIComments({
|
||||||
|
movieName,
|
||||||
|
movieInfo: movieInfo || undefined,
|
||||||
|
count,
|
||||||
|
aiConfig: {
|
||||||
|
CustomApiKey: aiConfig.CustomApiKey,
|
||||||
|
CustomBaseURL: aiConfig.CustomBaseURL,
|
||||||
|
CustomModel: aiConfig.CustomModel,
|
||||||
|
Temperature: aiConfig.Temperature,
|
||||||
|
MaxTokens: aiConfig.MaxTokens,
|
||||||
|
EnableWebSearch: aiConfig.EnableWebSearch,
|
||||||
|
WebSearchProvider: aiConfig.WebSearchProvider,
|
||||||
|
TavilyApiKey: aiConfig.TavilyApiKey,
|
||||||
|
SerperApiKey: aiConfig.SerperApiKey,
|
||||||
|
SerpApiKey: aiConfig.SerpApiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 返回结果
|
||||||
|
const response: AICommentsResponse = {
|
||||||
|
comments,
|
||||||
|
total: comments.length,
|
||||||
|
movieName,
|
||||||
|
isAiGenerated: true,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI评论生成失败:', error);
|
||||||
|
|
||||||
|
// 返回友好的错误信息
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'AI评论生成失败';
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: errorMessage,
|
||||||
|
details: process.env.NODE_ENV === 'development' ? String(error) : undefined
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,13 +73,25 @@ function transformToSSE(
|
|||||||
|
|
||||||
return new ReadableStream({
|
return new ReadableStream({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
|
let buffer = ''; // 缓冲区,用于保存不完整的行
|
||||||
|
let contentBuffer = ''; // 累积的内容,用于处理跨chunk的thinking标签
|
||||||
|
let inThinkingBlock = false; // 是否在thinking块内
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
const lines = chunk.split('\n').filter((line) => line.trim() !== '');
|
// 将新chunk与缓冲区拼接
|
||||||
|
const text = buffer + chunk;
|
||||||
|
// 按换行符分割,最后一个元素可能是不完整的行
|
||||||
|
const parts = text.split('\n');
|
||||||
|
// 保存最后一个不完整的行到缓冲区
|
||||||
|
buffer = parts.pop() || '';
|
||||||
|
|
||||||
|
// 处理完整的行
|
||||||
|
const lines = parts.filter((line) => line.trim() !== '');
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
@@ -113,9 +125,32 @@ function transformToSSE(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
if (text) {
|
||||||
controller.enqueue(
|
// 累积内容并处理thinking标签
|
||||||
new TextEncoder().encode(`data: ${JSON.stringify({ text })}\n\n`)
|
contentBuffer += text;
|
||||||
);
|
|
||||||
|
// 检查是否进入thinking块
|
||||||
|
if (contentBuffer.includes('<think>')) {
|
||||||
|
inThinkingBlock = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否退出thinking块
|
||||||
|
if (inThinkingBlock && contentBuffer.includes('</think>')) {
|
||||||
|
// 移除thinking块内容
|
||||||
|
contentBuffer = contentBuffer.replace(/<think>[\s\S]*?<\/think>/g, '');
|
||||||
|
inThinkingBlock = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有在不在thinking块内时才输出内容
|
||||||
|
if (!inThinkingBlock) {
|
||||||
|
// 输出非thinking部分的内容
|
||||||
|
const outputText = contentBuffer;
|
||||||
|
if (outputText) {
|
||||||
|
controller.enqueue(
|
||||||
|
new TextEncoder().encode(`data: ${JSON.stringify({ text: outputText })}\n\n`)
|
||||||
|
);
|
||||||
|
contentBuffer = ''; // 清空已输出的内容
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 只在非空数据解析失败时打印错误
|
// 只在非空数据解析失败时打印错误
|
||||||
@@ -126,6 +161,39 @@ function transformToSSE(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理缓冲区中剩余的数据
|
||||||
|
if (buffer.trim()) {
|
||||||
|
const line = buffer.trim();
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.slice(6).trim();
|
||||||
|
if (data && data !== '[DONE]') {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
let text = '';
|
||||||
|
if (provider === 'claude') {
|
||||||
|
if (json.type === 'content_block_delta') {
|
||||||
|
text = json.delta?.text || '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text = json.choices?.[0]?.delta?.content || '';
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
contentBuffer += text;
|
||||||
|
// 最后清理一次thinking标签
|
||||||
|
contentBuffer = contentBuffer.replace(/<think>[\s\S]*?<\/think>/g, '');
|
||||||
|
if (contentBuffer) {
|
||||||
|
controller.enqueue(
|
||||||
|
new TextEncoder().encode(`data: ${JSON.stringify({ text: contentBuffer })}\n\n`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Parse final buffer error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Stream error:', error);
|
console.error('Stream error:', error);
|
||||||
controller.error(error);
|
controller.error(error);
|
||||||
@@ -261,7 +329,10 @@ export async function POST(request: NextRequest) {
|
|||||||
// 非流式响应:等待完整响应后返回JSON
|
// 非流式响应:等待完整响应后返回JSON
|
||||||
const response = result as Response;
|
const response = result as Response;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const content = data.choices?.[0]?.message?.content || '';
|
let content = data.choices?.[0]?.message?.content || '';
|
||||||
|
|
||||||
|
// 移除thinking标签内容
|
||||||
|
content = content.replace(/<think>[\s\S]*?<\/think>/g, '');
|
||||||
|
|
||||||
return NextResponse.json({ content });
|
return NextResponse.json({ content });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ async function generateSignature(
|
|||||||
function getDeviceInfo(userAgent: string): string {
|
function getDeviceInfo(userAgent: string): string {
|
||||||
const ua = userAgent.toLowerCase();
|
const ua = userAgent.toLowerCase();
|
||||||
|
|
||||||
|
// 检查是否为 MoonTVPlus APP
|
||||||
|
if (ua.includes('moontvplus')) {
|
||||||
|
return 'MoonTVPlus APP';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为 OrionTV
|
||||||
|
if (ua.includes('oriontv')) {
|
||||||
|
return 'OrionTV';
|
||||||
|
}
|
||||||
|
|
||||||
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
||||||
if (ua.includes('android')) return 'Android Mobile';
|
if (ua.includes('android')) return 'Android Mobile';
|
||||||
if (ua.includes('iphone')) return 'iPhone';
|
if (ua.includes('iphone')) return 'iPhone';
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ async function generateSignature(
|
|||||||
function getDeviceInfo(userAgent: string): string {
|
function getDeviceInfo(userAgent: string): string {
|
||||||
const ua = userAgent.toLowerCase();
|
const ua = userAgent.toLowerCase();
|
||||||
|
|
||||||
|
// 检查是否为 MoonTVPlus APP
|
||||||
|
if (ua.includes('moontvplus')) {
|
||||||
|
return 'MoonTVPlus APP';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为 OrionTV
|
||||||
|
if (ua.includes('oriontv')) {
|
||||||
|
return 'OrionTV';
|
||||||
|
}
|
||||||
|
|
||||||
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
||||||
if (ua.includes('android')) return 'Android Mobile';
|
if (ua.includes('android')) return 'Android Mobile';
|
||||||
if (ua.includes('iphone')) return 'iPhone';
|
if (ua.includes('iphone')) return 'iPhone';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
setCachedMetaInfo,
|
setCachedMetaInfo,
|
||||||
} from '@/lib/openlist-cache';
|
} from '@/lib/openlist-cache';
|
||||||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||||
|
import { yellowWords } from '@/lib/yellow';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const apiUrl = searchParams.get('api');
|
const apiUrl = searchParams.get('api');
|
||||||
|
const yellowFilter = searchParams.get('yellowFilter') === 'true';
|
||||||
|
|
||||||
if (!apiUrl) {
|
if (!apiUrl) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -96,7 +98,7 @@ export async function GET(request: NextRequest) {
|
|||||||
console.log('CMS 代理 origin:', origin);
|
console.log('CMS 代理 origin:', origin);
|
||||||
|
|
||||||
// 处理返回数据,替换播放链接为代理链接
|
// 处理返回数据,替换播放链接为代理链接
|
||||||
const processedData = processPlayUrls(data, origin);
|
const processedData = processCmsResponse(data, origin, yellowFilter);
|
||||||
|
|
||||||
return NextResponse.json(processedData, {
|
return NextResponse.json(processedData, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -130,7 +132,7 @@ export async function GET(request: NextRequest) {
|
|||||||
/**
|
/**
|
||||||
* 处理 CMS API 返回数据,将播放链接替换为代理链接
|
* 处理 CMS API 返回数据,将播放链接替换为代理链接
|
||||||
*/
|
*/
|
||||||
function processPlayUrls(data: any, proxyOrigin: string): any {
|
function processCmsResponse(data: any, proxyOrigin: string, yellowFilter: boolean): any {
|
||||||
if (!data || typeof data !== 'object') {
|
if (!data || typeof data !== 'object') {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -138,6 +140,28 @@ function processPlayUrls(data: any, proxyOrigin: string): any {
|
|||||||
// 深拷贝数据,避免修改原始对象
|
// 深拷贝数据,避免修改原始对象
|
||||||
const processedData = JSON.parse(JSON.stringify(data));
|
const processedData = JSON.parse(JSON.stringify(data));
|
||||||
|
|
||||||
|
if (yellowFilter) {
|
||||||
|
if (processedData.class && Array.isArray(processedData.class)) {
|
||||||
|
processedData.class = processedData.class.filter((item: any) => !matchesYellowContent(item?.type_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedData.list && Array.isArray(processedData.list)) {
|
||||||
|
processedData.list = processedData.list.filter((item: any) => !matchesYellowContent(
|
||||||
|
item?.vod_name,
|
||||||
|
item?.type_name,
|
||||||
|
item?.vod_remarks,
|
||||||
|
item?.vod_content,
|
||||||
|
));
|
||||||
|
|
||||||
|
if (typeof processedData.total === 'number') {
|
||||||
|
processedData.total = processedData.list.length;
|
||||||
|
}
|
||||||
|
if (typeof processedData.limit === 'number') {
|
||||||
|
processedData.limit = processedData.list.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取 M3U8 代理 token
|
// 获取 M3U8 代理 token
|
||||||
const proxyToken = process.env.NEXT_PUBLIC_PROXY_M3U8_TOKEN || '';
|
const proxyToken = process.env.NEXT_PUBLIC_PROXY_M3U8_TOKEN || '';
|
||||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||||
@@ -174,6 +198,19 @@ function processPlayUrls(data: any, proxyOrigin: string): any {
|
|||||||
return processedData;
|
return processedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesYellowContent(...values: Array<string | undefined>): boolean {
|
||||||
|
const normalized = values
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return yellowWords.some((word) => normalized.includes(word.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理播放地址字符串
|
* 处理播放地址字符串
|
||||||
* 格式: 第01集$url1#第02集$url2#...
|
* 格式: 第01集$url1#第02集$url2#...
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { checkAnimeSubscriptions } from '@/lib/anime-subscription';
|
||||||
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 { EmailService } from '@/lib/email.service';
|
||||||
@@ -13,6 +14,10 @@ import { SearchResult } from '@/lib/types';
|
|||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// 内存中记录最后执行时间(毫秒时间戳)
|
||||||
|
let lastExecutionTime = 0;
|
||||||
|
const COOLDOWN_MS = 10 * 60 * 1000; // 10分钟冷却时间
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { password: string } }
|
{ params }: { params: { password: string } }
|
||||||
@@ -27,16 +32,53 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查冷却时间
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastExecution = now - lastExecutionTime;
|
||||||
|
|
||||||
|
if (lastExecutionTime > 0 && timeSinceLastExecution < COOLDOWN_MS) {
|
||||||
|
const remainingSeconds = Math.ceil((COOLDOWN_MS - timeSinceLastExecution) / 1000);
|
||||||
|
const remainingMinutes = Math.floor(remainingSeconds / 60);
|
||||||
|
const seconds = remainingSeconds % 60;
|
||||||
|
|
||||||
|
console.log(`Cron job skipped: cooldown period active. Remaining: ${remainingMinutes}m ${seconds}s`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: 'Cron job is in cooldown period',
|
||||||
|
remainingSeconds,
|
||||||
|
nextAvailableTime: new Date(lastExecutionTime + COOLDOWN_MS).toISOString(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Cron job triggered:', new Date().toISOString());
|
console.log('Cron job triggered:', new Date().toISOString());
|
||||||
|
|
||||||
cronJob();
|
// 更新最后执行时间
|
||||||
|
lastExecutionTime = now;
|
||||||
|
|
||||||
return NextResponse.json({
|
// 环境变量控制是否等待定时任务完全结束后再返回响应(默认 false)
|
||||||
success: true,
|
// 用于防止 Vercel 等平台杀后台进程
|
||||||
message: 'Cron job executed successfully',
|
const waitForCompletion = process.env.CRON_WAIT_FOR_COMPLETION === 'true';
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
if (waitForCompletion) {
|
||||||
|
// 等待定时任务完成后再返回 200
|
||||||
|
await cronJob();
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Cron job executed successfully',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 立即返回 202,定时任务在后台执行
|
||||||
|
cronJob();
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Cron job accepted and running in background',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, { status: 202 });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Cron job failed:', error);
|
console.error('Cron job failed:', error);
|
||||||
|
|
||||||
@@ -53,10 +95,16 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cronJob() {
|
async function cronJob() {
|
||||||
|
// 先刷新配置,确保其他任务使用最新配置
|
||||||
await refreshConfig();
|
await refreshConfig();
|
||||||
await refreshAllLiveChannels();
|
|
||||||
await refreshOpenList();
|
// 其余任务并行执行
|
||||||
await refreshRecordAndFavorites();
|
await Promise.all([
|
||||||
|
refreshAllLiveChannels(),
|
||||||
|
refreshOpenList(),
|
||||||
|
refreshRecordAndFavorites(),
|
||||||
|
checkAnimeSubscriptions(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAllLiveChannels() {
|
async function refreshAllLiveChannels() {
|
||||||
@@ -162,26 +210,28 @@ async function refreshRecordAndFavorites() {
|
|||||||
const key = `${source}+${id}`;
|
const key = `${source}+${id}`;
|
||||||
let promise = detailCache.get(key);
|
let promise = detailCache.get(key);
|
||||||
if (!promise) {
|
if (!promise) {
|
||||||
|
// 立即缓存Promise,避免并发时的竞态条件
|
||||||
promise = fetchVideoDetail({
|
promise = fetchVideoDetail({
|
||||||
source,
|
source,
|
||||||
id,
|
id,
|
||||||
fallbackTitle: fallbackTitle.trim(),
|
fallbackTitle: fallbackTitle.trim(),
|
||||||
})
|
})
|
||||||
.then((detail) => {
|
.then((detail) => {
|
||||||
// 成功时才缓存结果
|
|
||||||
const successPromise = Promise.resolve(detail);
|
|
||||||
detailCache.set(key, successPromise);
|
|
||||||
return detail;
|
return detail;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(`获取视频详情失败 (${source}+${id}):`, err);
|
console.error(`获取视频详情失败 (${source}+${id}):`, err);
|
||||||
|
// 失败时从缓存中移除,下次可以重试
|
||||||
|
detailCache.delete(key);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
detailCache.set(key, promise);
|
||||||
}
|
}
|
||||||
return promise;
|
return promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const user of users) {
|
// 处理单个用户的函数
|
||||||
|
const processUser = async (user: string) => {
|
||||||
console.log(`开始处理用户: ${user}`);
|
console.log(`开始处理用户: ${user}`);
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
|
|
||||||
@@ -214,6 +264,14 @@ async function refreshRecordAndFavorites() {
|
|||||||
|
|
||||||
const episodeCount = detail.episodes?.length || 0;
|
const episodeCount = detail.episodes?.length || 0;
|
||||||
if (episodeCount > 0 && episodeCount !== record.total_episodes) {
|
if (episodeCount > 0 && episodeCount !== record.total_episodes) {
|
||||||
|
// 计算新增的剧集数量
|
||||||
|
const newEpisodesCount = episodeCount > record.total_episodes
|
||||||
|
? episodeCount - record.total_episodes
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// 如果有新增剧集,累加到现有的 new_episodes 字段
|
||||||
|
const updatedNewEpisodes = (record.new_episodes || 0) + newEpisodesCount;
|
||||||
|
|
||||||
await db.savePlayRecord(user, source, id, {
|
await db.savePlayRecord(user, source, id, {
|
||||||
title: detail.title || record.title,
|
title: detail.title || record.title,
|
||||||
source_name: record.source_name,
|
source_name: record.source_name,
|
||||||
@@ -225,9 +283,10 @@ async function refreshRecordAndFavorites() {
|
|||||||
total_time: record.total_time,
|
total_time: record.total_time,
|
||||||
save_time: record.save_time,
|
save_time: record.save_time,
|
||||||
search_title: record.search_title,
|
search_title: record.search_title,
|
||||||
|
new_episodes: updatedNewEpisodes > 0 ? updatedNewEpisodes : undefined,
|
||||||
});
|
});
|
||||||
console.log(
|
console.log(
|
||||||
`更新播放记录: ${record.title} (${record.total_episodes} -> ${episodeCount})`
|
`更新播放记录: ${record.title} (${record.total_episodes} -> ${episodeCount}, 新增 ${newEpisodesCount} 集)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +371,7 @@ async function refreshRecordAndFavorites() {
|
|||||||
|
|
||||||
// 收集更新信息用于邮件
|
// 收集更新信息用于邮件
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||||
const playUrl = `${siteUrl}/play?source=${source}&id=${id}`;
|
const playUrl = `${siteUrl}/play?source=${source}&id=${id}&title=${encodeURIComponent(fav.title)}`;
|
||||||
userUpdates.push({
|
userUpdates.push({
|
||||||
title: fav.title,
|
title: fav.title,
|
||||||
oldEpisodes: fav.total_episodes,
|
oldEpisodes: fav.total_episodes,
|
||||||
@@ -331,44 +390,54 @@ async function refreshRecordAndFavorites() {
|
|||||||
|
|
||||||
console.log(`收藏处理完成: ${processedFavorites}/${totalFavorites}`);
|
console.log(`收藏处理完成: ${processedFavorites}/${totalFavorites}`);
|
||||||
|
|
||||||
// 如果有更新,发送汇总邮件
|
// 如果有更新,异步发送汇总邮件(不阻塞主流程)
|
||||||
if (userUpdates.length > 0) {
|
if (userUpdates.length > 0) {
|
||||||
try {
|
(async () => {
|
||||||
const userEmail = storage.getUserEmail ? await storage.getUserEmail(user) : null;
|
try {
|
||||||
const emailNotifications = storage.getEmailNotificationPreference
|
const userEmail = storage.getUserEmail ? await storage.getUserEmail(user) : null;
|
||||||
? await storage.getEmailNotificationPreference(user)
|
const emailNotifications = storage.getEmailNotificationPreference
|
||||||
: false;
|
? await storage.getEmailNotificationPreference(user)
|
||||||
|
: false;
|
||||||
|
|
||||||
if (userEmail && emailNotifications) {
|
if (userEmail && emailNotifications) {
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const emailConfig = config?.EmailConfig;
|
const emailConfig = config?.EmailConfig;
|
||||||
|
|
||||||
if (emailConfig?.enabled) {
|
if (emailConfig?.enabled) {
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||||
const siteName = config?.SiteConfig?.SiteName || 'MoonTVPlus';
|
const siteName = config?.SiteConfig?.SiteName || 'MoonTVPlus';
|
||||||
|
|
||||||
await EmailService.send(emailConfig, {
|
await EmailService.send(emailConfig, {
|
||||||
to: userEmail,
|
to: userEmail,
|
||||||
subject: `📺 收藏更新汇总 - ${userUpdates.length} 部影片有更新`,
|
subject: `📺 收藏更新汇总 - ${userUpdates.length} 部影片有更新`,
|
||||||
html: getBatchFavoriteUpdateEmailTemplate(
|
html: getBatchFavoriteUpdateEmailTemplate(
|
||||||
user,
|
user,
|
||||||
userUpdates,
|
userUpdates,
|
||||||
siteUrl,
|
siteUrl,
|
||||||
siteName
|
siteName
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`邮件汇总已发送至: ${userEmail} (${userUpdates.length} 个更新)`);
|
console.log(`邮件汇总已发送至: ${userEmail} (${userUpdates.length} 个更新)`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error(`发送邮件汇总失败 (${user}):`, emailError);
|
||||||
}
|
}
|
||||||
} catch (emailError) {
|
})().catch(err => console.error(`邮件发送异步任务失败 (${user}):`, err));
|
||||||
console.error(`发送邮件汇总失败 (${user}):`, emailError);
|
|
||||||
// 邮件发送失败不影响主流程
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`获取用户收藏失败 (${user}):`, err);
|
console.error(`获取用户收藏失败 (${user}):`, err);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分批并行处理用户,避免并发过高
|
||||||
|
// 可通过环境变量 CRON_USER_BATCH_SIZE 配置批处理大小,默认为 3
|
||||||
|
const BATCH_SIZE = parseInt(process.env.CRON_USER_BATCH_SIZE || '3', 10);
|
||||||
|
for (let i = 0; i < users.length; i += BATCH_SIZE) {
|
||||||
|
const batch = users.slice(i, i + BATCH_SIZE);
|
||||||
|
console.log(`处理用户批次 ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(users.length / BATCH_SIZE)}: ${batch.join(', ')}`);
|
||||||
|
await Promise.all(batch.map(user => processUser(user)));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('刷新播放记录/收藏任务完成');
|
console.log('刷新播放记录/收藏任务完成');
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
|
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
|
||||||
import { getDetailFromApi } from '@/lib/downstream';
|
import { getDetailFromApi } from '@/lib/downstream';
|
||||||
|
import {
|
||||||
|
executeSavedSourceScript,
|
||||||
|
normalizeScriptDetailResult,
|
||||||
|
normalizeScriptSources,
|
||||||
|
parseScriptSourceValue,
|
||||||
|
} from '@/lib/source-script';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -20,6 +26,49 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsedScriptSource = parseScriptSourceValue(sourceCode);
|
||||||
|
if (parsedScriptSource) {
|
||||||
|
try {
|
||||||
|
const sourcesExecution = await executeSavedSourceScript({
|
||||||
|
key: parsedScriptSource.scriptKey,
|
||||||
|
hook: 'getSources',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
const sources = normalizeScriptSources(sourcesExecution.result);
|
||||||
|
const sourceInfo =
|
||||||
|
sources.find((item) => item.id === parsedScriptSource.sourceId) || {
|
||||||
|
id: parsedScriptSource.sourceId,
|
||||||
|
name: parsedScriptSource.sourceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailExecution = await executeSavedSourceScript({
|
||||||
|
key: parsedScriptSource.scriptKey,
|
||||||
|
hook: 'detail',
|
||||||
|
payload: {
|
||||||
|
id,
|
||||||
|
sourceId: parsedScriptSource.sourceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalized = normalizeScriptDetailResult({
|
||||||
|
source: sourceCode,
|
||||||
|
scriptKey: parsedScriptSource.scriptKey,
|
||||||
|
scriptName: detailExecution.meta?.name || parsedScriptSource.scriptKey,
|
||||||
|
sourceId: parsedScriptSource.sourceId,
|
||||||
|
sourceName: sourceInfo.name,
|
||||||
|
detailId: id,
|
||||||
|
result: detailExecution.result,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(normalized);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 特殊处理 openlist 源
|
// 特殊处理 openlist 源
|
||||||
if (sourceCode === 'openlist') {
|
if (sourceCode === 'openlist') {
|
||||||
try {
|
try {
|
||||||
@@ -91,7 +140,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const listResponse = await client.listDirectory(folderPath, currentPage, pageSize);
|
const listResponse = await client.listDirectory(folderPath, currentPage, pageSize);
|
||||||
|
|
||||||
if (listResponse.code !== 200) {
|
if (listResponse.code !== 200) {
|
||||||
throw new Error('OpenList 列表获取失败');
|
throw new Error('OpenList 列表获取失败1');
|
||||||
}
|
}
|
||||||
|
|
||||||
total = listResponse.data.total;
|
total = listResponse.data.total;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio/slim';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { fetchDoubanWithVerification } from '@/lib/douban-anti-crawler';
|
import { fetchDoubanWithVerification } from '@/lib/douban-anti-crawler';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio/slim';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { fetchDoubanData } from '@/lib/douban';
|
import { fetchDoubanData } from '@/lib/douban';
|
||||||
|
|||||||
@@ -29,11 +29,29 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证 TVBox Token
|
// 验证 TVBox Token(从路径中获取)
|
||||||
const requestToken = params.token;
|
const requestToken = params.token;
|
||||||
const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
|
const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
|
||||||
|
|
||||||
if (!subscribeToken || requestToken !== subscribeToken) {
|
// 检查是否是全局token或用户token
|
||||||
|
let isValidToken = false;
|
||||||
|
if (globalToken && requestToken === globalToken) {
|
||||||
|
// 全局token
|
||||||
|
isValidToken = true;
|
||||||
|
} else {
|
||||||
|
// 检查是否是用户token
|
||||||
|
const { db } = await import('@/lib/db');
|
||||||
|
const username = await db.getUsernameByTvboxToken(requestToken);
|
||||||
|
if (username) {
|
||||||
|
// 检查用户是否被封禁
|
||||||
|
const userInfo = await db.getUserInfoV2(username);
|
||||||
|
if (userInfo && !userInfo.banned) {
|
||||||
|
isValidToken = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidToken) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
code: 401,
|
code: 401,
|
||||||
msg: '无效的访问token',
|
msg: '无效的访问token',
|
||||||
@@ -74,7 +92,7 @@ export async function GET(
|
|||||||
if (ac === 'detail') {
|
if (ac === 'detail') {
|
||||||
return await handleDetailBySearch(client, wd, requestToken, embyKey, request);
|
return await handleDetailBySearch(client, wd, requestToken, embyKey, request);
|
||||||
}
|
}
|
||||||
return await handleSearch(client, wd);
|
return await handleSearch(client, wd, requestToken);
|
||||||
} else if (ids || ac === 'detail') {
|
} else if (ids || ac === 'detail') {
|
||||||
// 详情模式
|
// 详情模式
|
||||||
if (!ids) {
|
if (!ids) {
|
||||||
@@ -91,7 +109,7 @@ export async function GET(
|
|||||||
return await handleDetail(client, ids, requestToken, embyKey, request);
|
return await handleDetail(client, ids, requestToken, embyKey, request);
|
||||||
} else {
|
} else {
|
||||||
// 列表模式
|
// 列表模式
|
||||||
return await handleSearch(client, '');
|
return await handleSearch(client, '', requestToken);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Emby CMS Proxy] 错误:', error);
|
console.error('[Emby CMS Proxy] 错误:', error);
|
||||||
@@ -110,7 +128,7 @@ export async function GET(
|
|||||||
/**
|
/**
|
||||||
* 处理搜索请求
|
* 处理搜索请求
|
||||||
*/
|
*/
|
||||||
async function handleSearch(client: EmbyClient, query: string) {
|
async function handleSearch(client: EmbyClient, query: string, token: string) {
|
||||||
const result = await client.getItems({
|
const result = await client.getItems({
|
||||||
searchTerm: query || undefined,
|
searchTerm: query || undefined,
|
||||||
IncludeItemTypes: 'Movie,Series',
|
IncludeItemTypes: 'Movie,Series',
|
||||||
@@ -122,7 +140,7 @@ async function handleSearch(client: EmbyClient, query: string) {
|
|||||||
const list = result.Items.map((item) => ({
|
const list = result.Items.map((item) => ({
|
||||||
vod_id: item.Id,
|
vod_id: item.Id,
|
||||||
vod_name: item.Name,
|
vod_name: item.Name,
|
||||||
vod_pic: client.getImageUrl(item.Id, 'Primary'),
|
vod_pic: client.getImageUrl(item.Id, 'Primary', undefined, token),
|
||||||
vod_remarks: item.Type === 'Movie' ? '电影' : '剧集',
|
vod_remarks: item.Type === 'Movie' ? '电影' : '剧集',
|
||||||
vod_year: item.ProductionYear?.toString() || '',
|
vod_year: item.ProductionYear?.toString() || '',
|
||||||
vod_content: item.Overview || '',
|
vod_content: item.Overview || '',
|
||||||
@@ -229,7 +247,7 @@ async function handleDetail(
|
|||||||
{
|
{
|
||||||
vod_id: item.Id,
|
vod_id: item.Id,
|
||||||
vod_name: item.Name,
|
vod_name: item.Name,
|
||||||
vod_pic: client.getImageUrl(item.Id, 'Primary'),
|
vod_pic: client.getImageUrl(item.Id, 'Primary', undefined, token),
|
||||||
vod_remarks: item.Type === 'Movie' ? '电影' : '剧集',
|
vod_remarks: item.Type === 'Movie' ? '电影' : '剧集',
|
||||||
vod_year: item.ProductionYear?.toString() || '',
|
vod_year: item.ProductionYear?.toString() || '',
|
||||||
vod_content: item.Overview || '',
|
vod_content: item.Overview || '',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { embyManager } from '@/lib/emby-manager';
|
import { embyManager } from '@/lib/emby-manager';
|
||||||
|
import { getProxyToken } from '@/lib/emby-token';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -19,6 +20,9 @@ export async function GET(request: NextRequest) {
|
|||||||
// 获取Emby客户端
|
// 获取Emby客户端
|
||||||
const client = await embyManager.getClient(embyKey);
|
const client = await embyManager.getClient(embyKey);
|
||||||
|
|
||||||
|
// 获取代理 token(如果启用了代理)
|
||||||
|
const proxyToken = client.isProxyEnabled() ? await getProxyToken(request) : null;
|
||||||
|
|
||||||
// 获取媒体详情
|
// 获取媒体详情
|
||||||
const item = await client.getItem(itemId);
|
const item = await client.getItem(itemId);
|
||||||
|
|
||||||
@@ -54,7 +58,7 @@ export async function GET(request: NextRequest) {
|
|||||||
title: item.Name,
|
title: item.Name,
|
||||||
type: item.Type === 'Movie' ? 'movie' : 'tv',
|
type: item.Type === 'Movie' ? 'movie' : 'tv',
|
||||||
overview: item.Overview || '',
|
overview: item.Overview || '',
|
||||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
poster: client.getImageUrl(item.Id, 'Primary', undefined, proxyToken || undefined),
|
||||||
year: item.ProductionYear?.toString() || '',
|
year: item.ProductionYear?.toString() || '',
|
||||||
rating: item.CommunityRating || 0,
|
rating: item.CommunityRating || 0,
|
||||||
playUrl: item.Type === 'Movie' ? await client.getStreamUrl(item.Id) : undefined,
|
playUrl: item.Type === 'Movie' ? await client.getStreamUrl(item.Id) : undefined,
|
||||||
|
|||||||
155
src/app/api/emby/image/[token]/[itemId]/route.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/* 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/image/{token}/{itemId}?imageType=Primary&maxWidth=300&embyKey=xxx
|
||||||
|
* 代理 Emby 图片
|
||||||
|
*
|
||||||
|
* 权限验证:TVBox Token(路径参数) 或 用户登录(满足其一即可)
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { token: string; itemId: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
// 双重验证:TVBox Token(全局或用户) 或 用户登录
|
||||||
|
const requestToken = params.token;
|
||||||
|
const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
|
||||||
|
// 验证 TVBox Token(全局token或用户token)
|
||||||
|
let hasValidToken = false;
|
||||||
|
if (requestToken === 'proxy') {
|
||||||
|
// 使用固定的 'proxy' token,跳过token验证,依赖用户登录验证
|
||||||
|
hasValidToken = false;
|
||||||
|
} else if (globalToken && requestToken === globalToken) {
|
||||||
|
// 全局token
|
||||||
|
hasValidToken = true;
|
||||||
|
} else {
|
||||||
|
// 检查是否是用户token
|
||||||
|
const { db } = await import('@/lib/db');
|
||||||
|
const username = await db.getUsernameByTvboxToken(requestToken);
|
||||||
|
if (username) {
|
||||||
|
// 检查用户是否被封禁
|
||||||
|
const userInfo = await db.getUserInfoV2(username);
|
||||||
|
if (userInfo && !userInfo.banned) {
|
||||||
|
hasValidToken = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户登录
|
||||||
|
const hasValidAuth = authInfo && authInfo.username;
|
||||||
|
|
||||||
|
// 两者至少满足其一
|
||||||
|
if (!hasValidToken && !hasValidAuth) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemId = params.itemId;
|
||||||
|
const imageType = (searchParams.get('imageType') || 'Primary') as 'Primary' | 'Backdrop' | 'Logo';
|
||||||
|
const maxWidth = searchParams.get('maxWidth') ? parseInt(searchParams.get('maxWidth')!) : undefined;
|
||||||
|
const embyKey = searchParams.get('embyKey') || undefined;
|
||||||
|
|
||||||
|
// 获取 Emby 客户端
|
||||||
|
const client = await getEmbyClient(embyKey);
|
||||||
|
|
||||||
|
// 获取图片 URL(强制获取直接URL,避免代理循环)
|
||||||
|
const imageUrl = client.getImageUrl(itemId, imageType, maxWidth, undefined, true);
|
||||||
|
|
||||||
|
// 构建请求头,添加自定义 User-Agent
|
||||||
|
const requestHeaders: HeadersInit = {
|
||||||
|
'User-Agent': client.getUserAgent(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建 AbortController 用于超时控制
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => abortController.abort(), 20000); // 20秒超时
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 请求图片
|
||||||
|
const imageResponse = await fetch(imageUrl, {
|
||||||
|
headers: requestHeaders,
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清除超时定时器
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!imageResponse.ok) {
|
||||||
|
console.error('[Emby Image] 获取图片失败:', {
|
||||||
|
itemId,
|
||||||
|
imageType,
|
||||||
|
status: imageResponse.status,
|
||||||
|
statusText: imageResponse.statusText,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取图片失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Content-Type
|
||||||
|
const contentType = imageResponse.headers.get('content-type') || 'image/jpeg';
|
||||||
|
|
||||||
|
// 构建响应头
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.set('Content-Type', contentType);
|
||||||
|
|
||||||
|
// 复制重要的响应头
|
||||||
|
const contentLength = imageResponse.headers.get('content-length');
|
||||||
|
if (contentLength) {
|
||||||
|
headers.set('Content-Length', contentLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置缓存头
|
||||||
|
headers.set('Cache-Control', 'public, max-age=86400'); // 缓存1天
|
||||||
|
|
||||||
|
// 返回图片内容
|
||||||
|
return new NextResponse(imageResponse.body, {
|
||||||
|
status: imageResponse.status,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 清除超时定时器
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
console.error('[Emby Image] 请求超时');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '请求超时' },
|
||||||
|
{ status: 504 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Emby Image] 错误:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取图片失败', details: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { getCachedEmbyList, setCachedEmbyList } from '@/lib/emby-cache';
|
import { getCachedEmbyList, setCachedEmbyList } from '@/lib/emby-cache';
|
||||||
import { embyManager } from '@/lib/emby-manager';
|
import { embyManager } from '@/lib/emby-manager';
|
||||||
|
import { getProxyToken } from '@/lib/emby-token';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -31,6 +32,9 @@ export async function GET(request: NextRequest) {
|
|||||||
// 获取Emby客户端
|
// 获取Emby客户端
|
||||||
const client = await embyManager.getClient(embyKey);
|
const client = await embyManager.getClient(embyKey);
|
||||||
|
|
||||||
|
// 获取代理 token(如果启用了代理)
|
||||||
|
const proxyToken = client.isProxyEnabled() ? await getProxyToken(request) : null;
|
||||||
|
|
||||||
// 获取媒体列表
|
// 获取媒体列表
|
||||||
const result = await client.getItems({
|
const result = await client.getItems({
|
||||||
ParentId: parentId,
|
ParentId: parentId,
|
||||||
@@ -46,7 +50,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const list = result.Items.map((item) => ({
|
const list = result.Items.map((item) => ({
|
||||||
id: item.Id,
|
id: item.Id,
|
||||||
title: item.Name,
|
title: item.Name,
|
||||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
poster: client.getImageUrl(item.Id, 'Primary', undefined, proxyToken || undefined),
|
||||||
year: item.ProductionYear?.toString() || '',
|
year: item.ProductionYear?.toString() || '',
|
||||||
rating: item.CommunityRating || 0,
|
rating: item.CommunityRating || 0,
|
||||||
mediaType: item.Type === 'Movie' ? 'movie' : 'tv',
|
mediaType: item.Type === 'Movie' ? 'movie' : 'tv',
|
||||||
|
|||||||
@@ -35,13 +35,32 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
// 双重验证:TVBox Token 或 用户登录
|
// 双重验证:TVBox Token(全局或用户) 或 用户登录
|
||||||
const requestToken = params.token;
|
const requestToken = params.token;
|
||||||
const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
|
const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
|
||||||
// 验证 TVBox Token
|
// 验证 TVBox Token(全局token或用户token)
|
||||||
const hasValidToken = subscribeToken && requestToken === subscribeToken;
|
let hasValidToken = false;
|
||||||
|
if (requestToken === 'proxy') {
|
||||||
|
// 使用固定的 'proxy' token,跳过token验证,依赖用户登录验证
|
||||||
|
hasValidToken = false;
|
||||||
|
} else if (globalToken && requestToken === globalToken) {
|
||||||
|
// 全局token
|
||||||
|
hasValidToken = true;
|
||||||
|
} else {
|
||||||
|
// 检查是否是用户token
|
||||||
|
const { db } = await import('@/lib/db');
|
||||||
|
const username = await db.getUsernameByTvboxToken(requestToken);
|
||||||
|
if (username) {
|
||||||
|
// 检查用户是否被封禁
|
||||||
|
const userInfo = await db.getUserInfoV2(username);
|
||||||
|
if (userInfo && !userInfo.banned) {
|
||||||
|
hasValidToken = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 验证用户登录
|
// 验证用户登录
|
||||||
const hasValidAuth = authInfo && authInfo.username;
|
const hasValidAuth = authInfo && authInfo.username;
|
||||||
|
|
||||||
@@ -62,30 +81,53 @@ export async function GET(
|
|||||||
|
|
||||||
// 构建 Emby 原始播放链接(强制获取直接URL,避免代理循环)
|
// 构建 Emby 原始播放链接(强制获取直接URL,避免代理循环)
|
||||||
let embyStreamUrl = await client.getStreamUrl(itemId, true, true);
|
let embyStreamUrl = await client.getStreamUrl(itemId, true, true);
|
||||||
|
console.log(embyStreamUrl)
|
||||||
|
|
||||||
// 构建请求头,转发 Range 请求
|
// 构建请求头,转发 Range 请求,并添加自定义 User-Agent
|
||||||
const requestHeaders: HeadersInit = {};
|
const requestHeaders: HeadersInit = {
|
||||||
|
'User-Agent': client.getUserAgent(),
|
||||||
|
};
|
||||||
const rangeHeader = request.headers.get('range');
|
const rangeHeader = request.headers.get('range');
|
||||||
if (rangeHeader) {
|
if (rangeHeader) {
|
||||||
requestHeaders['Range'] = rangeHeader;
|
requestHeaders['Range'] = rangeHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 流式代理视频内容
|
// 创建 AbortController 用于超时控制
|
||||||
let videoResponse = await fetch(embyStreamUrl, {
|
const abortController = new AbortController();
|
||||||
headers: requestHeaders,
|
const timeoutId = setTimeout(() => abortController.abort(), 300000); // 5分钟超时
|
||||||
});
|
|
||||||
|
|
||||||
// 如果返回 401,尝试重新认证并重试
|
try {
|
||||||
if (videoResponse.status === 401) {
|
// 流式代理视频内容
|
||||||
console.log('[Emby Play] 收到 401 错误,尝试重新认证');
|
let videoResponse = await fetch(embyStreamUrl, {
|
||||||
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,
|
headers: requestHeaders,
|
||||||
|
signal: abortController.signal,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
// 如果返回 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);
|
||||||
|
|
||||||
|
// 重置超时
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
const retryAbortController = new AbortController();
|
||||||
|
const retryTimeoutId = setTimeout(() => retryAbortController.abort(), 300000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
videoResponse = await fetch(embyStreamUrl, {
|
||||||
|
headers: requestHeaders,
|
||||||
|
signal: retryAbortController.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(retryTimeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除超时定时器
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!videoResponse.ok) {
|
if (!videoResponse.ok) {
|
||||||
console.error('[Emby Play] 获取视频流失败:', {
|
console.error('[Emby Play] 获取视频流失败:', {
|
||||||
@@ -125,11 +167,64 @@ export async function GET(
|
|||||||
// 使用 URL 中的文件名
|
// 使用 URL 中的文件名
|
||||||
headers.set('Content-Disposition', `inline; filename="${params.filename}"`);
|
headers.set('Content-Disposition', `inline; filename="${params.filename}"`);
|
||||||
|
|
||||||
// 流式返回视频内容,不等待下载完成
|
// 创建一个可以被中断的流
|
||||||
return new NextResponse(videoResponse.body, {
|
const { readable, writable } = new TransformStream();
|
||||||
|
const reader = videoResponse.body?.getReader();
|
||||||
|
|
||||||
|
if (!reader) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '无法读取视频流' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步管道传输,确保在客户端断开时清理资源
|
||||||
|
(async () => {
|
||||||
|
const writer = writable.getWriter();
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
await writer.write(value);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 客户端断开连接或其他错误
|
||||||
|
console.log('[Emby Play] 流传输中断:', error instanceof Error ? error.message : 'Unknown error');
|
||||||
|
// 取消上游 fetch,停止继续下载
|
||||||
|
try {
|
||||||
|
await reader.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略取消错误
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 确保资源被释放
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
await writer.close();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略关闭错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// 流式返回视频内容
|
||||||
|
return new NextResponse(readable, {
|
||||||
status: videoResponse.status,
|
status: videoResponse.status,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 清除超时定时器
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
console.error('[Emby Play] 请求超时');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '请求超时' },
|
||||||
|
{ status: 504 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Emby Play] 错误:', error);
|
console.error('[Emby Play] 错误:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -147,6 +147,16 @@ async function verifyTurnstileToken(token: string, secretKey: string): Promise<b
|
|||||||
function getDeviceInfo(request: NextRequest): string {
|
function getDeviceInfo(request: NextRequest): string {
|
||||||
const userAgent = request.headers.get('user-agent') || 'Unknown';
|
const userAgent = request.headers.get('user-agent') || 'Unknown';
|
||||||
|
|
||||||
|
// 检查是否为 MoonTVPlus APP
|
||||||
|
if (userAgent.toLowerCase().includes('moontvplus')) {
|
||||||
|
return 'MoonTVPlus APP';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为 OrionTV
|
||||||
|
if (userAgent.toLowerCase().includes('oriontv')) {
|
||||||
|
return 'OrionTV';
|
||||||
|
}
|
||||||
|
|
||||||
// 简单解析 User-Agent
|
// 简单解析 User-Agent
|
||||||
let browser = 'Unknown Browser';
|
let browser = 'Unknown Browser';
|
||||||
let os = 'Unknown OS';
|
let os = 'Unknown OS';
|
||||||
|
|||||||
@@ -174,13 +174,9 @@ export async function POST(request: NextRequest) {
|
|||||||
await storage.createMovieRequest(newRequest);
|
await storage.createMovieRequest(newRequest);
|
||||||
await storage.addUserMovieRequest(authInfo.username, newRequest.id);
|
await storage.addUserMovieRequest(authInfo.username, newRequest.id);
|
||||||
|
|
||||||
// 更新频率限制 - 保存到用户信息的 hash 中
|
// 更新频率限制 - 使用抽象方法兼容所有存储类型
|
||||||
if ('client' in storage && storage.client && typeof (storage.client as any).hSet === 'function') {
|
if (storage.updateLastMovieRequestTime) {
|
||||||
await (storage.client as any).hSet(
|
await storage.updateLastMovieRequestTime(authInfo.username, Date.now());
|
||||||
`user:${authInfo.username}:info`,
|
|
||||||
'last_movie_request_time',
|
|
||||||
Date.now().toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
// 清除用户信息缓存,确保下次读取到最新数据
|
// 清除用户信息缓存,确保下次读取到最新数据
|
||||||
const { userInfoCache } = await import('@/lib/user-cache');
|
const { userInfoCache } = await import('@/lib/user-cache');
|
||||||
|
|||||||
185
src/app/api/music/audio-proxy/route.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { OpenListClient } from '@/lib/openlist.client';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// 获取 OpenList 客户端
|
||||||
|
async function getOpenListClient(): Promise<OpenListClient | null> {
|
||||||
|
const config = await getConfig();
|
||||||
|
const musicConfig = config?.MusicConfig;
|
||||||
|
|
||||||
|
if (!musicConfig?.OpenListCacheEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = musicConfig.OpenListCacheURL;
|
||||||
|
const username = musicConfig.OpenListCacheUsername;
|
||||||
|
const password = musicConfig.OpenListCachePassword;
|
||||||
|
|
||||||
|
if (!url || !username || !password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OpenListClient(url, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代理OpenList缓存的音频文件
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const platform = searchParams.get('platform');
|
||||||
|
const id = searchParams.get('id');
|
||||||
|
const quality = searchParams.get('quality');
|
||||||
|
|
||||||
|
if (!platform || !id || !quality) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '缺少必要参数: platform, id, quality' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取OpenList客户端
|
||||||
|
const openListClient = await getOpenListClient();
|
||||||
|
if (!openListClient) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'OpenList未配置或未启用' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
const config = await getConfig();
|
||||||
|
const cachePath = config?.MusicConfig?.OpenListCachePath || '/music-cache';
|
||||||
|
|
||||||
|
// 构建音频文件路径
|
||||||
|
const audioPath = `${cachePath}/${platform}/audio/${id}-${quality}.mp3`;
|
||||||
|
|
||||||
|
// 获取文件信息
|
||||||
|
const fileResponse = await openListClient.getFile(audioPath);
|
||||||
|
|
||||||
|
if (fileResponse.code !== 200 || !fileResponse.data?.raw_url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '音频文件未找到' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有 Range 请求头
|
||||||
|
const range = request.headers.get('range');
|
||||||
|
const ifNoneMatch = request.headers.get('if-none-match');
|
||||||
|
const ifModifiedSince = request.headers.get('if-modified-since');
|
||||||
|
|
||||||
|
// 生成基于文件路径的 ETag
|
||||||
|
const generatedETag = `"${Buffer.from(audioPath).toString('base64')}"`;
|
||||||
|
|
||||||
|
// 如果客户端发送了 If-None-Match,检查是否匹配
|
||||||
|
if (ifNoneMatch && ifNoneMatch === generatedETag) {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||||
|
'ETag': generatedETag,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建上游请求头
|
||||||
|
const upstreamHeaders: Record<string, string> = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果有 Range 请求,转发给上游
|
||||||
|
if (range) {
|
||||||
|
upstreamHeaders['Range'] = range;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转发条件请求头到上游
|
||||||
|
if (ifNoneMatch) {
|
||||||
|
upstreamHeaders['If-None-Match'] = ifNoneMatch;
|
||||||
|
}
|
||||||
|
if (ifModifiedSince) {
|
||||||
|
upstreamHeaders['If-Modified-Since'] = ifModifiedSince;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从OpenList获取音频流
|
||||||
|
const response = await fetch(fileResponse.data.raw_url, {
|
||||||
|
headers: upstreamHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果上游返回 304 Not Modified,直接返回 304
|
||||||
|
if (response.status === 304) {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||||
|
'ETag': generatedETag,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 206) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取音频失败' },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取响应头
|
||||||
|
const contentType = response.headers.get('content-type') || 'audio/mpeg';
|
||||||
|
const contentLength = response.headers.get('content-length');
|
||||||
|
const contentRange = response.headers.get('content-range');
|
||||||
|
const acceptRanges = response.headers.get('accept-ranges');
|
||||||
|
const etag = response.headers.get('etag');
|
||||||
|
const lastModified = response.headers.get('last-modified');
|
||||||
|
|
||||||
|
// 创建响应头 - 设置永久缓存
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=31536000, immutable', // 永久缓存(1年)
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Accept-Ranges': acceptRanges || 'bytes',
|
||||||
|
'X-Cache-Source': 'openlist-audio-proxy',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (contentLength) {
|
||||||
|
headers['Content-Length'] = contentLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果上游返回了 Content-Range,转发给客户端
|
||||||
|
if (contentRange) {
|
||||||
|
headers['Content-Range'] = contentRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转发 ETag 和 Last-Modified 以支持浏览器缓存验证
|
||||||
|
if (etag) {
|
||||||
|
headers['ETag'] = etag;
|
||||||
|
}
|
||||||
|
if (lastModified) {
|
||||||
|
headers['Last-Modified'] = lastModified;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果上游没有提供 ETag,使用生成的 ETag
|
||||||
|
if (!etag) {
|
||||||
|
headers['ETag'] = generatedETag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回音频流,保持原始状态码(200 或 206)
|
||||||
|
return new NextResponse(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('代理OpenList音频失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: '代理请求失败',
|
||||||
|
details: (error as Error).message,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/app/api/music/playlists/route.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// GET - 获取用户的所有歌单
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 从 cookie 获取用户信息
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户状态
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfoV2) {
|
||||||
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (userInfoV2.banned) {
|
||||||
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlists = await db.getUserMusicPlaylists(authInfo.username);
|
||||||
|
|
||||||
|
return NextResponse.json({ playlists });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET /api/music/playlists error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST - 创建新歌单
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 从 cookie 获取用户信息
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户状态
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfoV2) {
|
||||||
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (userInfoV2.banned) {
|
||||||
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, description } = body;
|
||||||
|
|
||||||
|
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '歌单名称不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistId = randomUUID();
|
||||||
|
|
||||||
|
await db.createMusicPlaylist(authInfo.username, {
|
||||||
|
id: playlistId,
|
||||||
|
name: name.trim(),
|
||||||
|
description: description?.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlist = await db.getMusicPlaylist(playlistId);
|
||||||
|
|
||||||
|
return NextResponse.json({ playlist });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('POST /api/music/playlists error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT - 更新歌单信息
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 从 cookie 获取用户信息
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户状态
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfoV2) {
|
||||||
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (userInfoV2.banned) {
|
||||||
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { playlistId, name, description, cover } = body;
|
||||||
|
|
||||||
|
if (!playlistId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '歌单ID不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查歌单是否存在且属于当前用户
|
||||||
|
const playlist = await db.getMusicPlaylist(playlistId);
|
||||||
|
if (!playlist) {
|
||||||
|
return NextResponse.json({ error: '歌单不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (playlist.username !== authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '无权限操作此歌单' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: any = {};
|
||||||
|
if (name !== undefined) updates.name = name.trim();
|
||||||
|
if (description !== undefined) updates.description = description?.trim();
|
||||||
|
if (cover !== undefined) updates.cover = cover;
|
||||||
|
|
||||||
|
await db.updateMusicPlaylist(playlistId, updates);
|
||||||
|
|
||||||
|
const updatedPlaylist = await db.getMusicPlaylist(playlistId);
|
||||||
|
|
||||||
|
return NextResponse.json({ playlist: updatedPlaylist });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PUT /api/music/playlists error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE - 删除歌单
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 从 cookie 获取用户信息
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户状态
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfoV2) {
|
||||||
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (userInfoV2.banned) {
|
||||||
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const playlistId = searchParams.get('playlistId');
|
||||||
|
|
||||||
|
if (!playlistId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '歌单ID不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查歌单是否存在且属于当前用户
|
||||||
|
const playlist = await db.getMusicPlaylist(playlistId);
|
||||||
|
if (!playlist) {
|
||||||
|
return NextResponse.json({ error: '歌单不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (playlist.username !== authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '无权限操作此歌单' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.deleteMusicPlaylist(playlistId);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DELETE /api/music/playlists error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
187
src/app/api/music/playlists/songs/route.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// GET - 获取歌单中的所有歌曲
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 从 cookie 获取用户信息
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户状态
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfoV2) {
|
||||||
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (userInfoV2.banned) {
|
||||||
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const playlistId = searchParams.get('playlistId');
|
||||||
|
|
||||||
|
if (!playlistId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '歌单ID不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查歌单是否存在且属于当前用户
|
||||||
|
const playlist = await db.getMusicPlaylist(playlistId);
|
||||||
|
if (!playlist) {
|
||||||
|
return NextResponse.json({ error: '歌单不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (playlist.username !== authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '无权限访问此歌单' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const songs = await db.getPlaylistSongs(playlistId);
|
||||||
|
|
||||||
|
return NextResponse.json({ songs });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET /api/music/playlists/songs error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST - 添加歌曲到歌单
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 从 cookie 获取用户信息
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户状态
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfoV2) {
|
||||||
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (userInfoV2.banned) {
|
||||||
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { playlistId, song } = body;
|
||||||
|
|
||||||
|
if (!playlistId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '歌单ID不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!song || !song.platform || !song.id || !song.name || !song.artist) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '歌曲信息不完整' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查歌单是否存在且属于当前用户
|
||||||
|
const playlist = await db.getMusicPlaylist(playlistId);
|
||||||
|
if (!playlist) {
|
||||||
|
return NextResponse.json({ error: '歌单不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (playlist.username !== authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '无权限操作此歌单' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查歌曲是否已在歌单中
|
||||||
|
const exists = await db.isSongInPlaylist(playlistId, song.platform, song.id);
|
||||||
|
if (exists) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '歌曲已在歌单中' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.addSongToPlaylist(playlistId, {
|
||||||
|
platform: song.platform,
|
||||||
|
id: song.id,
|
||||||
|
name: song.name,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
pic: song.pic,
|
||||||
|
duration: song.duration || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('POST /api/music/playlists/songs error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE - 从歌单中移除歌曲
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 从 cookie 获取用户信息
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户状态
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfoV2) {
|
||||||
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (userInfoV2.banned) {
|
||||||
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const playlistId = searchParams.get('playlistId');
|
||||||
|
const platform = searchParams.get('platform');
|
||||||
|
const songId = searchParams.get('songId');
|
||||||
|
|
||||||
|
if (!playlistId || !platform || !songId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '参数不完整' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查歌单是否存在且属于当前用户
|
||||||
|
const playlist = await db.getMusicPlaylist(playlistId);
|
||||||
|
if (!playlist) {
|
||||||
|
return NextResponse.json({ error: '歌单不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (playlist.username !== authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '无权限操作此歌单' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.removeSongFromPlaylist(playlistId, platform, songId);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DELETE /api/music/playlists/songs error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
227
src/app/api/music/playrecords/route.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { MusicPlayRecord } from '@/lib/db.client';
|
||||||
|
import { getCachedSongs, setCachedSong } from '@/lib/music-song-cache';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 从 cookie 获取用户信息
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户状态
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
// 非站长,检查用户存在或被封禁
|
||||||
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfoV2) {
|
||||||
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (userInfoV2.banned) {
|
||||||
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await db.getAllMusicPlayRecords(authInfo.username);
|
||||||
|
|
||||||
|
// 从缓存中获取歌曲信息并填充到记录中
|
||||||
|
const keys = Object.keys(records).map(key => {
|
||||||
|
const [platform, id] = key.split('+');
|
||||||
|
return { platform, id };
|
||||||
|
});
|
||||||
|
const cachedSongs = getCachedSongs(keys);
|
||||||
|
|
||||||
|
// 将缓存的歌曲信息合并到记录中
|
||||||
|
const enrichedRecords: Record<string, MusicPlayRecord> = {};
|
||||||
|
for (const [key, record] of Object.entries(records)) {
|
||||||
|
const cachedSong = cachedSongs.get(key);
|
||||||
|
enrichedRecords[key] = {
|
||||||
|
...record,
|
||||||
|
name: cachedSong?.name || record.name,
|
||||||
|
artist: cachedSong?.artist || record.artist,
|
||||||
|
album: cachedSong?.album || record.album,
|
||||||
|
pic: cachedSong?.pic || record.pic,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(enrichedRecords, { status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取音乐播放记录失败', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 从 cookie 获取用户信息
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
// 非站长,检查用户存在或被封禁
|
||||||
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfoV2) {
|
||||||
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (userInfoV2.banned) {
|
||||||
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// 检查是否是批量添加
|
||||||
|
if (Array.isArray(body.records)) {
|
||||||
|
// 批量添加
|
||||||
|
const records: Array<{ platform: string; id: string; record: MusicPlayRecord }> = [];
|
||||||
|
|
||||||
|
for (const item of body.records) {
|
||||||
|
const { key, record } = item;
|
||||||
|
if (!key || !record) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing key or record in batch item' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证音乐播放记录数据
|
||||||
|
if (!record.platform || !record.id || !record.name || !record.artist) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid record data in batch item' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从key中解析platform和id
|
||||||
|
const [platform, id] = key.split('+');
|
||||||
|
if (!platform || !id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid key format in batch item' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
records.push({ platform, id, record });
|
||||||
|
|
||||||
|
// 缓存歌曲信息到服务器内存
|
||||||
|
setCachedSong(platform, id, {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
artist: record.artist,
|
||||||
|
album: record.album,
|
||||||
|
pic: record.pic,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量保存到数据库
|
||||||
|
await db.batchSaveMusicPlayRecords(authInfo.username, records);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, count: records.length }, { status: 200 });
|
||||||
|
} else {
|
||||||
|
// 单个添加(保持向后兼容)
|
||||||
|
const { key, record }: { key: string; record: MusicPlayRecord } = body;
|
||||||
|
|
||||||
|
if (!key || !record) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing key or record' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证音乐播放记录数据
|
||||||
|
if (!record.platform || !record.id || !record.name || !record.artist) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid record data' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从key中解析platform和id
|
||||||
|
const [platform, id] = key.split('+');
|
||||||
|
if (!platform || !id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid key format' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.saveMusicPlayRecord(authInfo.username, platform, id, record);
|
||||||
|
|
||||||
|
// 缓存歌曲信息到服务器内存
|
||||||
|
setCachedSong(platform, id, {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
artist: record.artist,
|
||||||
|
album: record.album,
|
||||||
|
pic: record.pic,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('保存音乐播放记录失败', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 从 cookie 获取用户信息
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
// 非站长,检查用户存在或被封禁
|
||||||
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (!userInfoV2) {
|
||||||
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (userInfoV2.banned) {
|
||||||
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const key = searchParams.get('key');
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
// 删除单条记录
|
||||||
|
const [platform, id] = key.split('+');
|
||||||
|
if (!platform || !id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid key format' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await db.deleteMusicPlayRecord(authInfo.username, platform, id);
|
||||||
|
} else {
|
||||||
|
// 清空所有记录
|
||||||
|
await db.clearAllMusicPlayRecords(authInfo.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('删除音乐播放记录失败', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/app/api/music/proxy/route.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// 代理音频流
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全检查:只允许代理音乐平台的音频和图片 CDN
|
||||||
|
const allowedDomains = [
|
||||||
|
'sycdn.kuwo.cn',
|
||||||
|
'kwcdn.kuwo.cn',
|
||||||
|
'img1.kwcdn.kuwo.cn',
|
||||||
|
'img2.kwcdn.kuwo.cn',
|
||||||
|
'img3.kwcdn.kuwo.cn',
|
||||||
|
'img4.kwcdn.kuwo.cn',
|
||||||
|
'music.163.com',
|
||||||
|
'y.qq.com',
|
||||||
|
'ws.stream.qqmusic.qq.com',
|
||||||
|
'isure.stream.qqmusic.qq.com',
|
||||||
|
'dl.stream.qqmusic.qq.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
let urlObj: URL;
|
||||||
|
try {
|
||||||
|
urlObj = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '无效的 URL' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllowed = allowedDomains.some(domain =>
|
||||||
|
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
console.warn(`拒绝代理音频请求: ${urlObj.hostname}`);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '不允许的目标域名' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有 Range 请求头
|
||||||
|
const range = request.headers.get('range');
|
||||||
|
|
||||||
|
// 构建上游请求头
|
||||||
|
const upstreamHeaders: Record<string, string> = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
'Referer': 'http://www.kuwo.cn/',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果有 Range 请求,转发给上游
|
||||||
|
if (range) {
|
||||||
|
upstreamHeaders['Range'] = range;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发起请求获取音频流
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: upstreamHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 206) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取音频失败' },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取响应头
|
||||||
|
const contentType = response.headers.get('content-type') || 'audio/mpeg';
|
||||||
|
const contentLength = response.headers.get('content-length');
|
||||||
|
const contentRange = response.headers.get('content-range');
|
||||||
|
const acceptRanges = response.headers.get('accept-ranges');
|
||||||
|
|
||||||
|
// 创建响应头
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Accept-Ranges': acceptRanges || 'bytes',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (contentLength) {
|
||||||
|
headers['Content-Length'] = contentLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果上游返回了 Content-Range,转发给客户端
|
||||||
|
if (contentRange) {
|
||||||
|
headers['Content-Range'] = contentRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回音频流,保持原始状态码(200 或 206)
|
||||||
|
return new NextResponse(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('代理音频失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: '代理请求失败',
|
||||||
|
details: (error as Error).message,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
732
src/app/api/music/route.ts
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { OpenListClient } from '@/lib/openlist.client';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// 检测是否为 Cloudflare 环境
|
||||||
|
const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
|
||||||
|
|
||||||
|
// 服务器端内存缓存
|
||||||
|
const serverCache = {
|
||||||
|
methodConfigs: new Map<string, { data: any; timestamp: number }>(),
|
||||||
|
proxyRequests: new Map<string, { data: any; timestamp: number }>(),
|
||||||
|
CACHE_DURATION: 24 * 60 * 60 * 1000, // 24小时缓存
|
||||||
|
};
|
||||||
|
|
||||||
|
// 正在下载的音频任务追踪(防止重复下载)
|
||||||
|
const downloadingTasks = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
// 获取 TuneHub 配置
|
||||||
|
async function getTuneHubConfig() {
|
||||||
|
const config = await getConfig();
|
||||||
|
const musicConfig = config?.MusicConfig;
|
||||||
|
|
||||||
|
const enabled = musicConfig?.TuneHubEnabled ?? false;
|
||||||
|
const baseUrl =
|
||||||
|
musicConfig?.TuneHubBaseUrl ||
|
||||||
|
process.env.TUNEHUB_BASE_URL ||
|
||||||
|
'https://tunehub.sayqz.com/api';
|
||||||
|
const apiKey = musicConfig?.TuneHubApiKey || process.env.TUNEHUB_API_KEY || '';
|
||||||
|
|
||||||
|
return { enabled, baseUrl, apiKey, musicConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 OpenList 客户端
|
||||||
|
async function getOpenListClient(): Promise<OpenListClient | null> {
|
||||||
|
const config = await getConfig();
|
||||||
|
const musicConfig = config?.MusicConfig;
|
||||||
|
|
||||||
|
if (!musicConfig?.OpenListCacheEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = musicConfig.OpenListCacheURL;
|
||||||
|
const username = musicConfig.OpenListCacheUsername;
|
||||||
|
const password = musicConfig.OpenListCachePassword;
|
||||||
|
|
||||||
|
if (!url || !username || !password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OpenListClient(url, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步下载音频文件并上传到 OpenList
|
||||||
|
async function cacheAudioToOpenList(
|
||||||
|
openListClient: OpenListClient,
|
||||||
|
audioUrl: string,
|
||||||
|
platform: string,
|
||||||
|
songId: string,
|
||||||
|
quality: string,
|
||||||
|
cachePath: string
|
||||||
|
): Promise<void> {
|
||||||
|
const taskKey = `${platform}-${songId}-${quality}`;
|
||||||
|
|
||||||
|
// 检查是否已经有任务在下载
|
||||||
|
const existingTask = downloadingTasks.get(taskKey);
|
||||||
|
if (existingTask) {
|
||||||
|
return existingTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建下载任务
|
||||||
|
const downloadTask = (async () => {
|
||||||
|
try {
|
||||||
|
const audioPath = `${cachePath}/${platform}/audio/${songId}-${quality}.mp3`;
|
||||||
|
|
||||||
|
const audioResponse = await fetch(audioUrl);
|
||||||
|
|
||||||
|
if (!audioResponse.ok) {
|
||||||
|
console.error('[Music Cache] 下载音频失败:', audioResponse.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBuffer = await audioResponse.arrayBuffer();
|
||||||
|
const audioBlob = Buffer.from(audioBuffer);
|
||||||
|
|
||||||
|
const token = await (openListClient as any).getToken();
|
||||||
|
|
||||||
|
const uploadResponse = await fetch(`${(openListClient as any).baseURL}/api/fs/put`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': token,
|
||||||
|
'Content-Type': 'audio/mpeg',
|
||||||
|
'File-Path': encodeURIComponent(audioPath),
|
||||||
|
'As-Task': 'false',
|
||||||
|
},
|
||||||
|
body: audioBlob,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
const errorText = await uploadResponse.text();
|
||||||
|
console.error('[Music Cache] 上传音频失败:', uploadResponse.status, errorText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Music Cache] 缓存音频到 OpenList 失败:', error);
|
||||||
|
} finally {
|
||||||
|
downloadingTasks.delete(taskKey);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
downloadingTasks.set(taskKey, downloadTask);
|
||||||
|
|
||||||
|
return downloadTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查并替换音频 URL 为 OpenList URL
|
||||||
|
async function replaceAudioUrlsWithOpenList(
|
||||||
|
data: any,
|
||||||
|
openListClient: OpenListClient | null,
|
||||||
|
platform: string,
|
||||||
|
quality: string,
|
||||||
|
cachePath: string
|
||||||
|
): Promise<any> {
|
||||||
|
// 获取配置,检查是否启用 OpenList 缓存
|
||||||
|
const config = await getConfig();
|
||||||
|
const cacheEnabled = config?.MusicConfig?.OpenListCacheEnabled ?? false;
|
||||||
|
const cacheProxyEnabled = config?.MusicConfig?.OpenListCacheProxyEnabled ?? true;
|
||||||
|
|
||||||
|
// 如果没有启用 OpenList 缓存,直接返回原数据
|
||||||
|
if (!cacheEnabled || !openListClient || !data?.data) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TuneHub 返回的数据结构是 { code: 0, data: { data: [...], total: 1 } }
|
||||||
|
// 需要提取内层的 data 数组
|
||||||
|
const songsData = data.data.data || data.data;
|
||||||
|
const songs = Array.isArray(songsData) ? songsData : [songsData];
|
||||||
|
|
||||||
|
for (const song of songs) {
|
||||||
|
if (!song?.id || !song?.url) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioPath = `${cachePath}/${platform}/audio/${song.id}-${quality}.mp3`;
|
||||||
|
|
||||||
|
// 如果缓存中已经标记为已缓存,且使用代理模式,直接返回代理URL
|
||||||
|
if (song.cached === true && cacheProxyEnabled) {
|
||||||
|
song.url = `/api/music/audio-proxy?platform=${platform}&id=${song.id}&quality=${quality}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 只有在未确认缓存状态时才调用 getFile()
|
||||||
|
const fileResponse = await openListClient.getFile(audioPath);
|
||||||
|
|
||||||
|
if (fileResponse.code === 200 && fileResponse.data?.raw_url) {
|
||||||
|
// 如果启用缓存代理,返回代理URL;否则返回直接URL
|
||||||
|
if (cacheProxyEnabled) {
|
||||||
|
// 使用代理URL,通过我们的服务器代理OpenList的音频
|
||||||
|
song.url = `/api/music/audio-proxy?platform=${platform}&id=${song.id}&quality=${quality}`;
|
||||||
|
} else {
|
||||||
|
// 直接使用OpenList的raw_url
|
||||||
|
song.url = fileResponse.data.raw_url;
|
||||||
|
}
|
||||||
|
song.cached = true;
|
||||||
|
} else {
|
||||||
|
song.cached = false;
|
||||||
|
|
||||||
|
cacheAudioToOpenList(openListClient, song.url, platform, song.id, quality, cachePath)
|
||||||
|
.catch(error => {
|
||||||
|
console.error('[Music Cache] 异步缓存音频失败:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
song.cached = false;
|
||||||
|
|
||||||
|
cacheAudioToOpenList(openListClient, song.url, platform, song.id, quality, cachePath)
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[Music Cache] 异步缓存音频失败:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用请求处理函数
|
||||||
|
async function proxyRequest(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TuneHub API 请求失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取方法配置并执行请求
|
||||||
|
async function executeMethod(
|
||||||
|
baseUrl: string,
|
||||||
|
platform: string,
|
||||||
|
func: string,
|
||||||
|
variables: Record<string, string> = {}
|
||||||
|
): Promise<any> {
|
||||||
|
// 1. 获取方法配置
|
||||||
|
const cacheKey = `method-config-${platform}-${func}`;
|
||||||
|
let config: any;
|
||||||
|
|
||||||
|
const cached = serverCache.methodConfigs.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
|
||||||
|
config = cached.data.data;
|
||||||
|
} else {
|
||||||
|
const response = await proxyRequest(`${baseUrl}/v1/methods/${platform}/${func}`);
|
||||||
|
const data = await response.json();
|
||||||
|
serverCache.methodConfigs.set(cacheKey, { data, timestamp: Date.now() });
|
||||||
|
config = data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('无法获取方法配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 替换模板变量
|
||||||
|
let url = config.url;
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
|
// 先将 variables 中的值转换为可执行的变量
|
||||||
|
const evalContext: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(variables)) {
|
||||||
|
// 尝试将字符串转换为数字(如果可能)
|
||||||
|
const numValue = Number(value);
|
||||||
|
evalContext[key] = isNaN(numValue) ? value : numValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归处理对象中的模板变量
|
||||||
|
function processTemplateValue(value: any): any {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// 处理包含模板变量的表达式
|
||||||
|
const expressionRegex = /\{\{(.+?)\}\}/g;
|
||||||
|
return value.replace(expressionRegex, (match, expression) => {
|
||||||
|
try {
|
||||||
|
// 在 Cloudflare 环境下,使用简单的表达式替换
|
||||||
|
if (isCloudflare) {
|
||||||
|
const expr = expression.trim();
|
||||||
|
|
||||||
|
// 检查是否是单个变量(没有运算符)
|
||||||
|
if (evalContext.hasOwnProperty(expr)) {
|
||||||
|
// 直接返回变量值
|
||||||
|
return String(evalContext[expr]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理包含运算的表达式(如 page - 1)
|
||||||
|
let result: any = expr;
|
||||||
|
|
||||||
|
// 替换变量为其值
|
||||||
|
for (const [key, val] of Object.entries(evalContext)) {
|
||||||
|
const regex = new RegExp(`\\b${key}\\b`, 'g');
|
||||||
|
// 对于数字直接替换,对于字符串需要加引号以便 eval
|
||||||
|
const replacement = typeof val === 'number' ? String(val) : `"${String(val).replace(/"/g, '\\"')}"`;
|
||||||
|
result = result.replace(regex, replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试计算表达式
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-eval
|
||||||
|
result = eval(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[executeMethod] Cloudflare 环境执行表达式失败: ${expr}`, err);
|
||||||
|
// 如果计算失败,尝试直接返回替换后的结果(去掉可能的引号)
|
||||||
|
result = result.replace(/^["']|["']$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(result);
|
||||||
|
} else {
|
||||||
|
// 在 Node.js 环境下,使用 Function 构造器
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
const func = new Function(...Object.keys(evalContext), `return ${expression}`);
|
||||||
|
const result = func(...Object.values(evalContext));
|
||||||
|
return String(result);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[executeMethod] 执行表达式失败: ${expression}`, err);
|
||||||
|
return '0'; // 默认值
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
return value.map(item => processTemplateValue(item));
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
const result: any = {};
|
||||||
|
for (const [k, v] of Object.entries(value)) {
|
||||||
|
result[k] = processTemplateValue(v);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 URL 参数
|
||||||
|
if (config.params) {
|
||||||
|
for (const [key, value] of Object.entries(config.params)) {
|
||||||
|
params[key] = processTemplateValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 POST body
|
||||||
|
let processedBody = config.body;
|
||||||
|
if (config.body) {
|
||||||
|
processedBody = processTemplateValue(config.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 构建完整 URL
|
||||||
|
if (config.method === 'GET' && Object.keys(params).length > 0) {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
urlObj.searchParams.append(key, value);
|
||||||
|
}
|
||||||
|
url = urlObj.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 发起请求
|
||||||
|
const requestOptions: RequestInit = {
|
||||||
|
method: config.method || 'GET',
|
||||||
|
headers: config.headers || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.method === 'POST' && processedBody) {
|
||||||
|
requestOptions.body = JSON.stringify(processedBody);
|
||||||
|
requestOptions.headers = {
|
||||||
|
...requestOptions.headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await proxyRequest(url, requestOptions);
|
||||||
|
let data = await response.json();
|
||||||
|
|
||||||
|
// 5. 执行 transform 函数(如果有)
|
||||||
|
if (config.transform) {
|
||||||
|
// 在 Cloudflare 环境下,将 transform 函数返回给前端执行
|
||||||
|
if (isCloudflare) {
|
||||||
|
// 将 transform 函数字符串附加到响应数据中
|
||||||
|
data.__transform = config.transform;
|
||||||
|
} else {
|
||||||
|
// 在 Node.js 环境下,直接执行 transform
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-eval
|
||||||
|
const transformFn = eval(`(${config.transform})`);
|
||||||
|
data = transformFn(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[executeMethod] Transform 函数执行失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 处理酷我音乐的图片 URL(转换为代理 URL)
|
||||||
|
if (platform === 'kuwo') {
|
||||||
|
const processKuwoImages = (obj: any): any => {
|
||||||
|
if (typeof obj === 'string' && obj.startsWith('http://') && obj.includes('kwcdn.kuwo.cn')) {
|
||||||
|
// 将 HTTP 图片 URL 转换为代理 URL
|
||||||
|
return `/api/music/proxy?url=${encodeURIComponent(obj)}`;
|
||||||
|
} else if (Array.isArray(obj)) {
|
||||||
|
return obj.map(item => processKuwoImages(item));
|
||||||
|
} else if (typeof obj === 'object' && obj !== null) {
|
||||||
|
const result: any = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
result[key] = processKuwoImages(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
|
||||||
|
data = processKuwoImages(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET 请求处理
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { enabled, baseUrl } = await getTuneHubConfig();
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '音乐功能未开启' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const action = searchParams.get('action');
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '缺少 action 参数' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理不同的 action
|
||||||
|
switch (action) {
|
||||||
|
case 'toplists': {
|
||||||
|
// 获取排行榜列表
|
||||||
|
const platform = searchParams.get('platform');
|
||||||
|
if (!platform) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '缺少 platform 参数' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `toplists-${platform}`;
|
||||||
|
const cached = serverCache.proxyRequests.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
|
||||||
|
return NextResponse.json(cached.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await executeMethod(baseUrl, platform, 'toplists');
|
||||||
|
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'toplist': {
|
||||||
|
// 获取排行榜详情
|
||||||
|
const platform = searchParams.get('platform');
|
||||||
|
const id = searchParams.get('id');
|
||||||
|
|
||||||
|
if (!platform || !id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '缺少 platform 或 id 参数' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `toplist-${platform}-${id}`;
|
||||||
|
const cached = serverCache.proxyRequests.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
|
||||||
|
return NextResponse.json(cached.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await executeMethod(baseUrl, platform, 'toplist', { id });
|
||||||
|
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'playlist': {
|
||||||
|
// 获取歌单详情
|
||||||
|
const platform = searchParams.get('platform');
|
||||||
|
const id = searchParams.get('id');
|
||||||
|
|
||||||
|
if (!platform || !id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '缺少 platform 或 id 参数' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `playlist-${platform}-${id}`;
|
||||||
|
const cached = serverCache.proxyRequests.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
|
||||||
|
return NextResponse.json(cached.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await executeMethod(baseUrl, platform, 'playlist', { id });
|
||||||
|
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'search': {
|
||||||
|
// 搜索歌曲
|
||||||
|
const platform = searchParams.get('platform');
|
||||||
|
const keyword = searchParams.get('keyword');
|
||||||
|
const page = searchParams.get('page') || '1';
|
||||||
|
const pageSize = searchParams.get('pageSize') || '20';
|
||||||
|
|
||||||
|
if (!platform || !keyword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '缺少 platform 或 keyword 参数' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `search-${platform}-${keyword}-${page}-${pageSize}`;
|
||||||
|
const cached = serverCache.proxyRequests.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
|
||||||
|
return NextResponse.json(cached.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意:不同平台可能使用不同的变量名
|
||||||
|
// 统一传递 keyword, page, pageSize, limit (limit = pageSize)
|
||||||
|
const data = await executeMethod(baseUrl, platform, 'search', {
|
||||||
|
keyword,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
limit: pageSize, // 有些平台使用 limit 而不是 pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '不支持的 action' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('音乐 API 错误:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: '请求失败',
|
||||||
|
details: (error as Error).message,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST 请求处理(用于解析歌曲)
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { enabled, baseUrl, apiKey } = await getTuneHubConfig();
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '音乐功能未开启' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { action } = body;
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '缺少 action 参数' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'parse': {
|
||||||
|
// 解析歌曲(需要 API Key)
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
code: -1,
|
||||||
|
error: '未配置 TuneHub API Key',
|
||||||
|
message: '未配置 TuneHub API Key'
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { platform, ids, quality } = body;
|
||||||
|
if (!platform || !ids) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
code: -1,
|
||||||
|
error: '缺少 platform 或 ids 参数',
|
||||||
|
message: '缺少 platform 或 ids 参数'
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加缓存支持
|
||||||
|
const qualityKey = quality || '320k';
|
||||||
|
const idsKey = Array.isArray(ids) ? ids.join(',') : ids;
|
||||||
|
const cacheKey = `parse-${platform}-${idsKey}-${qualityKey}`;
|
||||||
|
|
||||||
|
// 1. 获取 OpenList 配置
|
||||||
|
const openListClient = await getOpenListClient();
|
||||||
|
const config = await getConfig();
|
||||||
|
const cachePath = config?.MusicConfig?.OpenListCachePath || '/music-cache';
|
||||||
|
|
||||||
|
// 2. 检查内存缓存
|
||||||
|
const cached = serverCache.proxyRequests.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
|
||||||
|
// 如果启用了 OpenList,需要检查并替换音频 URL
|
||||||
|
if (openListClient) {
|
||||||
|
const updatedData = await replaceAudioUrlsWithOpenList(
|
||||||
|
cached.data,
|
||||||
|
openListClient,
|
||||||
|
platform,
|
||||||
|
qualityKey,
|
||||||
|
cachePath
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新内存缓存
|
||||||
|
serverCache.proxyRequests.set(cacheKey, { data: updatedData, timestamp: Date.now() });
|
||||||
|
|
||||||
|
return NextResponse.json(updatedData);
|
||||||
|
} else {
|
||||||
|
// 没有 OpenList 配置,直接返回内存缓存
|
||||||
|
return NextResponse.json(cached.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查 OpenList JSON 缓存
|
||||||
|
if (openListClient) {
|
||||||
|
try {
|
||||||
|
const openListPath = `${cachePath}/${platform}/${idsKey}-${qualityKey}.json`;
|
||||||
|
|
||||||
|
const fileResponse = await openListClient.getFile(openListPath);
|
||||||
|
if (fileResponse.code === 200 && fileResponse.data?.raw_url) {
|
||||||
|
// 下载缓存文件
|
||||||
|
const cacheResponse = await fetch(fileResponse.data.raw_url);
|
||||||
|
if (cacheResponse.ok) {
|
||||||
|
const cachedData = await cacheResponse.json();
|
||||||
|
|
||||||
|
// 检查并替换音频 URL
|
||||||
|
const updatedData = await replaceAudioUrlsWithOpenList(
|
||||||
|
cachedData,
|
||||||
|
openListClient,
|
||||||
|
platform,
|
||||||
|
qualityKey,
|
||||||
|
cachePath
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新内存缓存
|
||||||
|
serverCache.proxyRequests.set(cacheKey, { data: updatedData, timestamp: Date.now() });
|
||||||
|
|
||||||
|
return NextResponse.json(updatedData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// OpenList 缓存未命中,继续调用 TuneHub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 调用 TuneHub API 解析
|
||||||
|
try {
|
||||||
|
const response = await proxyRequest(`${baseUrl}/v1/parse`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
platform,
|
||||||
|
ids,
|
||||||
|
quality: qualityKey,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 如果 TuneHub 返回错误,包装成统一格式
|
||||||
|
if (!response.ok || data.code !== 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
code: data.code || -1,
|
||||||
|
message: data.message || data.error || '解析失败',
|
||||||
|
error: data.error || data.message || '解析失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 缓存成功的解析结果到内存
|
||||||
|
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
|
||||||
|
|
||||||
|
// 6. 检查并替换音频 URL 为 OpenList URL(如果已缓存)
|
||||||
|
// 同时异步下载未缓存的音频
|
||||||
|
const finalData = await replaceAudioUrlsWithOpenList(
|
||||||
|
data,
|
||||||
|
openListClient,
|
||||||
|
platform,
|
||||||
|
qualityKey,
|
||||||
|
cachePath
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. 缓存解析结果到 OpenList(异步,不阻塞响应)
|
||||||
|
if (openListClient) {
|
||||||
|
const jsonPath = `${cachePath}/${platform}/${idsKey}-${qualityKey}.json`;
|
||||||
|
openListClient.uploadFile(jsonPath, JSON.stringify(finalData, null, 2))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[Music Cache] 缓存解析结果到 OpenList 失败:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(finalData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析歌曲失败:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
code: -1,
|
||||||
|
message: '解析请求失败',
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '不支持的 action' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('音乐 API 错误:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: '请求失败',
|
||||||
|
details: (error as Error).message,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -304,6 +304,21 @@ export async function DELETE(request: NextRequest) {
|
|||||||
|
|
||||||
const downloader = getDownloader();
|
const downloader = getDownloader();
|
||||||
|
|
||||||
|
// 如果任务正在下载,先标记为取消状态,等待下载停止
|
||||||
|
const downloadPromise = activeDownloads.get(taskId);
|
||||||
|
if (downloadPromise) {
|
||||||
|
// 将任务状态设置为 error,这样下载器会停止下载
|
||||||
|
task.status = 'error';
|
||||||
|
task.errorMessage = '任务已被删除';
|
||||||
|
tasks.set(taskId, task);
|
||||||
|
|
||||||
|
// 从活跃下载列表中移除
|
||||||
|
activeDownloads.delete(taskId);
|
||||||
|
|
||||||
|
// 等待一小段时间,让下载操作有机会停止
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
// 删除文件
|
// 删除文件
|
||||||
await downloader.deleteTask(task);
|
await downloader.deleteTask(task);
|
||||||
|
|
||||||
@@ -311,12 +326,6 @@ export async function DELETE(request: NextRequest) {
|
|||||||
tasks.delete(taskId);
|
tasks.delete(taskId);
|
||||||
saveTasks(); // 持久化任务
|
saveTasks(); // 持久化任务
|
||||||
|
|
||||||
// 如果正在下载,等待下载完成后再删除
|
|
||||||
const downloadPromise = activeDownloads.get(taskId);
|
|
||||||
if (downloadPromise) {
|
|
||||||
activeDownloads.delete(taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ message: '任务已删除' });
|
return NextResponse.json({ message: '任务已删除' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除任务失败:', error);
|
console.error('删除任务失败:', error);
|
||||||
|
|||||||
@@ -30,9 +30,27 @@ export async function GET(
|
|||||||
|
|
||||||
// 验证 TVBox Token(从路径中获取)
|
// 验证 TVBox Token(从路径中获取)
|
||||||
const requestToken = params.token;
|
const requestToken = params.token;
|
||||||
const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
|
const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
|
||||||
|
|
||||||
if (!subscribeToken || requestToken !== subscribeToken) {
|
// 检查是否是全局token或用户token
|
||||||
|
let isValidToken = false;
|
||||||
|
if (globalToken && requestToken === globalToken) {
|
||||||
|
// 全局token
|
||||||
|
isValidToken = true;
|
||||||
|
} else {
|
||||||
|
// 检查是否是用户token
|
||||||
|
const { db } = await import('@/lib/db');
|
||||||
|
const username = await db.getUsernameByTvboxToken(requestToken);
|
||||||
|
if (username) {
|
||||||
|
// 检查用户是否被封禁
|
||||||
|
const userInfo = await db.getUserInfoV2(username);
|
||||||
|
if (userInfo && !userInfo.banned) {
|
||||||
|
isValidToken = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidToken) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
code: 401,
|
code: 401,
|
||||||
@@ -279,7 +297,7 @@ async function handleDetail(
|
|||||||
if (listResponse.code !== 200) {
|
if (listResponse.code !== 200) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
code: 0,
|
code: 0,
|
||||||
msg: 'OpenList 列表获取失败',
|
msg: 'OpenList 列表获取失败2',
|
||||||
page: 1,
|
page: 1,
|
||||||
pagecount: 0,
|
pagecount: 0,
|
||||||
limit: 0,
|
limit: 0,
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (listResponse.code !== 200) {
|
if (listResponse.code !== 200) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'OpenList 列表获取失败' },
|
{ error: 'OpenList 列表获取失败3' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,29 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
// 双重验证:TVBox Token 或 用户登录
|
// 双重验证:TVBox Token(全局或用户) 或 用户登录
|
||||||
const requestToken = params.token;
|
const requestToken = params.token;
|
||||||
const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
|
const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
|
||||||
// 验证 TVBox Token
|
// 验证 TVBox Token(全局token或用户token)
|
||||||
const hasValidToken = subscribeToken && requestToken === subscribeToken;
|
let hasValidToken = false;
|
||||||
|
if (globalToken && requestToken === globalToken) {
|
||||||
|
// 全局token
|
||||||
|
hasValidToken = true;
|
||||||
|
} else {
|
||||||
|
// 检查是否是用户token
|
||||||
|
const { db } = await import('@/lib/db');
|
||||||
|
const username = await db.getUsernameByTvboxToken(requestToken);
|
||||||
|
if (username) {
|
||||||
|
// 检查用户是否被封禁
|
||||||
|
const userInfo = await db.getUserInfoV2(username);
|
||||||
|
if (userInfo && !userInfo.banned) {
|
||||||
|
hasValidToken = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 验证用户登录
|
// 验证用户登录
|
||||||
const hasValidAuth = authInfo && authInfo.username;
|
const hasValidAuth = authInfo && authInfo.username;
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export const maxDuration = 60; // 设置最大执行时间为 60 秒
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M3U8 代理接口
|
* M3U8 代理接口
|
||||||
* 用于外部播放器访问,会执行去广告逻辑并处理相对链接
|
* 用于外部播放器访问,会执行去广告逻辑并处理相对链接
|
||||||
* GET /api/proxy-m3u8?url=<原始m3u8地址>&source=<播放源>&token=<鉴权token>
|
* GET /api/proxy-m3u8?url=<原始m3u8地址>&source=<播放源>&token=<鉴权token>
|
||||||
*/
|
*/
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const m3u8Url = searchParams.get('url');
|
const m3u8Url = searchParams.get('url');
|
||||||
@@ -34,14 +37,51 @@ export async function GET(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DIRECT_PLAY_SOURCE = 'directplay';
|
||||||
|
// 安全校验:防 SSRF / 域名重绑定,只允许合法的公网 URL。对所有经过 proxy-m3u8 的请求强制校验,不仅限于 directplay
|
||||||
|
const isSafeUrl = await validateProxyUrlServerSide(m3u8Url);
|
||||||
|
if (!isSafeUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Proxy request to local or invalid network is forbidden' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 获取当前请求的 origin
|
// 获取当前请求的 origin
|
||||||
const requestUrl = new URL(request.url);
|
// 优先级:SITE_BASE 环境变量 > 从请求头构建
|
||||||
const origin = `${requestUrl.protocol}//${requestUrl.host}`;
|
let origin = process.env.SITE_BASE;
|
||||||
|
if (!origin) {
|
||||||
|
// 从请求头中获取 Host 和协议
|
||||||
|
let host = request.headers.get('host') || request.headers.get('x-forwarded-host');
|
||||||
|
|
||||||
|
// 安全校验:防 Host 头注入漏洞 (要求仅包含合法域名或 IP 格式字符)
|
||||||
|
if (host && !/^[a-zA-Z0-9.-]+(:\d+)?$/.test(host)) {
|
||||||
|
host = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback:如果以上 Header 无效或未提供,回退到 request.url 获取
|
||||||
|
if (!host) {
|
||||||
|
try {
|
||||||
|
host = new URL(request.url).host;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid Request Host' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = request.headers.get('x-forwarded-proto') ||
|
||||||
|
(host.includes('localhost') || host.includes('127.0.0.1') ? 'http' : 'https');
|
||||||
|
origin = `${proto}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 获取原始 m3u8 内容
|
// 获取原始 m3u8 内容
|
||||||
|
const m3u8UrlObj = new URL(m3u8Url);
|
||||||
const response = await fetch(m3u8Url, {
|
const response = await fetch(m3u8Url, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
'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': '*/*',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||||
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
|
'Referer': `${m3u8UrlObj.protocol}//${m3u8UrlObj.host}/`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,8 +92,58 @@ export async function GET(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 后端 MIME Sniffing: 防御伪装成 m3u8 的大文件二进制流
|
||||||
|
// 使用白名单策略:只有明确属于文本/m3u8 类型的才放行解析
|
||||||
|
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||||
|
const isTextType = (
|
||||||
|
contentType === '' || // 无 Content-Type 时保守放行(后续有内容校验兜底)
|
||||||
|
contentType.includes('application/vnd.apple.mpegurl') || // 标准 m3u8
|
||||||
|
contentType.includes('application/x-mpegurl') || // 兼容 m3u8
|
||||||
|
contentType.includes('audio/mpegurl') || // 兼容 m3u8
|
||||||
|
contentType.includes('text/') || // text/plain 等
|
||||||
|
contentType.includes('application/json') // 部分 API 返回 JSON 格式的错误
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isTextType) {
|
||||||
|
if (source === DIRECT_PLAY_SOURCE) {
|
||||||
|
console.log(`[Proxy-M3U8] 检测到非文本媒体流 (Content-Type: ${contentType}), 针对 directplay 直链代理模式,直接透传二进制流, URL: ${m3u8Url}`);
|
||||||
|
// 构造一个新的 Response 对象用于二进制直接透传,确保包含了支持跨域的 header
|
||||||
|
const newHeaders = new Headers(response.headers);
|
||||||
|
newHeaders.set('Access-Control-Allow-Origin', '*');
|
||||||
|
|
||||||
|
// 如果源站返回了跨站相关的禁止头,尽量移除它们
|
||||||
|
newHeaders.delete('X-Frame-Options');
|
||||||
|
newHeaders.delete('Content-Security-Policy');
|
||||||
|
|
||||||
|
return new NextResponse(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: newHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[Proxy-M3U8] 拦截到非文本媒体流 (Content-Type: ${contentType}), 拒绝按文本解析, URL: ${m3u8Url}`);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Unsupported Media Type',
|
||||||
|
details: `The source returned Content-Type "${contentType}", which is not a text m3u8 playlist.`,
|
||||||
|
fallbackToDirect: true,
|
||||||
|
originalUrl: m3u8Url
|
||||||
|
},
|
||||||
|
{ status: 415, headers: { 'Access-Control-Allow-Origin': '*' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let m3u8Content = await response.text();
|
let m3u8Content = await response.text();
|
||||||
|
|
||||||
|
// 二次内容校验:即使 Content-Type 通过了白名单,检查实际内容是否为有效的 m3u8
|
||||||
|
// 有些服务器返回 text/plain 但实际内容是 HTML 错误页或其他格式
|
||||||
|
const trimmedContent = m3u8Content.trimStart();
|
||||||
|
if (trimmedContent.length > 0 && !trimmedContent.startsWith('#EXTM3U') && !trimmedContent.startsWith('#EXT')) {
|
||||||
|
console.warn(`[Proxy-M3U8] 内容校验失败:响应体不以 #EXTM3U 或 #EXT 开头, 可能非有效 m3u8, URL: ${m3u8Url}`);
|
||||||
|
// 不直接拒绝(可能是不规范但仍可播放的 m3u8),仅打印警告继续处理
|
||||||
|
}
|
||||||
|
|
||||||
// 执行去广告逻辑
|
// 执行去广告逻辑
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const customAdFilterCode = config.SiteConfig?.CustomAdFilterCode || '';
|
const customAdFilterCode = config.SiteConfig?.CustomAdFilterCode || '';
|
||||||
@@ -102,7 +192,10 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 默认去广告规则
|
* 默认去广告规则(服务端版本)
|
||||||
|
* 注意:前端 page.tsx 中的 filterAdsFromM3U8 是客户端侧的去广告逻辑(用于直连模式下由 HLS.js 的自定义 loader 拦截)。
|
||||||
|
* 本函数用于代理模式下,在服务端对 m3u8 内容进行去广告处理后再返回给客户端。
|
||||||
|
* 两套逻辑需要保持同步更新。
|
||||||
*/
|
*/
|
||||||
function filterAdsFromM3U8Default(type: string, m3u8Content: string): string {
|
function filterAdsFromM3U8Default(type: string, m3u8Content: string): string {
|
||||||
if (!m3u8Content) return '';
|
if (!m3u8Content) return '';
|
||||||
@@ -158,7 +251,10 @@ function filterAdsFromM3U8Default(type: string, m3u8Content: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 m3u8 中的相对链接转换为绝对链接,并将子 m3u8 链接转为代理链接
|
* 将 m3u8 中的相对链接转换为绝对链接,并将子 m3u8 链接转为代理链接。
|
||||||
|
* 此函数仅在代理模式下由服务端调用。
|
||||||
|
* - 子 m3u8 链接 → 指向 /api/proxy-m3u8(递归代理)
|
||||||
|
* - ts 分片/密钥 → directplay 模式指向 /api/proxy/vod/segment(解决 CORS)
|
||||||
*/
|
*/
|
||||||
function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string, proxyOrigin: string, token: string): string {
|
function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string, proxyOrigin: string, token: string): string {
|
||||||
const lines = m3u8Content.split('\n');
|
const lines = m3u8Content.split('\n');
|
||||||
@@ -187,10 +283,15 @@ function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string,
|
|||||||
} else {
|
} else {
|
||||||
keyUri = new URL(keyUri, baseDir).href;
|
keyUri = new URL(keyUri, baseDir).href;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 替换原来的 URI
|
|
||||||
line = line.replace(/URI="[^"]+"/, `URI="${keyUri}"`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 直链播放模式:通过代理访问密钥,避免 CORS 问题
|
||||||
|
if (source === 'directplay') {
|
||||||
|
keyUri = `${proxyOrigin}/api/proxy/vod/segment?url=${encodeURIComponent(keyUri)}&source=directplay`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换原来的 URI
|
||||||
|
line = line.replace(/URI="[^"]+"/, `URI="${keyUri}"`);
|
||||||
}
|
}
|
||||||
resolvedLines.push(line);
|
resolvedLines.push(line);
|
||||||
continue;
|
continue;
|
||||||
@@ -231,6 +332,9 @@ function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string,
|
|||||||
if (isM3u8) {
|
if (isM3u8) {
|
||||||
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '';
|
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '';
|
||||||
url = `${proxyOrigin}/api/proxy-m3u8?url=${encodeURIComponent(url)}${source ? `&source=${encodeURIComponent(source)}` : ''}${tokenParam}`;
|
url = `${proxyOrigin}/api/proxy-m3u8?url=${encodeURIComponent(url)}${source ? `&source=${encodeURIComponent(source)}` : ''}${tokenParam}`;
|
||||||
|
} else if (source === 'directplay') {
|
||||||
|
// 直链播放模式:通过代理访问媒体分片(ts/jpeg/png 等),避免 CORS 问题
|
||||||
|
url = `${proxyOrigin}/api/proxy/vod/segment?url=${encodeURIComponent(url)}&source=directplay`;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedLines.push(url);
|
resolvedLines.push(url);
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo
|
|||||||
const host = req.headers.get('host');
|
const host = req.headers.get('host');
|
||||||
const proxyBase = `${protocol}://${host}/api/proxy`;
|
const proxyBase = `${protocol}://${host}/api/proxy`;
|
||||||
|
|
||||||
|
// 获取 moontv-source 参数
|
||||||
|
const reqUrl = new URL(req.url);
|
||||||
|
const source = reqUrl.searchParams.get('moontv-source') || '';
|
||||||
|
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
const rewrittenLines: string[] = [];
|
const rewrittenLines: string[] = [];
|
||||||
|
|
||||||
@@ -119,19 +123,19 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo
|
|||||||
// 处理 TS 片段 URL 和其他媒体文件
|
// 处理 TS 片段 URL 和其他媒体文件
|
||||||
if (line && !line.startsWith('#')) {
|
if (line && !line.startsWith('#')) {
|
||||||
const resolvedUrl = resolveUrl(baseUrl, line);
|
const resolvedUrl = resolveUrl(baseUrl, line);
|
||||||
const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
|
const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`;
|
||||||
rewrittenLines.push(proxyUrl);
|
rewrittenLines.push(proxyUrl);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 EXT-X-MAP 标签中的 URI
|
// 处理 EXT-X-MAP 标签中的 URI
|
||||||
if (line.startsWith('#EXT-X-MAP:')) {
|
if (line.startsWith('#EXT-X-MAP:')) {
|
||||||
line = rewriteMapUri(line, baseUrl, proxyBase);
|
line = rewriteMapUri(line, baseUrl, proxyBase, allowCORS, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 EXT-X-KEY 标签中的 URI
|
// 处理 EXT-X-KEY 标签中的 URI
|
||||||
if (line.startsWith('#EXT-X-KEY:')) {
|
if (line.startsWith('#EXT-X-KEY:')) {
|
||||||
line = rewriteKeyUri(line, baseUrl, proxyBase);
|
line = rewriteKeyUri(line, baseUrl, proxyBase, allowCORS, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理嵌套的 M3U8 文件 (EXT-X-STREAM-INF)
|
// 处理嵌套的 M3U8 文件 (EXT-X-STREAM-INF)
|
||||||
@@ -143,7 +147,7 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo
|
|||||||
const nextLine = lines[i].trim();
|
const nextLine = lines[i].trim();
|
||||||
if (nextLine && !nextLine.startsWith('#')) {
|
if (nextLine && !nextLine.startsWith('#')) {
|
||||||
const resolvedUrl = resolveUrl(baseUrl, nextLine);
|
const resolvedUrl = resolveUrl(baseUrl, nextLine);
|
||||||
const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}`;
|
const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`;
|
||||||
rewrittenLines.push(proxyUrl);
|
rewrittenLines.push(proxyUrl);
|
||||||
} else {
|
} else {
|
||||||
rewrittenLines.push(nextLine);
|
rewrittenLines.push(nextLine);
|
||||||
@@ -158,23 +162,23 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo
|
|||||||
return rewrittenLines.join('\n');
|
return rewrittenLines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function rewriteMapUri(line: string, baseUrl: string, proxyBase: string) {
|
function rewriteMapUri(line: string, baseUrl: string, proxyBase: string, allowCORS: boolean, source: string) {
|
||||||
const uriMatch = line.match(/URI="([^"]+)"/);
|
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||||
if (uriMatch) {
|
if (uriMatch) {
|
||||||
const originalUri = uriMatch[1];
|
const originalUri = uriMatch[1];
|
||||||
const resolvedUrl = resolveUrl(baseUrl, originalUri);
|
const resolvedUrl = resolveUrl(baseUrl, originalUri);
|
||||||
const proxyUrl = `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
|
const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`;
|
||||||
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
|
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
|
||||||
}
|
}
|
||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string) {
|
function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string, allowCORS: boolean, source: string) {
|
||||||
const uriMatch = line.match(/URI="([^"]+)"/);
|
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||||
if (uriMatch) {
|
if (uriMatch) {
|
||||||
const originalUri = uriMatch[1];
|
const originalUri = uriMatch[1];
|
||||||
const resolvedUrl = resolveUrl(baseUrl, originalUri);
|
const resolvedUrl = resolveUrl(baseUrl, originalUri);
|
||||||
const proxyUrl = `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}`;
|
const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`;
|
||||||
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
|
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
|
||||||
}
|
}
|
||||||
return line;
|
return line;
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ export async function GET(request: Request) {
|
|||||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||||
headers.set('Accept-Ranges', 'bytes');
|
headers.set('Accept-Ranges', 'bytes');
|
||||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||||
const contentLength = response.headers.get('content-length');
|
|
||||||
if (contentLength) {
|
|
||||||
headers.set('Content-Length', contentLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用流式传输,避免占用内存
|
// 使用流式传输,避免占用内存
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { getConfig } from "@/lib/config";
|
import { getConfig } from "@/lib/config";
|
||||||
|
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||||
|
import { buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -33,6 +35,13 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const decodedUrl = decodeURIComponent(url);
|
const decodedUrl = decodeURIComponent(url);
|
||||||
|
|
||||||
|
// 安全校验:防 SSRF 拦截请求内网或非法 URL
|
||||||
|
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
|
||||||
|
if (!isSafeUrl) {
|
||||||
|
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(decodedUrl, {
|
const response = await fetch(decodedUrl, {
|
||||||
headers: {
|
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',
|
'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',
|
||||||
@@ -44,12 +53,9 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = buildProxyStreamHeaders(
|
||||||
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream');
|
response.headers.get('Content-Type') || 'application/octet-stream'
|
||||||
headers.set('Access-Control-Allow-Origin', '*');
|
);
|
||||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
||||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
|
||||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
|
||||||
|
|
||||||
return new Response(response.body, { headers });
|
return new Response(response.body, { headers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { NextResponse } from "next/server";
|
|||||||
|
|
||||||
import { getConfig } from "@/lib/config";
|
import { getConfig } from "@/lib/config";
|
||||||
import { getBaseUrl, resolveUrl } from "@/lib/live";
|
import { getBaseUrl, resolveUrl } from "@/lib/live";
|
||||||
|
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||||
|
import { buildProxyM3u8Headers, buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -38,6 +40,12 @@ export async function GET(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const decodedUrl = decodeURIComponent(url);
|
const decodedUrl = decodeURIComponent(url);
|
||||||
|
|
||||||
|
// 安全校验:防 SSRF 拦截请求内网或非法 URL
|
||||||
|
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
|
||||||
|
if (!isSafeUrl) {
|
||||||
|
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
response = await fetch(decodedUrl, {
|
response = await fetch(decodedUrl, {
|
||||||
cache: 'no-cache',
|
cache: 'no-cache',
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
@@ -66,23 +74,14 @@ export async function GET(request: Request) {
|
|||||||
// 重写 M3U8 内容
|
// 重写 M3U8 内容
|
||||||
const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, source);
|
const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, source);
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = buildProxyM3u8Headers(contentType || undefined);
|
||||||
headers.set('Content-Type', contentType || 'application/vnd.apple.mpegurl');
|
|
||||||
headers.set('Access-Control-Allow-Origin', '*');
|
|
||||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
||||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
|
||||||
headers.set('Cache-Control', 'no-cache');
|
|
||||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
|
||||||
return new Response(modifiedContent, { headers });
|
return new Response(modifiedContent, { headers });
|
||||||
}
|
}
|
||||||
// just proxy
|
// just proxy
|
||||||
const headers = new Headers();
|
const headers = buildProxyStreamHeaders(
|
||||||
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl');
|
response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl'
|
||||||
headers.set('Access-Control-Allow-Origin', '*');
|
);
|
||||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
||||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
|
||||||
headers.set('Cache-Control', 'no-cache');
|
headers.set('Cache-Control', 'no-cache');
|
||||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
|
||||||
|
|
||||||
// 直接返回视频流
|
// 直接返回视频流
|
||||||
return new Response(response.body, {
|
return new Response(response.body, {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getConfig } from "@/lib/config";
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||||
|
import { buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -19,16 +21,22 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing source' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing source' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查该视频源是否启用了代理模式
|
// 定义直链播放模式常量
|
||||||
const config = await getConfig();
|
const DIRECT_PLAY_SOURCE = 'directplay';
|
||||||
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
|
|
||||||
|
|
||||||
if (!videoSource) {
|
// 直链播放模式:跳过源站配置检查,直接代理
|
||||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
if (source !== DIRECT_PLAY_SOURCE) {
|
||||||
}
|
// 检查该视频源是否启用了代理模式
|
||||||
|
const config = await getConfig();
|
||||||
|
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
|
||||||
|
|
||||||
if (!videoSource.proxyMode) {
|
if (!videoSource) {
|
||||||
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
|
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoSource.proxyMode) {
|
||||||
|
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let response: Response | null = null;
|
let response: Response | null = null;
|
||||||
@@ -36,6 +44,13 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const decodedUrl = decodeURIComponent(url);
|
const decodedUrl = decodeURIComponent(url);
|
||||||
|
|
||||||
|
// 安全校验:防 SSRF 拦截请求内网或非法 URL (强制检查所有代理请求)
|
||||||
|
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
|
||||||
|
if (!isSafeUrl) {
|
||||||
|
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
response = await fetch(decodedUrl, {
|
response = await fetch(decodedUrl, {
|
||||||
headers: {
|
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',
|
'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',
|
||||||
@@ -46,19 +61,14 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = buildProxyStreamHeaders(
|
||||||
headers.set('Content-Type', response.headers.get('Content-Type') || 'video/mp2t');
|
response.headers.get('Content-Type') || 'video/mp2t',
|
||||||
headers.set('Access-Control-Allow-Origin', '*');
|
response.headers.get('content-length')
|
||||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
);
|
||||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
|
||||||
headers.set('Accept-Ranges', 'bytes');
|
|
||||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
|
||||||
const contentLength = response.headers.get('content-length');
|
|
||||||
if (contentLength) {
|
|
||||||
headers.set('Content-Length', contentLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用流式传输,避免占用内存
|
// 使用流式传输,避免占用内存
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
if (!response?.body) {
|
if (!response?.body) {
|
||||||
@@ -67,7 +77,6 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reader = response.body.getReader();
|
reader = response.body.getReader();
|
||||||
const isCancelled = false;
|
|
||||||
|
|
||||||
function pump() {
|
function pump() {
|
||||||
if (isCancelled || !reader) {
|
if (isCancelled || !reader) {
|
||||||
@@ -109,6 +118,7 @@ export async function GET(request: Request) {
|
|||||||
pump();
|
pump();
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
|
isCancelled = true;
|
||||||
// 当流被取消时,确保释放所有资源
|
// 当流被取消时,确保释放所有资源
|
||||||
if (reader) {
|
if (reader) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
|
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
|
||||||
import { searchFromApi } from '@/lib/downstream';
|
import { searchFromApi } from '@/lib/downstream';
|
||||||
|
import {
|
||||||
|
executeSavedSourceScript,
|
||||||
|
listEnabledSourceScripts,
|
||||||
|
normalizeScriptSearchResults,
|
||||||
|
normalizeScriptSources,
|
||||||
|
} from '@/lib/source-script';
|
||||||
import { yellowWords } from '@/lib/yellow';
|
import { yellowWords } from '@/lib/yellow';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -37,6 +43,69 @@ export async function GET(request: NextRequest) {
|
|||||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const enabledScripts = await listEnabledSourceScripts();
|
||||||
|
const matchedScript = enabledScripts.find((item) => item.key === resourceId);
|
||||||
|
if (matchedScript) {
|
||||||
|
const sourcesExecution = await executeSavedSourceScript({
|
||||||
|
key: matchedScript.key,
|
||||||
|
hook: 'getSources',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
const sources = normalizeScriptSources(sourcesExecution.result);
|
||||||
|
const scriptResults = await Promise.all(
|
||||||
|
sources.map(async (source) => {
|
||||||
|
const execution = await executeSavedSourceScript({
|
||||||
|
key: matchedScript.key,
|
||||||
|
hook: 'search',
|
||||||
|
payload: {
|
||||||
|
keyword: query,
|
||||||
|
page: 1,
|
||||||
|
sourceId: source.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeScriptSearchResults({
|
||||||
|
scriptKey: matchedScript.key,
|
||||||
|
scriptName: matchedScript.name,
|
||||||
|
sourceId: source.id,
|
||||||
|
sourceName: source.name,
|
||||||
|
result: execution.result,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = scriptResults.flat().filter((r) => r.title === query);
|
||||||
|
if (!config.SiteConfig.DisableYellowFilter) {
|
||||||
|
result = result.filter((item) => {
|
||||||
|
const typeName = item.type_name || '';
|
||||||
|
return !yellowWords.some((word: string) => typeName.includes(word));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheTime = await getCacheTime();
|
||||||
|
if (result.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: '未找到结果',
|
||||||
|
result: null,
|
||||||
|
},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ results: result },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||||
|
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||||
|
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||||
|
'Netlify-Vary': 'query',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 根据 resourceId 查找对应的 API 站点
|
// 根据 resourceId 查找对应的 API 站点
|
||||||
const targetSite = apiSites.find((site) => site.key === resourceId);
|
const targetSite = apiSites.find((site) => site.key === resourceId);
|
||||||
if (!targetSite) {
|
if (!targetSite) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAvailableApiSites } from '@/lib/config';
|
import { getAvailableApiSites } from '@/lib/config';
|
||||||
|
import { listEnabledSourceScripts } from '@/lib/source-script';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -11,8 +12,13 @@ export async function GET(request: NextRequest) {
|
|||||||
console.log('request', request.url);
|
console.log('request', request.url);
|
||||||
try {
|
try {
|
||||||
const apiSites = await getAvailableApiSites();
|
const apiSites = await getAvailableApiSites();
|
||||||
|
const scriptSites = (await listEnabledSourceScripts()).map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
name: item.name,
|
||||||
|
script: true,
|
||||||
|
}));
|
||||||
|
|
||||||
return NextResponse.json(apiSites);
|
return NextResponse.json([...apiSites, ...scriptSites]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: '获取资源失败' }, { status: 500 });
|
return NextResponse.json({ error: '获取资源失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
|
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
|
||||||
import { searchFromApi } from '@/lib/downstream';
|
import { searchFromApi } from '@/lib/downstream';
|
||||||
|
import { getProxyToken } from '@/lib/emby-token';
|
||||||
|
import {
|
||||||
|
executeSavedSourceScript,
|
||||||
|
listEnabledSourceScripts,
|
||||||
|
normalizeScriptSearchResults,
|
||||||
|
normalizeScriptSources,
|
||||||
|
} from '@/lib/source-script';
|
||||||
import { yellowWords } from '@/lib/yellow';
|
import { yellowWords } from '@/lib/yellow';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -36,6 +43,12 @@ export async function GET(request: NextRequest) {
|
|||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||||
|
|
||||||
|
// 创建权重映射表
|
||||||
|
const weightMap = new Map<string, number>();
|
||||||
|
config.SourceConfig.forEach(source => {
|
||||||
|
weightMap.set(source.key, source.weight ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
// 检查是否配置了 OpenList
|
// 检查是否配置了 OpenList
|
||||||
const hasOpenList = !!(
|
const hasOpenList = !!(
|
||||||
config.OpenListConfig?.Enabled &&
|
config.OpenListConfig?.Enabled &&
|
||||||
@@ -52,6 +65,9 @@ export async function GET(request: NextRequest) {
|
|||||||
console.log('[Search] Emby sources count:', embySources.length);
|
console.log('[Search] Emby sources count:', embySources.length);
|
||||||
console.log('[Search] Emby sources:', embySources.map(s => ({ key: s.config.key, name: s.config.name })));
|
console.log('[Search] Emby sources:', embySources.map(s => ({ key: s.config.key, name: s.config.name })));
|
||||||
|
|
||||||
|
// 获取代理 token(用于图片代理)
|
||||||
|
const proxyToken = await getProxyToken(request);
|
||||||
|
|
||||||
// 为每个 Emby 源创建搜索 Promise(全部并发,无限制)
|
// 为每个 Emby 源创建搜索 Promise(全部并发,无限制)
|
||||||
const embyPromises = embySources.map(({ client, config: embyConfig }) =>
|
const embyPromises = embySources.map(({ client, config: embyConfig }) =>
|
||||||
Promise.race([
|
Promise.race([
|
||||||
@@ -73,8 +89,9 @@ export async function GET(request: NextRequest) {
|
|||||||
id: item.Id,
|
id: item.Id,
|
||||||
source: sourceValue,
|
source: sourceValue,
|
||||||
source_name: sourceName,
|
source_name: sourceName,
|
||||||
|
weight: weightMap.get(sourceValue) ?? 0,
|
||||||
title: item.Name,
|
title: item.Name,
|
||||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
poster: client.getImageUrl(item.Id, 'Primary', undefined, client.isProxyEnabled() ? proxyToken || undefined : undefined),
|
||||||
episodes: [],
|
episodes: [],
|
||||||
episodes_titles: [],
|
episodes_titles: [],
|
||||||
year: item.ProductionYear?.toString() || '',
|
year: item.ProductionYear?.toString() || '',
|
||||||
@@ -128,6 +145,7 @@ export async function GET(request: NextRequest) {
|
|||||||
id: folderName,
|
id: folderName,
|
||||||
source: 'openlist',
|
source: 'openlist',
|
||||||
source_name: '私人影库',
|
source_name: '私人影库',
|
||||||
|
weight: weightMap.get('openlist') ?? 0,
|
||||||
title: info.title,
|
title: info.title,
|
||||||
poster: getTMDBImageUrl(info.poster_path),
|
poster: getTMDBImageUrl(info.poster_path),
|
||||||
episodes: [],
|
episodes: [],
|
||||||
@@ -166,24 +184,81 @@ export async function GET(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const scriptSummaries = await listEnabledSourceScripts();
|
||||||
|
const scriptPromises = scriptSummaries.map((script) =>
|
||||||
|
Promise.race([
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const sourcesExecution = await executeSavedSourceScript({
|
||||||
|
key: script.key,
|
||||||
|
hook: 'getSources',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
const sources = normalizeScriptSources(sourcesExecution.result);
|
||||||
|
|
||||||
|
const searchResults = await Promise.all(
|
||||||
|
sources.map(async (source) => {
|
||||||
|
const execution = await executeSavedSourceScript({
|
||||||
|
key: script.key,
|
||||||
|
hook: 'search',
|
||||||
|
payload: {
|
||||||
|
keyword: query,
|
||||||
|
page: 1,
|
||||||
|
sourceId: source.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeScriptSearchResults({
|
||||||
|
scriptKey: script.key,
|
||||||
|
scriptName: script.name,
|
||||||
|
sourceId: source.id,
|
||||||
|
sourceName: source.name,
|
||||||
|
result: execution.result,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return searchResults.flat();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Search] 搜索脚本 ${script.name} 失败:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
new Promise<any[]>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`${script.name} timeout`)), 20000)
|
||||||
|
),
|
||||||
|
]).catch((error) => {
|
||||||
|
console.error(`[Search] 搜索脚本 ${script.name} 超时:`, error);
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allResults = await Promise.all([
|
const allResults = await Promise.all([
|
||||||
openlistPromise,
|
openlistPromise,
|
||||||
...embyPromises,
|
...embyPromises,
|
||||||
...searchPromises,
|
...searchPromises,
|
||||||
|
...scriptPromises,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 分离结果:第一个是 openlist,接下来是 emby 结果,最后是 api 结果
|
// 分离结果:第一个是 openlist,接下来是 emby 结果,最后是 api 结果
|
||||||
// 添加安全检查,确保即使某个结果处理出错也不影响其他结果
|
// 添加安全检查,确保即使某个结果处理出错也不影响其他结果
|
||||||
const openlistResults = Array.isArray(allResults[0]) ? allResults[0] : [];
|
const openlistResults = Array.isArray(allResults[0]) ? allResults[0] : [];
|
||||||
const embyResultsArray = allResults.slice(1, 1 + embyPromises.length);
|
const embyResultsArray = allResults.slice(1, 1 + embyPromises.length);
|
||||||
const apiResults = allResults.slice(1 + embyPromises.length);
|
const apiResults = allResults.slice(1 + embyPromises.length, 1 + embyPromises.length + searchPromises.length);
|
||||||
|
const scriptResults = allResults.slice(1 + embyPromises.length + searchPromises.length);
|
||||||
|
|
||||||
// 合并所有 Emby 结果,添加安全检查
|
// 合并所有 Emby 结果,添加安全检查
|
||||||
const embyResults = embyResultsArray.filter(Array.isArray).flat();
|
const embyResults = embyResultsArray.filter(Array.isArray).flat();
|
||||||
const apiResultsFlat = apiResults.filter(Array.isArray).flat();
|
const apiResultsFlat = apiResults.filter(Array.isArray).flat();
|
||||||
|
const scriptResultsFlat = scriptResults.filter(Array.isArray).flat();
|
||||||
|
|
||||||
let flattenedResults = [...openlistResults, ...embyResults, ...apiResultsFlat];
|
let flattenedResults = [...openlistResults, ...embyResults, ...apiResultsFlat, ...scriptResultsFlat];
|
||||||
|
|
||||||
|
flattenedResults = flattenedResults.map((result) => ({
|
||||||
|
...result,
|
||||||
|
weight: result.weight ?? (weightMap.get(result.source) ?? 0),
|
||||||
|
}));
|
||||||
|
|
||||||
if (!config.SiteConfig.DisableYellowFilter) {
|
if (!config.SiteConfig.DisableYellowFilter) {
|
||||||
flattenedResults = flattenedResults.filter((result) => {
|
flattenedResults = flattenedResults.filter((result) => {
|
||||||
@@ -191,6 +266,14 @@ export async function GET(request: NextRequest) {
|
|||||||
return !yellowWords.some((word: string) => typeName.includes(word));
|
return !yellowWords.some((word: string) => typeName.includes(word));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 按权重降序排序
|
||||||
|
flattenedResults.sort((a, b) => {
|
||||||
|
const weightA = a.weight ?? 0;
|
||||||
|
const weightB = b.weight ?? 0;
|
||||||
|
return weightB - weightA;
|
||||||
|
});
|
||||||
|
|
||||||
const cacheTime = await getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
|
|
||||||
if (flattenedResults.length === 0) {
|
if (flattenedResults.length === 0) {
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { getAuthInfoFromCookie } from '@/lib/auth';
|
|||||||
import { getAvailableApiSites, getConfig } from '@/lib/config';
|
import { getAvailableApiSites, getConfig } from '@/lib/config';
|
||||||
import { searchFromApi } from '@/lib/downstream';
|
import { searchFromApi } from '@/lib/downstream';
|
||||||
import { yellowWords } from '@/lib/yellow';
|
import { yellowWords } from '@/lib/yellow';
|
||||||
|
import { getProxyToken } from '@/lib/emby-token';
|
||||||
|
import {
|
||||||
|
executeSavedSourceScript,
|
||||||
|
listEnabledSourceScripts,
|
||||||
|
normalizeScriptSearchResults,
|
||||||
|
normalizeScriptSources,
|
||||||
|
} from '@/lib/source-script';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -33,6 +40,19 @@ export async function GET(request: NextRequest) {
|
|||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||||
|
|
||||||
|
// 创建权重映射表
|
||||||
|
const weightMap = new Map<string, number>();
|
||||||
|
config.SourceConfig.forEach(source => {
|
||||||
|
weightMap.set(source.key, source.weight ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按权重降序排序 apiSites
|
||||||
|
const sortedApiSites = [...apiSites].sort((a, b) => {
|
||||||
|
const weightA = weightMap.get(a.key) ?? 0;
|
||||||
|
const weightB = weightMap.get(b.key) ?? 0;
|
||||||
|
return weightB - weightA;
|
||||||
|
});
|
||||||
|
|
||||||
// 检查是否配置了 OpenList
|
// 检查是否配置了 OpenList
|
||||||
const hasOpenList = !!(
|
const hasOpenList = !!(
|
||||||
config.OpenListConfig?.Enabled &&
|
config.OpenListConfig?.Enabled &&
|
||||||
@@ -47,6 +67,7 @@ export async function GET(request: NextRequest) {
|
|||||||
config.EmbyConfig.Sources.length > 0 &&
|
config.EmbyConfig.Sources.length > 0 &&
|
||||||
config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL)
|
config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL)
|
||||||
);
|
);
|
||||||
|
const enabledScripts = await listEnabledSourceScripts();
|
||||||
|
|
||||||
// 共享状态
|
// 共享状态
|
||||||
let streamClosed = false;
|
let streamClosed = false;
|
||||||
@@ -89,7 +110,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const startEvent = `data: ${JSON.stringify({
|
const startEvent = `data: ${JSON.stringify({
|
||||||
type: 'start',
|
type: 'start',
|
||||||
query,
|
query,
|
||||||
totalSources: apiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount,
|
totalSources: sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount + enabledScripts.length,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})}\n\n`;
|
})}\n\n`;
|
||||||
|
|
||||||
@@ -110,6 +131,9 @@ export async function GET(request: NextRequest) {
|
|||||||
const embySourcesMap = await embyManager.getAllClients();
|
const embySourcesMap = await embyManager.getAllClients();
|
||||||
const embySources = Array.from(embySourcesMap.values());
|
const embySources = Array.from(embySourcesMap.values());
|
||||||
|
|
||||||
|
// 获取代理 token(用于图片代理)
|
||||||
|
const proxyToken = await getProxyToken(request);
|
||||||
|
|
||||||
// 为每个 Emby 源并发搜索,并单独发送结果
|
// 为每个 Emby 源并发搜索,并单独发送结果
|
||||||
const embySearchPromises = embySources.map(async ({ client, config: embyConfig }) => {
|
const embySearchPromises = embySources.map(async ({ client, config: embyConfig }) => {
|
||||||
try {
|
try {
|
||||||
@@ -130,8 +154,9 @@ export async function GET(request: NextRequest) {
|
|||||||
id: item.Id,
|
id: item.Id,
|
||||||
source: sourceValue,
|
source: sourceValue,
|
||||||
source_name: sourceName,
|
source_name: sourceName,
|
||||||
|
weight: weightMap.get(sourceValue) ?? 0,
|
||||||
title: item.Name,
|
title: item.Name,
|
||||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
poster: client.getImageUrl(item.Id, 'Primary', undefined, client.isProxyEnabled() ? proxyToken || undefined : undefined),
|
||||||
episodes: [],
|
episodes: [],
|
||||||
episodes_titles: [],
|
episodes_titles: [],
|
||||||
year: item.ProductionYear?.toString() || '',
|
year: item.ProductionYear?.toString() || '',
|
||||||
@@ -236,6 +261,7 @@ export async function GET(request: NextRequest) {
|
|||||||
id: key,
|
id: key,
|
||||||
source: 'openlist',
|
source: 'openlist',
|
||||||
source_name: '私人影库',
|
source_name: '私人影库',
|
||||||
|
weight: weightMap.get('openlist') ?? 0,
|
||||||
title: info.title,
|
title: info.title,
|
||||||
poster: getTMDBImageUrl(info.poster_path),
|
poster: getTMDBImageUrl(info.poster_path),
|
||||||
episodes: [],
|
episodes: [],
|
||||||
@@ -294,7 +320,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 为每个源创建搜索 Promise
|
// 为每个源创建搜索 Promise
|
||||||
const searchPromises = apiSites.map(async (site) => {
|
const searchPromises = sortedApiSites.map(async (site) => {
|
||||||
try {
|
try {
|
||||||
// 添加超时控制
|
// 添加超时控制
|
||||||
const searchPromise = Promise.race([
|
const searchPromise = Promise.race([
|
||||||
@@ -318,6 +344,11 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filteredResults = filteredResults.map((result) => ({
|
||||||
|
...result,
|
||||||
|
weight: result.weight ?? (weightMap.get(result.source) ?? 0),
|
||||||
|
}));
|
||||||
|
|
||||||
// 发送该源的搜索结果
|
// 发送该源的搜索结果
|
||||||
completedSources++;
|
completedSources++;
|
||||||
|
|
||||||
@@ -363,7 +394,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否所有源都已完成
|
// 检查是否所有源都已完成
|
||||||
if (completedSources === apiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount) {
|
if (completedSources === sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount + enabledScripts.length) {
|
||||||
if (!streamClosed) {
|
if (!streamClosed) {
|
||||||
// 发送最终完成事件
|
// 发送最终完成事件
|
||||||
const completeEvent = `data: ${JSON.stringify({
|
const completeEvent = `data: ${JSON.stringify({
|
||||||
@@ -385,8 +416,118 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const scriptPromises = enabledScripts.map(async (script) => {
|
||||||
|
try {
|
||||||
|
const sourcesExecution = await Promise.race([
|
||||||
|
executeSavedSourceScript({
|
||||||
|
key: script.key,
|
||||||
|
hook: 'getSources',
|
||||||
|
payload: {},
|
||||||
|
}),
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`${script.name} timeout`)), 20000)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sources = normalizeScriptSources((sourcesExecution as any).result);
|
||||||
|
const sourceResults = await Promise.all(
|
||||||
|
sources.map(async (source) => {
|
||||||
|
const execution = await Promise.race([
|
||||||
|
executeSavedSourceScript({
|
||||||
|
key: script.key,
|
||||||
|
hook: 'search',
|
||||||
|
payload: {
|
||||||
|
keyword: query,
|
||||||
|
page: 1,
|
||||||
|
sourceId: source.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`${script.name}/${source.name} timeout`)), 20000)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return normalizeScriptSearchResults({
|
||||||
|
scriptKey: script.key,
|
||||||
|
scriptName: script.name,
|
||||||
|
sourceId: source.id,
|
||||||
|
sourceName: source.name,
|
||||||
|
result: (execution as any).result,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let filteredResults = sourceResults.flat();
|
||||||
|
if (!config.SiteConfig.DisableYellowFilter) {
|
||||||
|
filteredResults = filteredResults.filter((result) => {
|
||||||
|
const typeName = result.type_name || '';
|
||||||
|
return !yellowWords.some((word: string) => typeName.includes(word));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
completedSources++;
|
||||||
|
|
||||||
|
if (!streamClosed) {
|
||||||
|
const sourceEvent = `data: ${JSON.stringify({
|
||||||
|
type: 'source_result',
|
||||||
|
source: `script:${script.key}`,
|
||||||
|
sourceName: script.name,
|
||||||
|
results: filteredResults,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})}\n\n`;
|
||||||
|
|
||||||
|
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
||||||
|
streamClosed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredResults.length > 0) {
|
||||||
|
allResults.push(...filteredResults);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`搜索脚本失败 ${script.name}:`, error);
|
||||||
|
|
||||||
|
completedSources++;
|
||||||
|
|
||||||
|
if (!streamClosed) {
|
||||||
|
const errorEvent = `data: ${JSON.stringify({
|
||||||
|
type: 'source_error',
|
||||||
|
source: `script:${script.key}`,
|
||||||
|
sourceName: script.name,
|
||||||
|
error: error instanceof Error ? error.message : '搜索失败',
|
||||||
|
timestamp: Date.now()
|
||||||
|
})}\n\n`;
|
||||||
|
|
||||||
|
if (!safeEnqueue(encoder.encode(errorEvent))) {
|
||||||
|
streamClosed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completedSources === sortedApiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount + enabledScripts.length) {
|
||||||
|
if (!streamClosed) {
|
||||||
|
const completeEvent = `data: ${JSON.stringify({
|
||||||
|
type: 'complete',
|
||||||
|
totalResults: allResults.length,
|
||||||
|
completedSources,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})}\n\n`;
|
||||||
|
|
||||||
|
if (safeEnqueue(encoder.encode(completeEvent))) {
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to close controller:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 等待所有搜索完成
|
// 等待所有搜索完成
|
||||||
await Promise.allSettled(searchPromises);
|
await Promise.allSettled([...searchPromises, ...scriptPromises]);
|
||||||
},
|
},
|
||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
|
|||||||