Compare commits
450 Commits
| 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 | ||
|
|
d778c988bd | ||
|
|
e754feeb68 | ||
|
|
f1d3f805ec | ||
|
|
ada7d12fa3 | ||
|
|
7c62e1612c | ||
|
|
6494c80159 | ||
|
|
1a7005a8f9 | ||
|
|
8a6866e0b5 | ||
|
|
943efe9269 | ||
|
|
74f0533727 | ||
|
|
58f3bf99dc | ||
|
|
935c2b23f3 | ||
|
|
82bb6ee537 | ||
|
|
88f99cd4ac | ||
|
|
554bc49a51 | ||
|
|
258c95f6a2 | ||
|
|
64bedb04b6 | ||
|
|
d8dbd10389 | ||
|
|
8b0d999619 | ||
|
|
f5c36330d9 | ||
|
|
69ad303045 | ||
|
|
1c763bee39 | ||
|
|
425c4eae67 | ||
|
|
9b42fb0c2e | ||
|
|
2a336ac15b | ||
|
|
292dd4701e | ||
|
|
f4a4768c16 | ||
|
|
73232fd172 | ||
|
|
f96f9ebbac | ||
|
|
24a411c104 | ||
|
|
b3764a7938 | ||
|
|
9b335da4ce | ||
|
|
5ef590fe5b | ||
|
|
e6db59a16d | ||
|
|
29781d9d5a | ||
|
|
267258f66c | ||
|
|
aa10ebd638 | ||
|
|
e0390cc0bc | ||
|
|
49484d0121 | ||
|
|
1bd253ce15 | ||
|
|
fadec0afe4 | ||
|
|
feeb81ed1f | ||
|
|
2b7c5fa517 | ||
|
|
7184883b81 | ||
|
|
bd4447f42d | ||
|
|
e0b3ecd580 | ||
|
|
79b0201c64 | ||
|
|
8d971650c5 | ||
|
|
6eaed34b14 | ||
|
|
a167763324 | ||
|
|
ed23e1da6f | ||
|
|
ea0ebcd8ce | ||
|
|
05ea826f54 | ||
|
|
e90fb5034b | ||
|
|
8472685b81 | ||
|
|
06efd8121f | ||
|
|
a41f2fd1d6 | ||
|
|
a01519a3fb | ||
|
|
729c4fcf4d | ||
|
|
4737693865 | ||
|
|
bcdd2464ca | ||
|
|
c8558307ee | ||
|
|
3755307117 | ||
|
|
87cad0e3b6 | ||
|
|
f1d4293978 | ||
|
|
53e75e4d96 | ||
|
|
d2bd6d20b6 | ||
|
|
7a8c0b2d29 | ||
|
|
03a8ead69f | ||
|
|
5c08304b6d | ||
|
|
77ee98aee4 | ||
|
|
0359979477 | ||
|
|
c354ec8168 | ||
|
|
30a89d878d | ||
|
|
7ef9737cb6 | ||
|
|
2d8b410b33 | ||
|
|
6cdef3f7e2 | ||
|
|
037a7e7249 | ||
|
|
f31a9b0430 | ||
|
|
e2a94fee2d | ||
|
|
16e56dbb33 | ||
|
|
1b566550af | ||
|
|
78f7c68ef5 | ||
|
|
5e9ecc20f0 | ||
|
|
dc7b3602b5 | ||
|
|
23c3d3a81f | ||
|
|
c8ed4ca830 | ||
|
|
be20bc6dc9 | ||
|
|
64f65d11c4 | ||
|
|
23430c5f65 | ||
|
|
cdb1c603b6 | ||
|
|
42dfa43289 | ||
|
|
b70a7bebc9 | ||
|
|
6a976dd91c | ||
|
|
531187b5a4 | ||
|
|
837e862d75 | ||
|
|
be8373de40 | ||
|
|
9b7eb719a2 | ||
|
|
12d48c83cb | ||
|
|
dad414045d | ||
|
|
2587479970 | ||
|
|
ce6d02c023 | ||
|
|
7d05246577 | ||
|
|
c8ec2f14b6 | ||
|
|
d7daab795c | ||
|
|
d0e50317a5 | ||
|
|
40290c6036 | ||
|
|
b3b6969d85 | ||
|
|
b6d2f05de5 | ||
|
|
226e041dd8 | ||
|
|
fb83bb5a05 | ||
|
|
6bce099a8d | ||
|
|
036e909f74 | ||
|
|
e392e9f543 | ||
|
|
0655bbc875 | ||
|
|
2850b6bef8 | ||
|
|
d74112e8c7 | ||
|
|
ecb1416796 | ||
|
|
c28dacd0f6 | ||
|
|
b0f10c34ad | ||
|
|
3018f2ff66 | ||
|
|
2dd2c007a9 | ||
|
|
6d7a3b551b | ||
|
|
ed77c873e0 | ||
|
|
59e3685e65 | ||
|
|
8f72498cbb | ||
|
|
28528ad2cb | ||
|
|
93688bb8da | ||
|
|
89be4aab87 | ||
|
|
02ba79f3d8 | ||
|
|
4acec23b93 | ||
|
|
ab666bd0f6 | ||
|
|
7f42929ee4 | ||
|
|
033eae152b | ||
|
|
3f610124e7 | ||
|
|
fd9d911583 | ||
|
|
391d805a3b | ||
|
|
2c805e2522 | ||
|
|
3807f4cf60 | ||
|
|
f1ced03517 | ||
|
|
83b3315fba | ||
|
|
1053f2968e | ||
|
|
ffd29e009a | ||
|
|
7655eee23d | ||
|
|
b101b80481 | ||
|
|
f5a61cf9d5 | ||
|
|
94a2caa984 | ||
|
|
e54ec1a902 | ||
|
|
30278b3ac1 | ||
|
|
b6026199e3 | ||
|
|
af423c22e1 | ||
|
|
8c2db37d45 | ||
|
|
8b74ddc73f | ||
|
|
6a9a208f8c | ||
|
|
8a5114ef53 | ||
|
|
5504bf6e2f | ||
|
|
e04e1ce3bc | ||
|
|
03a59b07a9 | ||
|
|
88c9c6b55e | ||
|
|
a74911d655 | ||
|
|
00a5055817 | ||
|
|
afdf0b7af5 | ||
|
|
f1707f8fa4 | ||
|
|
a41e69c387 | ||
|
|
fc28992123 | ||
|
|
1e3df4f88e | ||
|
|
1c8a4f7bd2 | ||
|
|
9b7afbbcc1 | ||
|
|
ca66ee92ea | ||
|
|
769795daaa | ||
|
|
a2e165d645 | ||
|
|
3d30258f4e | ||
|
|
a371366a09 | ||
|
|
198272f2e0 | ||
|
|
7783a47a5c | ||
|
|
9e8e3a4cc5 | ||
|
|
4474c0c6f9 | ||
|
|
2736c9e845 | ||
|
|
b61db02478 | ||
|
|
71da826b69 | ||
|
|
110b14ba02 | ||
|
|
f5d9551e2b | ||
|
|
514b3768fe | ||
|
|
227d1370da | ||
|
|
b0e4d3c624 | ||
|
|
35dda705cd | ||
|
|
37cc53b166 | ||
|
|
2b22158f4b | ||
|
|
3e39004929 | ||
|
|
7a4a8262e1 | ||
|
|
f1a295cd75 | ||
|
|
9b3de638a1 | ||
|
|
53039c2ba9 | ||
|
|
e5cab79993 | ||
|
|
4484097f83 | ||
|
|
1f2dcaa08c | ||
|
|
7a1b71ea4c | ||
|
|
fdf14dbae0 | ||
|
|
bc49b60c0b | ||
|
|
6c7c69624d | ||
|
|
7536061ee5 | ||
|
|
8bcc17333c | ||
|
|
b6e7e02b87 | ||
|
|
4a857ead08 | ||
|
|
a078380a6d | ||
|
|
0d4d7c9e31 | ||
|
|
b51ffb4544 | ||
|
|
6c28f76128 | ||
|
|
ba8528c12e | ||
|
|
6d046aea0a | ||
|
|
0928756d8c | ||
|
|
a10bf361a8 | ||
|
|
2054e3122f | ||
|
|
98a8140028 | ||
|
|
2616b70365 | ||
|
|
97488c3bee | ||
|
|
17554ef1ea | ||
|
|
ea636b2307 | ||
|
|
d9dbf4ac83 | ||
|
|
601b5c31d2 | ||
|
|
66853d814a | ||
|
|
8b329efe8c | ||
|
|
2e025eabfb | ||
|
|
06cf30640b | ||
|
|
435d3a0f36 | ||
|
|
a1ef59454f | ||
|
|
a323429bad | ||
|
|
77dd738d97 | ||
|
|
1c2b1b3c4a | ||
|
|
0edd224b5d | ||
|
|
e5aa93f18d | ||
|
|
f2593a054e | ||
|
|
674f4b7aa2 | ||
|
|
70cb6f6589 | ||
|
|
97d748fa79 | ||
|
|
a698c6b493 | ||
|
|
42b11c3679 |
@@ -1,2 +1,5 @@
|
||||
.env
|
||||
.env*.local
|
||||
public/screenshot1.png
|
||||
public/screenshot2.png
|
||||
public/screenshot3.png
|
||||
|
||||
188
.github/workflows/cloudflare-deploy.yml
vendored
Normal file
@@ -0,0 +1,188 @@
|
||||
name: Deploy to Cloudflare
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
echo "BUILD_TARGET=cloudflare" >> $GITHUB_ENV
|
||||
echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
echo "NEXT_PUBLIC_STORAGE_TYPE=${{ secrets.NEXT_PUBLIC_STORAGE_TYPE }}" >> $GITHUB_ENV
|
||||
echo "NEXT_PUBLIC_SITE_NAME=${{ secrets.NEXT_PUBLIC_SITE_NAME }}" >> $GITHUB_ENV
|
||||
echo "NEXT_PUBLIC_SEARCH_MAX_PAGE=${{ secrets.NEXT_PUBLIC_SEARCH_MAX_PAGE }}" >> $GITHUB_ENV
|
||||
echo "NEXT_PUBLIC_DOUBAN_PROXY_TYPE=${{ secrets.NEXT_PUBLIC_DOUBAN_PROXY_TYPE }}" >> $GITHUB_ENV
|
||||
echo "NEXT_PUBLIC_DOUBAN_PROXY=${{ secrets.NEXT_PUBLIC_DOUBAN_PROXY }}" >> $GITHUB_ENV
|
||||
echo "NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE=${{ secrets.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE }}" >> $GITHUB_ENV
|
||||
echo "NEXT_PUBLIC_DOUBAN_IMAGE_PROXY=${{ secrets.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY }}" >> $GITHUB_ENV
|
||||
echo "NEXT_PUBLIC_DISABLE_YELLOW_FILTER=${{ secrets.NEXT_PUBLIC_DISABLE_YELLOW_FILTER }}" >> $GITHUB_ENV
|
||||
echo "NEXT_PUBLIC_FLUID_SEARCH=${{ secrets.NEXT_PUBLIC_FLUID_SEARCH }}" >> $GITHUB_ENV
|
||||
echo "NEXT_PUBLIC_PROXY_M3U8_TOKEN=${{ secrets.NEXT_PUBLIC_PROXY_M3U8_TOKEN }}" >> $GITHUB_ENV
|
||||
echo "NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES=${{ secrets.NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES }}" >> $GITHUB_ENV
|
||||
echo "NEXT_PUBLIC_VOICE_CHAT_STRATEGY=${{ secrets.NEXT_PUBLIC_VOICE_CHAT_STRATEGY }}" >> $GITHUB_ENV
|
||||
echo "NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD=${{ secrets.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD }}" >> $GITHUB_ENV
|
||||
echo "NEXT_PUBLIC_ENABLE_SOURCE_SEARCH=${{ secrets.NEXT_PUBLIC_ENABLE_SOURCE_SEARCH }}" >> $GITHUB_ENV
|
||||
echo "USERNAME=${{ secrets.USERNAME }}" >> $GITHUB_ENV
|
||||
echo "PASSWORD=${{ secrets.PASSWORD }}" >> $GITHUB_ENV
|
||||
echo "UPSTASH_URL=${{ secrets.UPSTASH_URL }}" >> $GITHUB_ENV
|
||||
echo "UPSTASH_TOKEN=${{ secrets.UPSTASH_TOKEN }}" >> $GITHUB_ENV
|
||||
echo "TMDB_API_KEY=${{ secrets.TMDB_API_KEY }}" >> $GITHUB_ENV
|
||||
echo "TMDB_PROXY=${{ secrets.TMDB_PROXY }}" >> $GITHUB_ENV
|
||||
echo "TMDB_REVERSE_PROXY=${{ secrets.TMDB_REVERSE_PROXY }}" >> $GITHUB_ENV
|
||||
echo "DANMAKU_API_BASE=${{ secrets.DANMAKU_API_BASE }}" >> $GITHUB_ENV
|
||||
echo "DANMAKU_API_TOKEN=${{ secrets.DANMAKU_API_TOKEN }}" >> $GITHUB_ENV
|
||||
echo "ANNOUNCEMENT=${{ secrets.ANNOUNCEMENT }}" >> $GITHUB_ENV
|
||||
echo "CRON_PASSWORD=${{ secrets.CRON_PASSWORD }}" >> $GITHUB_ENV
|
||||
echo "SITE_BASE=${{ secrets.SITE_BASE }}" >> $GITHUB_ENV
|
||||
echo "REDIS_URL=${{ secrets.REDIS_URL }}" >> $GITHUB_ENV
|
||||
echo "KVROCKS_URL=${{ secrets.KVROCKS_URL }}" >> $GITHUB_ENV
|
||||
echo "INIT_CONFIG=${{ secrets.INIT_CONFIG }}" >> $GITHUB_ENV
|
||||
echo "CONFIG_SUBSCRIPTION_URL=${{ secrets.CONFIG_SUBSCRIPTION_URL }}" >> $GITHUB_ENV
|
||||
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
|
||||
run: |
|
||||
# Function to append variable to wrangler.toml if it has a value
|
||||
append_var() {
|
||||
local var_name=$1
|
||||
local var_value=$2
|
||||
if [ -n "$var_value" ]; then
|
||||
echo "$var_name = \"$var_value\"" >> wrangler.toml
|
||||
fi
|
||||
}
|
||||
|
||||
# Append all environment variables to wrangler.toml
|
||||
append_var "NEXT_PUBLIC_STORAGE_TYPE" "${{ secrets.NEXT_PUBLIC_STORAGE_TYPE }}"
|
||||
append_var "NEXT_PUBLIC_SITE_NAME" "${{ secrets.NEXT_PUBLIC_SITE_NAME }}"
|
||||
append_var "NEXT_PUBLIC_SEARCH_MAX_PAGE" "${{ secrets.NEXT_PUBLIC_SEARCH_MAX_PAGE }}"
|
||||
append_var "NEXT_PUBLIC_DOUBAN_PROXY_TYPE" "${{ secrets.NEXT_PUBLIC_DOUBAN_PROXY_TYPE }}"
|
||||
append_var "NEXT_PUBLIC_DOUBAN_PROXY" "${{ secrets.NEXT_PUBLIC_DOUBAN_PROXY }}"
|
||||
append_var "NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE" "${{ secrets.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE }}"
|
||||
append_var "NEXT_PUBLIC_DOUBAN_IMAGE_PROXY" "${{ secrets.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY }}"
|
||||
append_var "NEXT_PUBLIC_DISABLE_YELLOW_FILTER" "${{ secrets.NEXT_PUBLIC_DISABLE_YELLOW_FILTER }}"
|
||||
append_var "NEXT_PUBLIC_FLUID_SEARCH" "${{ secrets.NEXT_PUBLIC_FLUID_SEARCH }}"
|
||||
append_var "NEXT_PUBLIC_PROXY_M3U8_TOKEN" "${{ secrets.NEXT_PUBLIC_PROXY_M3U8_TOKEN }}"
|
||||
append_var "NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES" "${{ secrets.NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES }}"
|
||||
append_var "NEXT_PUBLIC_VOICE_CHAT_STRATEGY" "${{ secrets.NEXT_PUBLIC_VOICE_CHAT_STRATEGY }}"
|
||||
append_var "NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD" "${{ secrets.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD }}"
|
||||
append_var "NEXT_PUBLIC_ENABLE_SOURCE_SEARCH" "${{ secrets.NEXT_PUBLIC_ENABLE_SOURCE_SEARCH }}"
|
||||
append_var "USERNAME" "${{ secrets.USERNAME }}"
|
||||
append_var "PASSWORD" "${{ secrets.PASSWORD }}"
|
||||
append_var "UPSTASH_URL" "${{ secrets.UPSTASH_URL }}"
|
||||
append_var "UPSTASH_TOKEN" "${{ secrets.UPSTASH_TOKEN }}"
|
||||
append_var "TMDB_API_KEY" "${{ secrets.TMDB_API_KEY }}"
|
||||
append_var "TMDB_PROXY" "${{ secrets.TMDB_PROXY }}"
|
||||
append_var "TMDB_REVERSE_PROXY" "${{ secrets.TMDB_REVERSE_PROXY }}"
|
||||
append_var "DANMAKU_API_BASE" "${{ secrets.DANMAKU_API_BASE }}"
|
||||
append_var "DANMAKU_API_TOKEN" "${{ secrets.DANMAKU_API_TOKEN }}"
|
||||
append_var "ANNOUNCEMENT" "${{ secrets.ANNOUNCEMENT }}"
|
||||
append_var "CRON_PASSWORD" "${{ secrets.CRON_PASSWORD }}"
|
||||
append_var "SITE_BASE" "${{ secrets.SITE_BASE }}"
|
||||
append_var "REDIS_URL" "${{ secrets.REDIS_URL }}"
|
||||
append_var "KVROCKS_URL" "${{ secrets.KVROCKS_URL }}"
|
||||
append_var "INIT_CONFIG" "${{ secrets.INIT_CONFIG }}"
|
||||
append_var "CONFIG_SUBSCRIPTION_URL" "${{ secrets.CONFIG_SUBSCRIPTION_URL }}"
|
||||
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:"
|
||||
cat wrangler.toml
|
||||
|
||||
- name: Build for Cloudflare
|
||||
run: pnpm run build:cloudflare
|
||||
|
||||
- name: Install Wrangler
|
||||
run: pnpm add -g wrangler@4.60.0
|
||||
|
||||
- name: Run D1 Database Migrations
|
||||
run: |
|
||||
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
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: deploy
|
||||
packageManager: pnpm
|
||||
wranglerVersion: "4.60.0"
|
||||
10
.github/workflows/docker-image-dev.yml
vendored
@@ -33,8 +33,6 @@ jobs:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
os: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
os: ubuntu-24.04-arm
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
@@ -52,10 +50,10 @@ jobs:
|
||||
if [[ "${{ github.ref_name }}" == "main" ]]; then
|
||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus" >> $GITHUB_ENV
|
||||
elif [[ "${{ github.ref_name }}" == "dev" ]]; then
|
||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev" >> $GITHUB_ENV
|
||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev2" >> $GITHUB_ENV
|
||||
else
|
||||
# 为其他分支或手动触发设置一个默认值(可以根据需要调整)
|
||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev" >> $GITHUB_ENV
|
||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev2" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -131,9 +129,9 @@ jobs:
|
||||
if [[ "${{ github.ref_name }}" == "main" ]]; then
|
||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus" >> $GITHUB_ENV
|
||||
elif [[ "${{ github.ref_name }}" == "dev" ]]; then
|
||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev" >> $GITHUB_ENV
|
||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev2" >> $GITHUB_ENV
|
||||
else
|
||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev" >> $GITHUB_ENV
|
||||
echo "IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/moontvplus-dev2" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# 新增步骤:在 merge 任务中也需要生成同样的标签列表
|
||||
|
||||
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
|
||||
10
.gitignore
vendored
@@ -33,6 +33,11 @@ yarn-error.log*
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# cloudflare
|
||||
.open-next/
|
||||
.wrangler/
|
||||
wrangler.toml.bak
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -53,3 +58,8 @@ public/workbox-*.js.map
|
||||
|
||||
/.claude
|
||||
/.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
|
||||
1
.node-version
Normal file
@@ -0,0 +1 @@
|
||||
24
|
||||
370
CHANGELOG
@@ -1,3 +1,373 @@
|
||||
## [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
|
||||
### Fixed
|
||||
- 修复跳转登录不登出
|
||||
|
||||
## [210.1.2] - 2026-01-27
|
||||
### Fixed
|
||||
- 修复刷新token失效无限重定向
|
||||
|
||||
## [210.1.1] - 2026-01-27
|
||||
### Fixed
|
||||
- 修复token过期重定向无法续期的问题
|
||||
|
||||
## [210.1.0] - 2026-01-27
|
||||
### Added
|
||||
- 增加直链播放
|
||||
- 直播兼容txt格式
|
||||
|
||||
### Changed
|
||||
- 首页轮播图和继续观看可隐藏
|
||||
- 刷新token改为前端进行防止redis数据库方式报错
|
||||
|
||||
## [210.0.0] - 2026-01-25
|
||||
### Added
|
||||
- 新增网络直播功能
|
||||
- 动漫磁力搜索增加蜜柑
|
||||
- 动漫磁力搜索增加动漫花园
|
||||
- pansou增加关键词过滤
|
||||
- cloudflare workers部署支持
|
||||
|
||||
### Changed
|
||||
- 去广告增强
|
||||
- 登录验证机制改为Token+Refresh Token形式
|
||||
- 移除旧版用户操作残留
|
||||
|
||||
### Fixed
|
||||
- 修复缓存弹幕限制后不显示原始数量
|
||||
- 修复测速缓存导致的错误测速数据
|
||||
- 修复videocard,2000年只显示0年
|
||||
|
||||
## [209.1.0] - 2026-01-21
|
||||
### Added
|
||||
- 增加禁用自动匹配弹幕
|
||||
- 增加弹幕上限设置
|
||||
- 增加禁用弹幕热力图开关
|
||||
|
||||
### Changed
|
||||
- 恢复无数据库支持
|
||||
- 反爬增强
|
||||
|
||||
### Fixed
|
||||
- 修复订阅去广告开关不正确修改链接问题
|
||||
|
||||
## [209.0.0] - 2026-01-18
|
||||
### Added
|
||||
- 新增收藏更新邮件发送
|
||||
- 新增测速超时设置
|
||||
- tmdb详情支持查看季度信息,集数信息
|
||||
- tmdb支持设置反代
|
||||
- tmdb图片支持自定义设置url
|
||||
- 豆瓣图片增加百度图片代理
|
||||
|
||||
### Changed
|
||||
- 统一upstash和redis数据层
|
||||
- 优化docker镜像大小
|
||||
- Cloudflare Turnstile开启前检测sitekey和Secretkey不能为空
|
||||
- 豆瓣反爬增强
|
||||
- 竖向videocard的source name调整到海报右下角
|
||||
- 取消小雅获取剧集强制刷新
|
||||
- 本地设置选项卡增加图标
|
||||
|
||||
### Fixed
|
||||
- 修复详情不应用豆瓣图片策略
|
||||
|
||||
## [208.0.0] - 2026-01-16
|
||||
### Added
|
||||
- ai问片增加非流式响应
|
||||
- 新增下集弹幕预加载
|
||||
- openlist和小雅增加禁用预览方式播放
|
||||
- 管理配置增加主动重载
|
||||
- emby增加排序功能
|
||||
- 播放页面标题自动跟随影片标题
|
||||
- emby数据增加导入导出
|
||||
|
||||
### Changed
|
||||
- 小雅现在会自动刷新播放链接
|
||||
- 缓冲策略从下拉框改为滑块
|
||||
- openlist扫描前增加tmdbkey配置检测
|
||||
- 优化firefox下的弹幕热力图
|
||||
- 小雅获取剧集时刷新文件夹
|
||||
|
||||
### Fixed
|
||||
- 修复redis方式数据导入报错
|
||||
- 修复小雅不过滤空url
|
||||
|
||||
|
||||
## [207.0.0] - 2026-01-13
|
||||
### Added
|
||||
- 私人影库增加小雅支持
|
||||
- 测速增加码率测算
|
||||
- 私人影库增加求片功能
|
||||
- 搜索增加简繁转换器
|
||||
- openlist多目录扫描支持
|
||||
|
||||
### Changed
|
||||
- 视频源保留关键字增加xiaoya,emby
|
||||
- 年代筛选的年代改为动态生成
|
||||
- openlist支持视频预览方式播放
|
||||
|
||||
### Fixed
|
||||
- 修复upstash方式数据导入报错
|
||||
- 修复新加载弹幕不显示弹幕数量
|
||||
- 修复生产环境emby分类返回空数组
|
||||
|
||||
## [206.3.0] - 2026-01-10
|
||||
### Added
|
||||
- emby支持配置多源
|
||||
- emby支持配置高级参数(转码mp4,代理等)
|
||||
- 新增自定义ai问片默认消息
|
||||
|
||||
### Changed
|
||||
- 优化搜索逻辑和聚合逻辑
|
||||
- 加载缓存弹幕时显示元信息
|
||||
- 服务器代理豆瓣图片反防盗链增强
|
||||
|
||||
### Fixed
|
||||
- 修复emby分类切换时加载旧数据
|
||||
|
||||
## [206.2.1] - 2026-01-08
|
||||
### Fixed
|
||||
- 修正豆瓣图片源
|
||||
|
||||
## [206.2.0] - 2026-01-07
|
||||
### Added
|
||||
- 轮播图数据源增加豆瓣
|
||||
- 弹幕开关状态持久化
|
||||
- play页面新增复制视频链接
|
||||
|
||||
### Changed
|
||||
- 搜索聚合规则增强
|
||||
- emby剧集弹幕匹配优化
|
||||
- 搜索来源筛选提升私人影库权重
|
||||
- 私人影库未配置openlist时自动跳转emby
|
||||
- 优化弹幕加载逻辑
|
||||
|
||||
### Fixed
|
||||
- 修复私人影库报错导致搜索无结果
|
||||
- 修复无法保存更多推荐数据源
|
||||
|
||||
## [206.1.0] - 2026-01-05
|
||||
### Added
|
||||
- 新增手动上传弹幕功能
|
||||
|
||||
### Changed
|
||||
- 修改页面进度条逻辑
|
||||
- openlist和emby源改为异步搜索
|
||||
- 提高移动端选集面板高度
|
||||
|
||||
### Fixed
|
||||
- 修复live页面500报错
|
||||
|
||||
|
||||
## [206.0.0] - 2026-01-04
|
||||
### Added
|
||||
- 私人影库增加emby支持
|
||||
- 清空增加确认框
|
||||
- 增加页面切换进度条
|
||||
- 增加首页模块配置
|
||||
|
||||
### Changed
|
||||
- 私人影库支持解析ova集数
|
||||
- 优化弹幕匹配逻辑
|
||||
- 修改本地设置面板可折叠
|
||||
- 关闭预告片后不再检测连通性,检测连通性时增加query保证无缓存检测
|
||||
|
||||
### Fixed
|
||||
- 修正视频源代理模式外部播放器链接
|
||||
- 修复删除配置文件源提示成功的问题
|
||||
|
||||
## [205.1.0] - 2026-01-02
|
||||
### Added
|
||||
- 播放器显示截屏按钮
|
||||
- 播放器在移动端增加快进快退控件
|
||||
- 私人影库增加扫描模式
|
||||
- 定时任务接口增加鉴权
|
||||
- tmdb轮播图支持预告片显示
|
||||
|
||||
### Changed
|
||||
- 调整竖向videocard样式
|
||||
- 源站寻片应用黄色过滤器
|
||||
- 调整播放页大屏幕下封面图大小
|
||||
- 热力图不再使用artplayer内置方式,改为自定义实现
|
||||
|
||||
### Fixed
|
||||
- 修复私人影库分页问题
|
||||
- 修复同一视频频繁弹窗弹幕选择
|
||||
- 彻底移除旧版用户导入导出并修复导入密码二次hash的问题
|
||||
|
||||
|
||||
## [205.0.1] - 2026-01-01
|
||||
### Changed
|
||||
- 迁移跳过配置到新数据结构
|
||||
|
||||
@@ -28,6 +28,9 @@ ENV DOCKER_ENV=true
|
||||
# 生成生产构建
|
||||
RUN pnpm run build
|
||||
|
||||
# 使用 pnpm deploy 提取生产依赖到独立目录
|
||||
RUN pnpm deploy --filter=. --prod --legacy /tmp/prod-deps
|
||||
|
||||
# ---- 第 3 阶段:生成运行时镜像 ----
|
||||
FROM node:24-alpine AS runner
|
||||
|
||||
@@ -55,8 +58,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/server.js ./server.js
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# 安装 Socket.IO 相关依赖(standalone 模式不会自动包含)
|
||||
RUN pnpm add socket.io@^4.8.1 socket.io-client@^4.8.1 --prod
|
||||
# 从构建器中复制生产依赖(包含 Socket.IO)
|
||||
COPY --from=builder --chown=nextjs:nodejs /tmp/prod-deps/node_modules ./node_modules
|
||||
|
||||
# 切换到非特权用户
|
||||
USER nextjs
|
||||
|
||||
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"]
|
||||
255
README.md
@@ -4,6 +4,8 @@
|
||||
<img src="public/logo.png" alt="MoonTVPlus Logo" width="120">
|
||||
</div>
|
||||
|
||||
## ⚠️ 请某些人停止你的抄袭行为,不要我上什么功能你就抄什么,借鉴≠抄袭
|
||||
|
||||
> 🎬 **MoonTVPlus** 是基于 [MoonTV v100](https://github.com/MoonTechLab/LunaTV) 二次开发的增强版影视聚合播放器。它在原版基础上新增了外部播放器支持、视频超分、弹幕系统、评论抓取等实用功能,提供更强大的观影体验。
|
||||
|
||||
<div align="center">
|
||||
@@ -25,11 +27,13 @@
|
||||
- ✨ **视频超分 (Anime4K)**:使用 WebGPU 技术实现实时视频画质增强(支持 1.5x/2x/3x/4x 超分)
|
||||
- 💬 **弹幕系统**:完整的弹幕搜索、匹配、加载功能,支持弹幕设置持久化、弹幕屏蔽
|
||||
- 📝 **豆瓣评论抓取**:自动抓取并展示豆瓣电影短评,支持分页加载
|
||||
- 🧩 **视频源脚本**:支持通过脚本自定义视频源、搜索、详情与播放解析逻辑(实验性)
|
||||
- 🪒**自定义去广告**:你可以自定义你的去广告代码,实现更强力的去广告功能
|
||||
- 🚀 **更快更顺滑**:相较原版项目整体速度更快,交互体验更好
|
||||
- 🎭 **观影室**:支持多人同步观影、实时聊天、语音通话等功能(实验性)。
|
||||
- 📥 **M3U8完整下载**:通过合并m3u8片段实现完整视频下载。
|
||||
- 📥 **M3U8完整下载**:支持浏览器内合并 m3u8 片段下载,也支持下载到本地文件夹并无感播放本地视频。
|
||||
- 💾 **服务器离线下载**:支持在服务器端下载视频文件,支持断点续传,提前下载到家秒加载 。
|
||||
- 📚 **私人影库**:接入 OpenList,可打造专属私人影库,亦可观看网盘资源。
|
||||
- 📚 **私人影库**:接入 OpenList、Emby 或小雅,可打造专属私人影库,亦可观看网盘资源。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
@@ -50,6 +54,7 @@
|
||||
<img src="public/screenshot3.png" alt="项目截图" style="max-width:600px">
|
||||
</details>
|
||||
|
||||
|
||||
### 请不要在 B站、小红书、微信公众号、抖音、今日头条或其他中国大陆社交平台发布视频或文章宣传本项目,不授权任何“科技周刊/月刊”类项目或站点收录本项目。
|
||||
|
||||
## 🗺 目录
|
||||
@@ -72,26 +77,126 @@
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 分类 | 主要依赖 |
|
||||
| --------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router |
|
||||
| UI & 样式 | [Tailwind CSS 3](https://tailwindcss.com/) |
|
||||
| 语言 | TypeScript 4 |
|
||||
| 分类 | 主要依赖 |
|
||||
| --------- | ------------------------------------------------------------ |
|
||||
| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router |
|
||||
| UI & 样式 | [Tailwind CSS 3](https://tailwindcss.com/) |
|
||||
| 语言 | TypeScript 4 |
|
||||
| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) |
|
||||
| 代码质量 | ESLint · Prettier · Jest |
|
||||
| 部署 | Docker |
|
||||
| 代码质量 | ESLint · Prettier · Jest |
|
||||
| 部署 | Docker |
|
||||
|
||||
## 部署
|
||||
|
||||
本项目**支持 Docker 和Vercel平台** 部署。
|
||||
本项目**支持 Docker、Vercel 和 Cloudflare Workers 平台** 部署。
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus)
|
||||
|
||||
**一键部署到zeabur**
|
||||
**一键部署到 Zeabur**
|
||||
|
||||
[](https://zeabur.com/templates/SCHCAY/deploy)
|
||||
|
||||
### Kvrocks 存储(推荐)
|
||||
**一键部署到 Render**
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/mtvpls/MoonTVPlus)
|
||||
|
||||
|
||||
|
||||
### Cloudflare Workers 部署(通过 GitHub Actions)
|
||||
|
||||
Cloudflare Workers 提供免费的边缘计算服务,通过 GitHub Actions 可以实现自动化部署。
|
||||
|
||||
#### 前置要求
|
||||
|
||||
1. 一个 Cloudflare 账号
|
||||
2. Fork 本项目到你的 GitHub 账号
|
||||
3. 准备一个 Upstash Redis 实例(推荐)
|
||||
|
||||
#### 配置步骤
|
||||
|
||||
**1. 获取 Cloudflare API Token 和 Account ID**
|
||||
|
||||
- 访问 [Cloudflare Dashboard](https://dash.cloudflare.com/)
|
||||
- 点击右上角头像 > My Profile > API Tokens
|
||||
- 点击 "Create Token",选择 "Edit Cloudflare Workers" 模板
|
||||
- 或使用自定义 Token,需要以下权限:
|
||||
- Account - Cloudflare Workers Scripts - Edit
|
||||
- Account - D1 - Edit(仅在使用 D1 数据库时需要)
|
||||
- 创建后复制生成的 API Token
|
||||
- 在 Dashboard 首页右侧可以看到你的 Account ID
|
||||
|
||||
**2. 配置 GitHub Secrets**
|
||||
|
||||
进入你 Fork 的仓库,点击 Settings > Secrets and variables > Actions > New repository secret,添加以下必需的 Secrets:
|
||||
|
||||
**必需配置:**
|
||||
|
||||
| Secret 名称 | 说明 | 示例值 |
|
||||
| -------------------------- | --------------------- | ------------------------ |
|
||||
| `CLOUDFLARE_API_TOKEN` | Cloudflare API Token | `your_api_token_here` |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare Account ID | `abc123def456` |
|
||||
| `USERNAME` | 站长账号 | `admin` |
|
||||
| `PASSWORD` | 站长密码 | `your_secure_password` |
|
||||
| `NEXT_PUBLIC_STORAGE_TYPE` | 存储类型 | `upstash` |
|
||||
| `UPSTASH_URL` | Upstash Redis URL | `https://xxx.upstash.io` |
|
||||
| `UPSTASH_TOKEN` | Upstash Redis Token | `your_upstash_token` |
|
||||
|
||||
**3. 触发部署**
|
||||
|
||||
配置完成后,有两种方式触发部署:
|
||||
|
||||
**方式一:手动触发**
|
||||
|
||||
- 进入仓库的 Actions 页面
|
||||
- 选择 "Deploy to Cloudflare" workflow
|
||||
- 点击 "Run workflow" 按钮
|
||||
- 选择分支(通常是 main 或 dev)
|
||||
- 点击 "Run workflow" 开始部署
|
||||
|
||||
**方式二:自动触发(可选)**
|
||||
|
||||
如果想要在推送代码时自动部署,可以修改 `.github/workflows/cloudflare-deploy.yml` 文件:
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main # 或你的主分支名称
|
||||
workflow_dispatch:
|
||||
```
|
||||
|
||||
**4. 查看部署状态**
|
||||
|
||||
- 在 Actions 页面可以看到部署进度
|
||||
- 部署成功后,访问 `https://your-project-name.your-account.workers.dev`
|
||||
- 也可以在 Cloudflare Dashboard 的 Workers & Pages 中查看部署的应用
|
||||
|
||||
**5. 绑定自定义域名(可选)**
|
||||
|
||||
- 在 Cloudflare Dashboard 中进入你的 Worker
|
||||
- 点击 Settings > Triggers > Custom Domains
|
||||
- 添加你的自定义域名
|
||||
|
||||
**6. 使用 D1 数据库(可选)**
|
||||
|
||||
如果想使用 Cloudflare D1 数据库代替 Upstash Redis,需要进行以下配置:
|
||||
|
||||
1. 在 Cloudflare Dashboard 中创建一个 D1 数据库
|
||||
2. 复制数据库 ID
|
||||
3. 在 GitHub Secrets 中配置:
|
||||
- 将 `NEXT_PUBLIC_STORAGE_TYPE` 设置为 `d1`
|
||||
- 添加 `D1_DATABASE_ID` 并填入你的数据库 ID
|
||||
- 无需配置 `UPSTASH_URL` 和 `UPSTASH_TOKEN`
|
||||
|
||||
**7. 配置外部定时任务(可选)**
|
||||
|
||||
可使用外部定时请求/api/cron/mtvpls端点以触发定时任务,或新建一个workers请求触发,推荐每小时请求一次。
|
||||
|
||||
---
|
||||
|
||||
### Docker 部署
|
||||
|
||||
#### Kvrocks 存储(推荐)
|
||||
|
||||
```yml
|
||||
services:
|
||||
@@ -115,7 +220,7 @@ services:
|
||||
container_name: moontv-kvrocks
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- kvrocks-data:/var/lib/kvrocks
|
||||
- kvrocks-data:/var/lib/kvrocks/data
|
||||
networks:
|
||||
- moontv-network
|
||||
networks:
|
||||
@@ -163,6 +268,7 @@ networks:
|
||||
1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。
|
||||
2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN**
|
||||
3. 使用如下 docker compose
|
||||
|
||||
```yml
|
||||
services:
|
||||
moontv-core:
|
||||
@@ -179,6 +285,44 @@ services:
|
||||
- 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:
|
||||
```
|
||||
|
||||
## 配置文件
|
||||
|
||||
完成部署后为空壳应用,无播放源,需要站长在管理后台的配置文件设置中填写配置文件,本版本已不支持无数据库运行。
|
||||
@@ -234,39 +378,51 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 | 可选值 | 默认值 |
|
||||
| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
||||
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
||||
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
|
||||
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
||||
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
|
||||
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
|
||||
| REDIS_URL | redis 连接 url | 连接 url | 空 |
|
||||
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
|
||||
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
|
||||
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
||||
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
|
||||
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
|
||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
|
||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
|
||||
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
|
||||
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
|
||||
| NEXT_PUBLIC_PROXY_M3U8_TOKEN | M3U8 代理 API 鉴权 Token(外部播放器跳转时的鉴权token,不填为无鉴权) | 任意字符串 | (空) |
|
||||
| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) |
|
||||
| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false |
|
||||
| TVBOX_SUBSCRIBE_TOKEN | TVBOX 订阅 API 访问 Token,如启用TVBOX功能必须设置该项 | 任意字符串 | (空) |
|
||||
| WATCH_ROOM_ENABLED | 是否启用观影室功能(vercel部署不支持该功能,可使用外部服务器) | true/false | false |
|
||||
| WATCH_ROOM_SERVER_TYPE | 观影室服务器类型 | internal/external | internal |
|
||||
| WATCH_ROOM_EXTERNAL_SERVER_URL | 外部观影室服务器地址(当 SERVER_TYPE 为 external 时必填) | WebSocket URL | (空) |
|
||||
| WATCH_ROOM_EXTERNAL_SERVER_AUTH | 外部观影室服务器认证令牌(当 SERVER_TYPE 为 external 时必填) | 任意字符串 | (空) |
|
||||
| NEXT_PUBLIC_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback |
|
||||
| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false |
|
||||
| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data |
|
||||
| VIDEOINFO_CACHE_MINUTES | 私人影库视频信息在内存中的缓存时长(分钟) | 正整数 | 1440(1天) |
|
||||
| NEXT_PUBLIC_ENABLE_SOURCE_SEARCH | 是否开启源站寻片功能 | true/false | true |
|
||||
| MAX_PLAY_RECORDS_PER_USER | 单个用户播放记录清理阈值(超过此数量将自动清理旧记录) | 正整数 | 100 |
|
||||
| 变量 | 说明 | 可选值 | 默认值 |
|
||||
| ---------------------------------------- | ------------------------------------------------------------ | --------------------------- | ------------------------------------------------------------ |
|
||||
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
||||
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
||||
| 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 | 空 |
|
||||
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
||||
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash、d1 | 无默认,必填字段 |
|
||||
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
|
||||
| REDIS_URL | redis 连接 url | 连接 url | 空 |
|
||||
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
|
||||
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
|
||||
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
||||
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
|
||||
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
|
||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
|
||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
|
||||
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
|
||||
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
|
||||
| NEXT_PUBLIC_PROXY_M3U8_TOKEN | M3U8 代理 API 鉴权 Token(外部播放器跳转时的鉴权token,不填为无鉴权) | 任意字符串 | (空) |
|
||||
| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) |
|
||||
| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false |
|
||||
| TVBOX_SUBSCRIBE_TOKEN | TVBOX 订阅 API 访问 Token,如启用TVBOX功能必须设置该项 | 任意字符串 | (空) |
|
||||
| TVBOX_BLOCKED_SOURCES | TVBOX 订阅屏蔽源列表(多个源用逗号分隔,匹配视频源的 key) | 逗号分隔的源 key | (空) |
|
||||
| WATCH_ROOM_ENABLED | 是否启用观影室功能(vercel部署不支持该功能,可使用外部服务器) | true/false | false |
|
||||
| WATCH_ROOM_SERVER_TYPE | 观影室服务器类型 | internal/external | internal |
|
||||
| WATCH_ROOM_EXTERNAL_SERVER_URL | 外部观影室服务器地址(当 SERVER_TYPE 为 external 时必填) | WebSocket URL | (空) |
|
||||
| WATCH_ROOM_EXTERNAL_SERVER_AUTH | 外部观影室服务器认证令牌(当 SERVER_TYPE 为 external 时必填) | 任意字符串 | (空) |
|
||||
| NEXT_PUBLIC_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback |
|
||||
| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false |
|
||||
| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data |
|
||||
| VIDEOINFO_CACHE_MINUTES | 私人影库视频信息在内存中的缓存时长(分钟) | 正整数 | 1440(1天) |
|
||||
| NEXT_PUBLIC_ENABLE_SOURCE_SEARCH | 是否开启源站寻片功能 | true/false | true |
|
||||
| MAX_PLAY_RECORDS_PER_USER | 单个用户播放记录清理阈值(超过此数量将自动清理旧记录) | 正整数 | 100 |
|
||||
| INIT_CONFIG | 初始配置(JSON 格式,包含 api_site、custom_category、lives 等) | JSON 字符串 | (空) |
|
||||
| CONFIG_SUBSCRIPTION_URL | 配置订阅 URL(Base58 编码的配置文件地址,优先级高于 INIT_CONFIG) | URL | (空) |
|
||||
| TMDB_API_KEY | TMDB API 密钥 | 任意字符串 | (空) |
|
||||
| TMDB_PROXY | TMDB 代理地址 | URL | (空) |
|
||||
| TMDB_REVERSE_PROXY | TMDB 反向代理地址 | URL | (空) |
|
||||
| DANMAKU_API_BASE | 弹幕 API 地址 | URL | http://localhost:9321 |
|
||||
| DANMAKU_API_TOKEN | 弹幕 API Token | 任意字符串 | 87654321 |
|
||||
| DATA_MIGRATION_CHUNK_SIZE | 数据迁移批处理大小(控制导入导出时每批处理的用户数量和数据条数) | 正整数 | 10 |
|
||||
|
||||
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
||||
|
||||
@@ -299,13 +455,16 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
|
||||
**配置步骤:**
|
||||
|
||||
1. 按照 [watch-room-server](https://github.com/tgs9915/watch-room-server) 的文档部署外部服务器
|
||||
|
||||
2. 在 MoonTVPlus 中设置以下环境变量:
|
||||
|
||||
```env
|
||||
WATCH_ROOM_ENABLED=true
|
||||
WATCH_ROOM_SERVER_TYPE=external
|
||||
WATCH_ROOM_EXTERNAL_SERVER_URL=wss://your-watch-room-server.com
|
||||
WATCH_ROOM_EXTERNAL_SERVER_AUTH=your_secure_token
|
||||
```
|
||||
|
||||
3. 重启应用即可使用外部观影室服务器
|
||||
|
||||
|
||||
@@ -324,6 +483,7 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
|
||||
|
||||
|
||||
## 超分功能说明
|
||||
|
||||
超分功能需要浏览器支持webgpu并且你的浏览器环境不能是http(如非要在http中使用,需要在浏览器端设置允许不安全的内容)
|
||||
|
||||
|
||||
@@ -342,11 +502,14 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
|
||||
### 配置步骤
|
||||
|
||||
1. 在环境变量中设置以下配置:
|
||||
|
||||
```env
|
||||
# 启用 TVBOX 订阅功能
|
||||
ENABLE_TVBOX_SUBSCRIBE=true
|
||||
# 设置订阅访问 Token(请使用强密码)
|
||||
TVBOX_SUBSCRIBE_TOKEN=your_secure_random_token
|
||||
# 可选:屏蔽特定视频源(多个源用逗号分隔,填写视频源的 key)
|
||||
TVBOX_BLOCKED_SOURCES=source1,source2
|
||||
```
|
||||
|
||||
2. 重启应用后,登录网站,点击用户菜单中的"订阅"按钮
|
||||
|
||||
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 +1,2 @@
|
||||
205.0.1
|
||||
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,用于用户独立订阅';
|
||||
@@ -1,8 +1,12 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
// 检测是否为 Cloudflare Pages 构建
|
||||
const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare';
|
||||
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
// Cloudflare Pages 不支持 standalone,使用默认输出
|
||||
output: isCloudflare ? undefined : 'standalone',
|
||||
eslint: {
|
||||
dirs: ['src'],
|
||||
// 在生产构建时忽略 ESLint 错误
|
||||
@@ -13,7 +17,7 @@ const nextConfig = {
|
||||
swcMinify: true,
|
||||
|
||||
experimental: {
|
||||
instrumentationHook: process.env.NODE_ENV === 'production',
|
||||
instrumentationHook: process.env.NODE_ENV === 'production' && !isCloudflare,
|
||||
},
|
||||
|
||||
// Uncoment to add domain whitelist
|
||||
@@ -31,7 +35,7 @@ const nextConfig = {
|
||||
],
|
||||
},
|
||||
|
||||
webpack(config) {
|
||||
webpack(config, { isServer }) {
|
||||
// Grab the existing rule that handles SVG imports
|
||||
const fileLoaderRule = config.module.rules.find((rule) =>
|
||||
rule.test?.test?.('.svg')
|
||||
@@ -67,6 +71,25 @@ const nextConfig = {
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
53
open-next.config.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { OpenNextConfig } from '@opennextjs/cloudflare';
|
||||
|
||||
const config: OpenNextConfig = {
|
||||
default: {
|
||||
override: {
|
||||
wrapper: 'cloudflare-node',
|
||||
converter: 'edge',
|
||||
proxyExternalRequest: 'fetch',
|
||||
incrementalCache: 'dummy',
|
||||
tagCache: 'dummy',
|
||||
queue: 'dummy',
|
||||
},
|
||||
},
|
||||
edgeExternals: [
|
||||
'node:crypto',
|
||||
'node:async_hooks',
|
||||
'node:buffer',
|
||||
'node:stream',
|
||||
'node:util',
|
||||
'node:events',
|
||||
'node:path',
|
||||
'node:url',
|
||||
'node:fs',
|
||||
'node:http',
|
||||
'node:https',
|
||||
'node:net',
|
||||
'node:tls',
|
||||
'node:dns',
|
||||
'node:os',
|
||||
'node:zlib',
|
||||
'node:vm',
|
||||
'node:child_process',
|
||||
'node:string_decoder',
|
||||
'node:tty',
|
||||
'node:assert',
|
||||
'node:punycode',
|
||||
'node:timers',
|
||||
'node:worker_threads',
|
||||
],
|
||||
middleware: {
|
||||
external: true,
|
||||
override: {
|
||||
wrapper: 'cloudflare-edge',
|
||||
converter: 'edge',
|
||||
proxyExternalRequest: 'fetch',
|
||||
incrementalCache: 'dummy',
|
||||
tagCache: 'dummy',
|
||||
queue: 'dummy',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
38
package.json
@@ -5,7 +5,10 @@
|
||||
"scripts": {
|
||||
"dev": "pnpm gen:manifest && node server.js",
|
||||
"build": "pnpm gen:manifest && next build",
|
||||
"build:cloudflare": "BUILD_TARGET=cloudflare pnpm gen:manifest && npx @opennextjs/cloudflare build",
|
||||
"start": "NODE_ENV=production node server.js",
|
||||
"preview:cloudflare": "wrangler dev",
|
||||
"deploy:cloudflare": "wrangler deploy",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "eslint src --fix && pnpm format",
|
||||
"lint:strict": "eslint --max-warnings=0 src",
|
||||
@@ -16,8 +19,10 @@
|
||||
"format:check": "prettier -c .",
|
||||
"gen:manifest": "node scripts/generate-manifest.js",
|
||||
"postbuild": "echo 'Build completed - sitemap generation disabled'",
|
||||
"prepare": "husky install",
|
||||
"watch-room:server": "node server/watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY"
|
||||
"watch-room:server": "node server/watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY",
|
||||
"init:sqlite": "node scripts/init-sqlite.js",
|
||||
"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": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -27,15 +32,19 @@
|
||||
"@headlessui/react": "^2.2.4",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@upstash/redis": "^1.25.0",
|
||||
"@vercel/postgres": "^0.10.0",
|
||||
"@vidstack/react": "^1.12.13",
|
||||
"anime4k-webgpu": "^1.0.0",
|
||||
"artplayer": "^5.3.0",
|
||||
"artplayer-plugin-danmuku": "^5.2.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"bs58": "^6.0.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"clsx": "^2.0.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"flv.js": "^1.6.2",
|
||||
"framer-motion": "^12.18.1",
|
||||
"he": "^1.2.0",
|
||||
"hls.js": "^1.6.10",
|
||||
@@ -43,18 +52,22 @@
|
||||
"lucide-react": "^0.438.0",
|
||||
"media-icons": "^1.1.5",
|
||||
"mux.js": "^6.3.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "^14.2.33",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^7.0.12",
|
||||
"nprogress": "^0.2.0",
|
||||
"opencc-js": "^1.0.5",
|
||||
"parse-torrent-name": "^0.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-window": "^2.2.3",
|
||||
"redis": "^4.6.7",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"swiper": "^11.2.8",
|
||||
@@ -64,20 +77,21 @@
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^16.3.0",
|
||||
"@commitlint/config-conventional": "^16.2.4",
|
||||
"@opennextjs/cloudflare": "^1.15.1",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/bs58": "^5.0.0",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/node": "24.0.3",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-window": "^2.0.0",
|
||||
"@types/testing-library__jest-dom": "^5.14.9",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
@@ -88,25 +102,15 @@
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-plugin-simple-import-sort": "^7.0.0",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"husky": "^7.0.4",
|
||||
"jest": "^27.5.1",
|
||||
"lint-staged": "^12.5.0",
|
||||
"next-router-mock": "^0.9.0",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.5.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^4.9.5",
|
||||
"vercel": "^50.4.10",
|
||||
"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"
|
||||
}
|
||||
10566
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 siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
|
||||
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTVPlus';
|
||||
|
||||
// manifest.json 模板
|
||||
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,12 +3,14 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { parseStringPromise } from 'xml2js';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* POST /api/acg/search
|
||||
* 搜索 ACG 磁力资源(仅管理员和站长可用)
|
||||
* POST /api/acg/acgrip
|
||||
* 搜索 ACG.RIP 磁力资源(仅管理员和站长可用,支持分页)
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -47,12 +49,18 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 请求 acg.rip API
|
||||
const acgUrl = `https://acg.rip/page/${pageNum}.xml?term=${encodeURIComponent(trimmedKeyword)}`;
|
||||
// 请求 acg.rip RSS
|
||||
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(acgUrl, {
|
||||
const response = await universalMagnetFetch(searchUrl, config.SiteConfig.MagnetProxy, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -78,10 +86,12 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// 转换为标准格式
|
||||
const results = items.map((item: any) => {
|
||||
const description = item.description?.[0] || '';
|
||||
|
||||
// 提取描述中的图片(如果有)
|
||||
let images: string[] = [];
|
||||
if (item.description?.[0]) {
|
||||
const imgMatches = item.description[0].match(/src="([^"]+)"/g);
|
||||
if (description) {
|
||||
const imgMatches = description.match(/src="([^"]+)"/g);
|
||||
if (imgMatches) {
|
||||
images = imgMatches.map((match: string) => {
|
||||
const urlMatch = match.match(/src="([^"]+)"/);
|
||||
@@ -90,13 +100,19 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const title = item.title?.[0] || '';
|
||||
const link = item.link?.[0] || '';
|
||||
const guid = item.guid?.[0] || link || `${title}-${item.pubDate?.[0] || ''}`;
|
||||
const pubDate = item.pubDate?.[0] || '';
|
||||
const torrentUrl = item.enclosure?.[0]?.$?.url || '';
|
||||
|
||||
return {
|
||||
title: item.title?.[0] || '',
|
||||
link: item.link?.[0] || '',
|
||||
guid: item.guid?.[0] || '',
|
||||
pubDate: item.pubDate?.[0] || '',
|
||||
torrentUrl: item.enclosure?.[0]?.$?.url || '',
|
||||
description: item.description?.[0] || '',
|
||||
title,
|
||||
link,
|
||||
guid,
|
||||
pubDate,
|
||||
torrentUrl,
|
||||
description,
|
||||
images,
|
||||
};
|
||||
});
|
||||
@@ -107,9 +123,8 @@ export async function POST(req: NextRequest) {
|
||||
total: results.length,
|
||||
items: results,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('ACG 搜索失败:', error);
|
||||
console.error('ACG.RIP 搜索失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || '搜索失败' },
|
||||
{ status: 500 }
|
||||
140
src/app/api/acg/dmhy/route.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { parseStringPromise } from 'xml2js';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* POST /api/acg/dmhy
|
||||
* 搜索 动漫花园 (share.dmhy.org) RSS(仅管理员和站长可用)
|
||||
* - http://share.dmhy.org/topics/rss/rss.xml?keyword=xxx
|
||||
* - RSS 不支持分页(page>1 返回空 items)
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(req);
|
||||
if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限访问' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { keyword, page = 1 } = await req.json();
|
||||
|
||||
if (!keyword || typeof keyword !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: '搜索关键词不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedKeyword = keyword.trim();
|
||||
if (!trimmedKeyword) {
|
||||
return NextResponse.json(
|
||||
{ error: '搜索关键词不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const pageNum = parseInt(String(page), 10);
|
||||
if (isNaN(pageNum) || pageNum < 1) {
|
||||
return NextResponse.json(
|
||||
{ error: '页码必须是大于0的整数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pageNum > 1) {
|
||||
return NextResponse.json({
|
||||
keyword: trimmedKeyword,
|
||||
page: pageNum,
|
||||
total: 0,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const baseUrl = `${getMagnetBaseUrl(
|
||||
'http://share.dmhy.org',
|
||||
config.SiteConfig.MagnetDmhyReverseProxy
|
||||
)}/topics/rss/rss.xml`;
|
||||
const params = new URLSearchParams({ keyword: trimmedKeyword });
|
||||
const searchUrl = `${baseUrl}?${params.toString()}`;
|
||||
|
||||
const response = await universalMagnetFetch(searchUrl, config.SiteConfig.MagnetProxy, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`DMHY API 请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const xmlData = await response.text();
|
||||
const parsed = await parseStringPromise(xmlData);
|
||||
|
||||
if (!parsed?.rss?.channel?.[0]?.item) {
|
||||
return NextResponse.json({
|
||||
keyword: trimmedKeyword,
|
||||
page: pageNum,
|
||||
total: 0,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
const items = parsed.rss.channel[0].item;
|
||||
|
||||
const results = items.map((item: any) => {
|
||||
const title = item.title?.[0] || '';
|
||||
const link = item.link?.[0] || '';
|
||||
const guid = item.guid?.[0] || link || `${title}-${item.pubDate?.[0] || ''}`;
|
||||
const pubDate = item.pubDate?.[0] || '';
|
||||
const description = item.description?.[0] || '';
|
||||
const torrentUrl = item.enclosure?.[0]?.$?.url || '';
|
||||
|
||||
// 提取描述中的图片(如果有)
|
||||
let images: string[] = [];
|
||||
if (description) {
|
||||
const imgMatches = description.match(/src="([^"]+)"/g);
|
||||
if (imgMatches) {
|
||||
images = imgMatches
|
||||
.map((match: string) => {
|
||||
const urlMatch = match.match(/src="([^"]+)"/);
|
||||
return urlMatch ? urlMatch[1] : '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
link,
|
||||
guid,
|
||||
pubDate,
|
||||
torrentUrl,
|
||||
description,
|
||||
images,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
keyword: trimmedKeyword,
|
||||
page: pageNum,
|
||||
total: results.length,
|
||||
items: results,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('DMHY 搜索失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || '搜索失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
154
src/app/api/acg/mikan/route.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { parseStringPromise } from 'xml2js';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const pickText = (value: any): string => {
|
||||
if (value === undefined || value === null) return '';
|
||||
if (Array.isArray(value)) return String(value[0] ?? '');
|
||||
return String(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/acg/mikan
|
||||
* 搜索 Mikan RSS(仅管理员和站长可用,不支持分页)
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 检查权限
|
||||
const authInfo = getAuthInfoFromCookie(req);
|
||||
if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限访问' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { keyword, page = 1 } = await req.json();
|
||||
|
||||
if (!keyword || typeof keyword !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: '搜索关键词不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedKeyword = keyword.trim();
|
||||
if (!trimmedKeyword) {
|
||||
return NextResponse.json(
|
||||
{ error: '搜索关键词不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证页码(Mikan RSS 不支持分页,这里仍接收 page 以保持接口一致)
|
||||
const pageNum = parseInt(String(page), 10);
|
||||
if (isNaN(pageNum) || pageNum < 1) {
|
||||
return NextResponse.json(
|
||||
{ error: '页码必须是大于0的整数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pageNum > 1) {
|
||||
return NextResponse.json({
|
||||
keyword: trimmedKeyword,
|
||||
page: pageNum,
|
||||
total: 0,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const searchBaseUrl = getMagnetBaseUrl(
|
||||
'https://mikanani.me',
|
||||
config.SiteConfig.MagnetMikanReverseProxy
|
||||
);
|
||||
const searchUrl = `${searchBaseUrl}/RSS/Search?searchstr=${encodeURIComponent(trimmedKeyword)}`;
|
||||
|
||||
const response = await universalMagnetFetch(searchUrl, config.SiteConfig.MagnetProxy, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Mikan API 请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const xmlData = await response.text();
|
||||
const parsed = await parseStringPromise(xmlData);
|
||||
|
||||
if (!parsed?.rss?.channel?.[0]?.item) {
|
||||
return NextResponse.json({
|
||||
keyword: trimmedKeyword,
|
||||
page: pageNum,
|
||||
total: 0,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
const items = parsed.rss.channel[0].item;
|
||||
|
||||
const results = items.map((item: any) => {
|
||||
const title = pickText(item.title);
|
||||
const link = pickText(item.link);
|
||||
const guid = pickText(item.guid) || link || `${title}-${pickText(item.torrent?.[0]?.pubDate)}`;
|
||||
const pubDate =
|
||||
pickText(item.pubDate) ||
|
||||
pickText(item.torrent?.[0]?.pubDate) ||
|
||||
pickText(item['dc:date']);
|
||||
|
||||
const description =
|
||||
pickText(item.description) ||
|
||||
pickText(item['content:encoded']) ||
|
||||
'';
|
||||
|
||||
const torrentUrl =
|
||||
pickText(item.enclosure?.[0]?.$?.url) ||
|
||||
pickText(item.enclosure?.[0]?.$?.href) ||
|
||||
'';
|
||||
|
||||
// 提取描述中的图片(如果有)
|
||||
let images: string[] = [];
|
||||
if (description) {
|
||||
const imgMatches = description.match(/src="([^"]+)"/g);
|
||||
if (imgMatches) {
|
||||
images = imgMatches.map((match: string) => {
|
||||
const urlMatch = match.match(/src="([^"]+)"/);
|
||||
return urlMatch ? urlMatch[1] : '';
|
||||
}).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
link,
|
||||
guid,
|
||||
pubDate,
|
||||
torrentUrl,
|
||||
description,
|
||||
images,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
keyword: trimmedKeyword,
|
||||
page: pageNum,
|
||||
total: results.length,
|
||||
items: results,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Mikan 搜索失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || '搜索失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic'; // 禁用缓存
|
||||
|
||||
/**
|
||||
* GET /api/ad-filter
|
||||
|
||||
@@ -57,10 +57,14 @@ export async function POST(request: NextRequest) {
|
||||
EnableHomepageEntry,
|
||||
EnableVideoCardEntry,
|
||||
EnablePlayPageEntry,
|
||||
EnableAIComments,
|
||||
AllowRegularUsers,
|
||||
Temperature,
|
||||
MaxTokens,
|
||||
SystemPrompt,
|
||||
EnableStreaming,
|
||||
DefaultMessageNoVideo,
|
||||
DefaultMessageWithVideo,
|
||||
} = body as {
|
||||
Enabled: boolean;
|
||||
Provider: 'openai' | 'claude' | 'custom';
|
||||
@@ -90,10 +94,14 @@ export async function POST(request: NextRequest) {
|
||||
EnableHomepageEntry: boolean;
|
||||
EnableVideoCardEntry: boolean;
|
||||
EnablePlayPageEntry: boolean;
|
||||
EnableAIComments: boolean;
|
||||
AllowRegularUsers: boolean;
|
||||
Temperature?: number;
|
||||
MaxTokens?: number;
|
||||
SystemPrompt?: string;
|
||||
EnableStreaming?: boolean;
|
||||
DefaultMessageNoVideo?: string;
|
||||
DefaultMessageWithVideo?: string;
|
||||
};
|
||||
|
||||
// 参数校验
|
||||
@@ -126,10 +134,12 @@ export async function POST(request: NextRequest) {
|
||||
typeof EnableHomepageEntry !== 'boolean' ||
|
||||
typeof EnableVideoCardEntry !== 'boolean' ||
|
||||
typeof EnablePlayPageEntry !== 'boolean' ||
|
||||
typeof EnableAIComments !== 'boolean' ||
|
||||
typeof AllowRegularUsers !== 'boolean' ||
|
||||
(Temperature !== undefined && typeof Temperature !== 'number') ||
|
||||
(MaxTokens !== undefined && typeof MaxTokens !== 'number') ||
|
||||
(SystemPrompt !== undefined && typeof SystemPrompt !== 'string')
|
||||
(SystemPrompt !== undefined && typeof SystemPrompt !== 'string') ||
|
||||
(EnableStreaming !== undefined && typeof EnableStreaming !== 'boolean')
|
||||
) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
@@ -174,10 +184,14 @@ export async function POST(request: NextRequest) {
|
||||
EnableHomepageEntry,
|
||||
EnableVideoCardEntry,
|
||||
EnablePlayPageEntry,
|
||||
EnableAIComments,
|
||||
AllowRegularUsers,
|
||||
Temperature,
|
||||
MaxTokens,
|
||||
SystemPrompt,
|
||||
EnableStreaming,
|
||||
DefaultMessageNoVideo,
|
||||
DefaultMessageWithVideo,
|
||||
};
|
||||
|
||||
// 写入数据库
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,31 +34,17 @@ export async function GET(request: NextRequest) {
|
||||
if (username === process.env.USERNAME) {
|
||||
result.Role = 'owner';
|
||||
} else {
|
||||
// 优先从新版本获取用户信息
|
||||
// 从新版数据库获取用户信息
|
||||
const { db } = await import('@/lib/db');
|
||||
const userInfoV2 = await db.getUserInfoV2(username);
|
||||
|
||||
if (userInfoV2) {
|
||||
// 使用新版本用户信息
|
||||
if (userInfoV2.role === 'admin' && !userInfoV2.banned) {
|
||||
result.Role = 'admin';
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: '你是管理员吗你就访问?' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
if (userInfoV2 && userInfoV2.role === 'admin' && !userInfoV2.banned) {
|
||||
result.Role = 'admin';
|
||||
} else {
|
||||
// 回退到配置中查找
|
||||
const user = config.UserConfig.Users.find((u) => u.username === username);
|
||||
if (user && user.role === 'admin' && !user.banned) {
|
||||
result.Role = 'admin';
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: '你是管理员吗你就访问?' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: '你是管理员吗你就访问?' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,3 +64,54 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{ error: '不支持本地存储进行管理员配置' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
try {
|
||||
const newConfig = await request.json();
|
||||
|
||||
// 权限检查
|
||||
if (username !== process.env.USERNAME) {
|
||||
const { db } = await import('@/lib/db');
|
||||
const userInfoV2 = await db.getUserInfoV2(username);
|
||||
|
||||
if (!userInfoV2 || (userInfoV2.role !== 'admin' && userInfoV2.role !== 'owner') || userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
const { db } = await import('@/lib/db');
|
||||
const { configSelfCheck, setCachedConfig } = await import('@/lib/config');
|
||||
|
||||
// 自检配置
|
||||
const checkedConfig = configSelfCheck(newConfig);
|
||||
|
||||
// 保存到数据库
|
||||
await db.saveAdminConfig(checkedConfig);
|
||||
|
||||
// 更新缓存
|
||||
await setCachedConfig(checkedConfig);
|
||||
|
||||
return NextResponse.json({ success: true, message: '配置已保存' });
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '保存配置失败: ' + (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { SimpleCrypto } from '@/lib/crypto';
|
||||
import { db } from '@/lib/db';
|
||||
import { CURRENT_VERSION } from '@/lib/version';
|
||||
import { updateProgress, clearProgress } from '@/lib/data-migration-progress';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: '权限不足,只有站长可以导出数据' }, { status: 401 });
|
||||
}
|
||||
|
||||
const username = authInfo.username; // 存储到局部变量以便 TypeScript 类型推断
|
||||
|
||||
const config = await db.getAdminConfig();
|
||||
if (!config) {
|
||||
return NextResponse.json({ error: '无法获取配置' }, { status: 500 });
|
||||
@@ -61,51 +64,129 @@ export async function POST(req: NextRequest) {
|
||||
};
|
||||
|
||||
// 获取所有V2用户
|
||||
const usersV2Result = await db.getUserListV2(0, 10000, process.env.USERNAME);
|
||||
const usersV2Result = await db.getUserListV2(0, 1000000, process.env.USERNAME);
|
||||
exportData.data.usersV2 = usersV2Result.users;
|
||||
console.log(`从getUserListV2获取到 ${usersV2Result.users.length} 个用户`);
|
||||
|
||||
// 获取所有用户(包括旧版用户)
|
||||
// 获取所有用户(getAllUsers返回的是V2用户)
|
||||
let allUsers = await db.getAllUsers();
|
||||
// 添加站长用户
|
||||
allUsers.push(process.env.USERNAME);
|
||||
// 添加V2用户
|
||||
allUsers.push(process.env.USERNAME); // 添加站长
|
||||
// 添加V2用户列表中的用户
|
||||
usersV2Result.users.forEach(user => {
|
||||
if (!allUsers.includes(user.username)) {
|
||||
allUsers.push(user.username);
|
||||
}
|
||||
});
|
||||
allUsers = Array.from(new Set(allUsers));
|
||||
console.log(`准备导出 ${allUsers.length} 个V2用户(包括站长)`);
|
||||
|
||||
// 为每个用户收集数据
|
||||
for (const username of allUsers) {
|
||||
const userData = {
|
||||
// 播放记录
|
||||
playRecords: await db.getAllPlayRecords(username),
|
||||
// 收藏夹
|
||||
favorites: await db.getAllFavorites(username),
|
||||
// 搜索历史
|
||||
searchHistory: await db.getSearchHistory(username),
|
||||
// 跳过片头片尾配置
|
||||
skipConfigs: await db.getAllSkipConfigs(username),
|
||||
// 用户密码(通过验证空密码来检查用户是否存在,然后获取密码)
|
||||
password: await getUserPassword(username),
|
||||
// V2用户的加密密码
|
||||
passwordV2: await getUserPasswordV2(username)
|
||||
};
|
||||
// 为每个用户收集数据(只导出V2用户)- 使用并行处理
|
||||
console.log(`开始并行导出 ${allUsers.length} 个用户的数据...`);
|
||||
updateProgress(username, 'export', 'collecting', 0, allUsers.length, '开始收集用户数据...');
|
||||
|
||||
exportData.data.userData[username] = userData;
|
||||
// 分块处理用户,每批处理数量可通过环境变量配置
|
||||
const CHUNK_SIZE = parseInt(process.env.DATA_MIGRATION_CHUNK_SIZE || '10', 10);
|
||||
let exportedCount = 0;
|
||||
|
||||
for (let i = 0; i < allUsers.length; i += CHUNK_SIZE) {
|
||||
const chunk = allUsers.slice(i, i + CHUNK_SIZE);
|
||||
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})...`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`已完成 ${exportedCount}/${allUsers.length} 个用户`);
|
||||
}
|
||||
|
||||
// 覆盖站长密码
|
||||
exportData.data.userData[process.env.USERNAME].password = process.env.PASSWORD;
|
||||
console.log(`成功导出 ${exportedCount} 个用户的数据`);
|
||||
|
||||
// 将数据转换为JSON字符串
|
||||
updateProgress(username, 'export', 'serializing', exportedCount, exportedCount, '正在序列化数据...');
|
||||
const jsonData = JSON.stringify(exportData);
|
||||
|
||||
// 先压缩数据
|
||||
updateProgress(username, 'export', 'compressing', exportedCount, exportedCount, '正在压缩数据...');
|
||||
const compressedData = await gzipAsync(jsonData);
|
||||
|
||||
// 使用提供的密码加密压缩后的数据
|
||||
updateProgress(username, 'export', 'encrypting', exportedCount, exportedCount, '正在加密数据...');
|
||||
const encryptedData = SimpleCrypto.encrypt(compressedData.toString('base64'), password);
|
||||
|
||||
// 生成文件名
|
||||
@@ -113,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 filename = `moontv-backup-${timestamp}.dat`;
|
||||
|
||||
// 清除进度信息
|
||||
updateProgress(username, 'export', 'completed', exportedCount, exportedCount, '导出完成!');
|
||||
setTimeout(() => clearProgress(username, 'export'), 3000);
|
||||
|
||||
// 返回加密的数据作为文件下载
|
||||
return new NextResponse(encryptedData, {
|
||||
status: 200,
|
||||
@@ -125,6 +210,11 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
} catch (error) {
|
||||
console.error('数据导出失败:', error);
|
||||
// 清除进度信息
|
||||
const authInfo = getAuthInfoFromCookie(req);
|
||||
if (authInfo?.username) {
|
||||
clearProgress(authInfo.username, 'export');
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '导出失败' },
|
||||
{ status: 500 }
|
||||
@@ -132,32 +222,41 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:获取用户密码(通过数据库直接访问)
|
||||
async function getUserPassword(username: string): Promise<string | null> {
|
||||
try {
|
||||
// 使用 Redis 存储的直接访问方法
|
||||
const storage = (db as any).storage;
|
||||
if (storage && typeof storage.client?.get === 'function') {
|
||||
const passwordKey = `u:${username}:pwd`;
|
||||
const password = await storage.client.get(passwordKey);
|
||||
return password;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`获取用户 ${username} 密码失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:获取V2用户的加密密码
|
||||
async function getUserPasswordV2(username: string): Promise<string | null> {
|
||||
try {
|
||||
const storage = (db as any).storage;
|
||||
if (storage && typeof storage.client?.hget === 'function') {
|
||||
const userInfoKey = `user:${username}:info`;
|
||||
const password = await storage.client.hget(userInfoKey, 'password');
|
||||
return password;
|
||||
if (!storage) return null;
|
||||
|
||||
// 检查存储类型
|
||||
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`;
|
||||
|
||||
if (typeof storage.withRetry === 'function' && storage.client?.hgetall) {
|
||||
const userInfo = await storage.withRetry(() => storage.client.hgetall(userInfoKey));
|
||||
if (userInfo && userInfo.password) {
|
||||
return userInfo.password;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`获取用户 ${username} V2密码失败:`, error);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { configSelfCheck, setCachedConfig } from '@/lib/config';
|
||||
import { SimpleCrypto } from '@/lib/crypto';
|
||||
import { db } from '@/lib/db';
|
||||
import { updateProgress, clearProgress } from '@/lib/data-migration-progress';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: '权限不足,只有站长可以导入数据' }, { status: 401 });
|
||||
}
|
||||
|
||||
const username = authInfo.username; // 存储到局部变量以便 TypeScript 类型推断
|
||||
|
||||
// 解析表单数据
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File;
|
||||
@@ -78,8 +81,16 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// 开始导入数据 - 先清空现有数据
|
||||
updateProgress(username, 'import', 'clearing', 0, 1, '正在清空现有数据...');
|
||||
await db.clearAllData();
|
||||
|
||||
// 额外清除所有V2用户(clearAllData可能只清除旧版用户)
|
||||
const existingUsers = await db.getUserListV2(0, 1000000, process.env.USERNAME);
|
||||
for (const user of existingUsers.users) {
|
||||
await db.deleteUserV2(user.username);
|
||||
}
|
||||
console.log(`已清除 ${existingUsers.users.length} 个现有V2用户`);
|
||||
|
||||
// 导入管理员配置
|
||||
importData.data.adminConfig = configSelfCheck(importData.data.adminConfig);
|
||||
await db.saveAdminConfig(importData.data.adminConfig);
|
||||
@@ -94,111 +105,275 @@ export async function POST(req: NextRequest) {
|
||||
// 不影响主流程,继续执行
|
||||
}
|
||||
|
||||
// 导入V2用户信息
|
||||
if (importData.data.usersV2 && Array.isArray(importData.data.usersV2)) {
|
||||
for (const userV2 of importData.data.usersV2) {
|
||||
// 导入用户数据和user:info
|
||||
const userData = importData.data.userData;
|
||||
const storage = (db as any).storage;
|
||||
// 使用前面已声明的 storageType 变量
|
||||
const usersV2Map = new Map((importData.data.usersV2 || []).map((u: any) => [u.username, u]));
|
||||
|
||||
const userCount = Object.keys(userData).length;
|
||||
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;
|
||||
|
||||
for (let i = 0; i < usernames.length; i += CHUNK_SIZE) {
|
||||
const chunk = usernames.slice(i, i + CHUNK_SIZE);
|
||||
console.log(`处理第 ${Math.floor(i / CHUNK_SIZE) + 1} 批用户 (${chunk.length} 个)`);
|
||||
updateProgress(
|
||||
username,
|
||||
'import',
|
||||
'importing',
|
||||
importedCount,
|
||||
userCount,
|
||||
`正在导入用户数据 (${importedCount}/${userCount})...`
|
||||
);
|
||||
|
||||
// 并行导入当前批次的用户
|
||||
const importPromises = chunk.map(async (username) => {
|
||||
try {
|
||||
// 跳过环境变量中的站长(站长使用环境变量认证)
|
||||
if (userV2.username === process.env.USERNAME) {
|
||||
console.log(`跳过站长 ${userV2.username} 的导入`);
|
||||
continue;
|
||||
}
|
||||
const user = userData[username];
|
||||
// 数据批处理大小(用于播放记录、收藏夹等)
|
||||
const DATA_BATCH_SIZE = parseInt(process.env.DATA_MIGRATION_CHUNK_SIZE || '10', 10);
|
||||
|
||||
// 获取用户的加密密码
|
||||
const userData = importData.data.userData[userV2.username];
|
||||
const passwordV2 = userData?.passwordV2;
|
||||
// 为所有有passwordV2的用户创建user:info
|
||||
if (user.passwordV2) {
|
||||
const userV2 = usersV2Map.get(username) as any;
|
||||
|
||||
if (passwordV2) {
|
||||
// 将站长角色转换为普通角色
|
||||
const importedRole = userV2.role === 'owner' ? 'user' : userV2.role;
|
||||
if (userV2.role === 'owner') {
|
||||
console.log(`用户 ${userV2.username} 的角色从 owner 转换为 user`);
|
||||
// 确定角色:站长为owner,其他用户从usersV2获取或默认为user
|
||||
let role: 'owner' | 'admin' | 'user' = 'user';
|
||||
if (username === process.env.USERNAME) {
|
||||
role = 'owner';
|
||||
} else if (userV2) {
|
||||
role = userV2.role === 'owner' ? 'user' : userV2.role;
|
||||
}
|
||||
|
||||
// 直接使用加密后的密码创建用户
|
||||
const storage = (db as any).storage;
|
||||
if (storage && typeof storage.client?.hset === 'function') {
|
||||
const userInfoKey = `user:${userV2.username}:info`;
|
||||
const createdAt = userV2.created_at || Date.now();
|
||||
const createdAt = userV2?.created_at || Date.now();
|
||||
|
||||
const userInfo: any = {
|
||||
role: importedRole,
|
||||
banned: userV2.banned,
|
||||
password: passwordV2,
|
||||
// 根据存储类型使用不同的导入方法
|
||||
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.tags && userV2.tags.length > 0) {
|
||||
if (userV2?.tags && userV2.tags.length > 0) {
|
||||
userInfo.tags = JSON.stringify(userV2.tags);
|
||||
}
|
||||
|
||||
if (userV2.oidcSub) {
|
||||
if (userV2?.oidcSub) {
|
||||
userInfo.oidcSub = userV2.oidcSub;
|
||||
// 创建OIDC映射
|
||||
const oidcSubKey = `oidc:sub:${userV2.oidcSub}`;
|
||||
await storage.client.set(oidcSubKey, userV2.username);
|
||||
}
|
||||
|
||||
if (userV2.enabledApis && userV2.enabledApis.length > 0) {
|
||||
if (userV2?.enabledApis && userV2.enabledApis.length > 0) {
|
||||
userInfo.enabledApis = JSON.stringify(userV2.enabledApis);
|
||||
}
|
||||
|
||||
await storage.client.hset(userInfoKey, userInfo);
|
||||
|
||||
// 添加到用户列表(Sorted Set)
|
||||
const userListKey = 'user:list';
|
||||
await storage.client.zadd(userListKey, {
|
||||
await storage.withRetry(() => storage.client.hSet(userInfoKey, userInfo));
|
||||
await storage.withRetry(() => storage.client.zAdd('user:list', {
|
||||
score: createdAt,
|
||||
member: userV2.username,
|
||||
});
|
||||
value: username,
|
||||
}));
|
||||
|
||||
console.log(`V2用户 ${userV2.username} 导入成功`);
|
||||
if (userV2?.oidcSub) {
|
||||
const oidcSubKey = `oidc:sub:${userV2.oidcSub}`;
|
||||
await storage.withRetry(() => storage.client.set(oidcSubKey, username));
|
||||
}
|
||||
|
||||
console.log(`用户 ${username} 导入成功 (Redis)`);
|
||||
}
|
||||
} else {
|
||||
console.log(`跳过用户 ${username}:没有passwordV2`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 并行导入用户的各类数据
|
||||
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(`导入V2用户 ${userV2.username} 失败:`, 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} 个用户`
|
||||
);
|
||||
}
|
||||
|
||||
// 导入用户数据
|
||||
const userData = importData.data.userData;
|
||||
for (const username in userData) {
|
||||
const user = userData[username];
|
||||
await db.createUserV2(username, user.password,user.role,user.tags);
|
||||
|
||||
|
||||
// 导入播放记录
|
||||
if (user.playRecords) {
|
||||
for (const [key, record] of Object.entries(user.playRecords)) {
|
||||
await (db as any).storage.setPlayRecord(username, key, record);
|
||||
}
|
||||
}
|
||||
|
||||
// 导入收藏夹
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`成功导入 ${importedCount} 个用户的user:info`);
|
||||
updateProgress(username, 'import', 'completed', importedCount, userCount, '导入完成!');
|
||||
setTimeout(() => clearProgress(username, 'import'), 3000);
|
||||
|
||||
return NextResponse.json({
|
||||
message: '数据导入成功',
|
||||
@@ -210,6 +385,11 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
} catch (error) {
|
||||
console.error('数据导入失败:', error);
|
||||
// 清除进度信息
|
||||
const authInfo = getAuthInfoFromCookie(req);
|
||||
if (authInfo?.username) {
|
||||
clearProgress(authInfo.username, 'import');
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '导入失败' },
|
||||
{ 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
190
src/app/api/admin/email/route.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import type { AdminConfig } from '@/lib/admin.types';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getStorage } from '@/lib/db';
|
||||
import { EmailService } from '@/lib/email.service';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET - 获取邮件配置
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = getStorage();
|
||||
const userInfo = await storage.getUserInfoV2?.(authInfo.username);
|
||||
|
||||
// 只有管理员和站长可以访问
|
||||
if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
const emailConfig = adminConfig?.EmailConfig || {
|
||||
enabled: false,
|
||||
provider: 'smtp' as const,
|
||||
};
|
||||
|
||||
// 不返回敏感信息(密码、API Key)
|
||||
const safeConfig = {
|
||||
enabled: emailConfig.enabled,
|
||||
provider: emailConfig.provider,
|
||||
smtp: emailConfig.smtp
|
||||
? {
|
||||
host: emailConfig.smtp.host,
|
||||
port: emailConfig.smtp.port,
|
||||
secure: emailConfig.smtp.secure,
|
||||
user: emailConfig.smtp.user,
|
||||
from: emailConfig.smtp.from,
|
||||
password: emailConfig.smtp.password ? '******' : '',
|
||||
}
|
||||
: undefined,
|
||||
resend: emailConfig.resend
|
||||
? {
|
||||
from: emailConfig.resend.from,
|
||||
apiKey: emailConfig.resend.apiKey ? '******' : '',
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return NextResponse.json(safeConfig);
|
||||
} catch (error) {
|
||||
console.error('获取邮件配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - 保存邮件配置或发送测试邮件
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = getStorage();
|
||||
const userInfo = await storage.getUserInfoV2?.(authInfo.username);
|
||||
|
||||
// 只有管理员和站长可以访问
|
||||
if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, config, testEmail } = body;
|
||||
|
||||
// 发送测试邮件
|
||||
if (action === 'test') {
|
||||
if (!testEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: '请提供测试邮箱地址' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const emailConfig = config as AdminConfig['EmailConfig'];
|
||||
if (!emailConfig || !emailConfig.enabled) {
|
||||
return NextResponse.json(
|
||||
{ error: '邮件配置未启用' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const adminConfig = await getConfig();
|
||||
const siteName = adminConfig?.SiteConfig?.SiteName || 'MoonTVPlus';
|
||||
await EmailService.sendTestEmail(emailConfig, testEmail, siteName);
|
||||
return NextResponse.json({ success: true, message: '测试邮件发送成功' });
|
||||
} catch (error) {
|
||||
console.error('发送测试邮件失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: `发送失败: ${(error as Error).message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存邮件配置
|
||||
if (action === 'save') {
|
||||
const emailConfig = config as AdminConfig['EmailConfig'];
|
||||
if (!emailConfig) {
|
||||
return NextResponse.json(
|
||||
{ error: '邮件配置不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证配置
|
||||
if (emailConfig.enabled) {
|
||||
if (emailConfig.provider === 'smtp') {
|
||||
if (!emailConfig.smtp?.host || !emailConfig.smtp?.port || !emailConfig.smtp?.user || !emailConfig.smtp?.from) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SMTP配置不完整' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (emailConfig.provider === 'resend') {
|
||||
if (!emailConfig.resend?.apiKey || !emailConfig.resend?.from) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Resend配置不完整' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取现有配置
|
||||
const adminConfig = await getConfig();
|
||||
if (!adminConfig) {
|
||||
return NextResponse.json(
|
||||
{ error: '管理员配置不存在' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 如果密码或API Key是占位符,保留原有值
|
||||
if (emailConfig.smtp?.password === '******') {
|
||||
const oldConfig = adminConfig.EmailConfig;
|
||||
if (oldConfig?.smtp?.password) {
|
||||
emailConfig.smtp.password = oldConfig.smtp.password;
|
||||
}
|
||||
}
|
||||
|
||||
if (emailConfig.resend?.apiKey === '******') {
|
||||
const oldConfig = adminConfig.EmailConfig;
|
||||
if (oldConfig?.resend?.apiKey) {
|
||||
emailConfig.resend.apiKey = oldConfig.resend.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
adminConfig.EmailConfig = emailConfig;
|
||||
await storage.setAdminConfig(adminConfig);
|
||||
|
||||
return NextResponse.json({ success: true, message: '邮件配置保存成功' });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: '无效的操作' },
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('处理邮件配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
45
src/app/api/admin/emby/export/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{ error: '不支持本地存储进行管理员配置' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 仅站长可用
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
return NextResponse.json({ error: '权限不足,仅站长可用' }, { status: 403 });
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
const embyConfig = adminConfig.EmbyConfig || {};
|
||||
|
||||
const exportData = JSON.stringify(embyConfig, null, 2);
|
||||
|
||||
return new NextResponse(exportData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="emby-config-${Date.now()}.json"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '导出失败: ' + (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
80
src/app/api/admin/emby/import/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig, setCachedConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{ error: '不支持本地存储进行管理员配置' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 仅站长可用
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
return NextResponse.json({ error: '权限不足,仅站长可用' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { data } = body;
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: '缺少导入数据' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
|
||||
// 追加和覆盖:合并Sources数组
|
||||
if (data.Sources && Array.isArray(data.Sources)) {
|
||||
const existingSources = adminConfig.EmbyConfig?.Sources || [];
|
||||
|
||||
// 覆盖已存在的,追加新的
|
||||
const mergedSources = [...existingSources];
|
||||
for (const importSource of data.Sources) {
|
||||
const existingIndex = mergedSources.findIndex(s => s.key === importSource.key);
|
||||
if (existingIndex >= 0) {
|
||||
mergedSources[existingIndex] = importSource;
|
||||
} else {
|
||||
mergedSources.push(importSource);
|
||||
}
|
||||
}
|
||||
|
||||
adminConfig.EmbyConfig = {
|
||||
...adminConfig.EmbyConfig,
|
||||
Sources: mergedSources,
|
||||
};
|
||||
} else {
|
||||
// 旧格式:直接覆盖
|
||||
adminConfig.EmbyConfig = {
|
||||
...adminConfig.EmbyConfig,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
|
||||
// 更新内存缓存
|
||||
await setCachedConfig(adminConfig);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '导入成功',
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '导入失败: ' + (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
116
src/app/api/admin/emby/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { EmbyClient } from '@/lib/emby.client';
|
||||
import { clearEmbyCache } from '@/lib/emby-cache';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* POST /api/admin/emby
|
||||
* Emby 配置管理接口
|
||||
* - test: 测试 Emby 连接
|
||||
* - clearCache: 清除 Emby 缓存
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{ error: '不支持本地存储进行管理员配置' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, ServerURL, ApiKey, Username, Password } = body;
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
// 获取配置
|
||||
const adminConfig = await getConfig();
|
||||
|
||||
// 权限检查
|
||||
if (username !== process.env.USERNAME) {
|
||||
const userInfo = await db.getUserInfoV2(username);
|
||||
if (!userInfo || userInfo.role !== 'admin' || userInfo.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'test') {
|
||||
// 测试连接
|
||||
if (!ServerURL) {
|
||||
return NextResponse.json({ error: '请填写 Emby 服务器地址' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!ApiKey && !Username) {
|
||||
return NextResponse.json(
|
||||
{ error: '请填写 API Key 或用户名' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const testConfig = {
|
||||
ServerURL,
|
||||
ApiKey,
|
||||
Username,
|
||||
Password,
|
||||
};
|
||||
|
||||
const client = new EmbyClient(testConfig);
|
||||
|
||||
// 如果使用用户名密码,先认证
|
||||
if (!ApiKey && Username) {
|
||||
try {
|
||||
await client.authenticate(Username, Password || '');
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Emby 认证失败: ' + (error as Error).message },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
const isConnected = await client.checkConnectivity();
|
||||
if (!isConnected) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Emby 连接失败,请检查服务器地址和认证信息' },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Emby 连接测试成功',
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'clearCache') {
|
||||
// 清除缓存
|
||||
const result = clearEmbyCache();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `已清除 ${result.cleared} 条 Emby 缓存`,
|
||||
cleared: result.cleared,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: '不支持的操作' }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('Emby 配置保存失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Emby 配置保存失败: ' + (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, key, name, url, ua, epg } = body;
|
||||
const { action, key, name, url, ua, epg, proxyMode } = body;
|
||||
|
||||
if (!config) {
|
||||
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
|
||||
@@ -153,6 +153,18 @@ export async function POST(request: NextRequest) {
|
||||
config.LiveConfig = sortedLiveConfig;
|
||||
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:
|
||||
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';
|
||||
|
||||
/**
|
||||
* 清理字符串中的 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
|
||||
* 保存 OpenList 配置
|
||||
@@ -26,7 +45,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, Enabled, URL, Username, Password, RootPath, OfflineDownloadPath, ScanInterval } = body;
|
||||
const { action, Enabled, URL, Username, Password, RootPaths, OfflineDownloadPath, ScanInterval, ScanMode, DisableVideoPreview } = body;
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
@@ -53,11 +72,13 @@ export async function POST(request: NextRequest) {
|
||||
URL: URL || '',
|
||||
Username: Username || '',
|
||||
Password: Password || '',
|
||||
RootPath: RootPath || '/',
|
||||
RootPaths: RootPaths || ['/'],
|
||||
OfflineDownloadPath: OfflineDownloadPath || '/',
|
||||
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
||||
ScanInterval: 0,
|
||||
ScanMode: ScanMode || 'hybrid',
|
||||
DisableVideoPreview: DisableVideoPreview || false,
|
||||
};
|
||||
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
@@ -76,8 +97,19 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 验证 RootPaths
|
||||
if (!Array.isArray(RootPaths) || RootPaths.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: '请至少提供一个根目录' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 清理 RootPaths 中的 BOM 和不可见字符
|
||||
const cleanedRootPaths = RootPaths.map(cleanPath);
|
||||
|
||||
// 验证扫描间隔
|
||||
let scanInterval = parseInt(ScanInterval) || 0;
|
||||
const scanInterval = parseInt(ScanInterval) || 0;
|
||||
if (scanInterval > 0 && scanInterval < 60) {
|
||||
return NextResponse.json(
|
||||
{ error: '定时扫描间隔最低为 60 分钟' },
|
||||
@@ -103,11 +135,13 @@ export async function POST(request: NextRequest) {
|
||||
URL,
|
||||
Username,
|
||||
Password,
|
||||
RootPath: RootPath || '/',
|
||||
RootPaths: cleanedRootPaths,
|
||||
OfflineDownloadPath: OfflineDownloadPath || '/',
|
||||
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
||||
ScanInterval: scanInterval,
|
||||
ScanMode: ScanMode || 'hybrid',
|
||||
DisableVideoPreview: DisableVideoPreview || false,
|
||||
};
|
||||
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
|
||||
51
src/app/api/admin/reload/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { clearConfigCache } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
if (username !== process.env.USERNAME) {
|
||||
return NextResponse.json({ error: '仅支持站长重载配置' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await clearConfigCache();
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '重载配置失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -43,10 +43,17 @@ export async function POST(request: NextRequest) {
|
||||
DanmakuApiToken,
|
||||
TMDBApiKey,
|
||||
TMDBProxy,
|
||||
TMDBReverseProxy,
|
||||
BannerDataSource,
|
||||
RecommendationDataSource,
|
||||
PansouApiUrl,
|
||||
PansouUsername,
|
||||
PansouPassword,
|
||||
PansouKeywordBlocklist,
|
||||
MagnetProxy,
|
||||
MagnetMikanReverseProxy,
|
||||
MagnetDmhyReverseProxy,
|
||||
MagnetAcgripReverseProxy,
|
||||
EnableComments,
|
||||
CustomAdFilterCode,
|
||||
CustomAdFilterVersion,
|
||||
@@ -81,10 +88,17 @@ export async function POST(request: NextRequest) {
|
||||
DanmakuApiToken: string;
|
||||
TMDBApiKey?: string;
|
||||
TMDBProxy?: string;
|
||||
TMDBReverseProxy?: string;
|
||||
BannerDataSource?: string;
|
||||
RecommendationDataSource?: string;
|
||||
PansouApiUrl?: string;
|
||||
PansouUsername?: string;
|
||||
PansouPassword?: string;
|
||||
PansouKeywordBlocklist?: string;
|
||||
MagnetProxy?: string;
|
||||
MagnetMikanReverseProxy?: string;
|
||||
MagnetDmhyReverseProxy?: string;
|
||||
MagnetAcgripReverseProxy?: string;
|
||||
EnableComments: boolean;
|
||||
CustomAdFilterCode?: string;
|
||||
CustomAdFilterVersion?: number;
|
||||
@@ -122,7 +136,14 @@ export async function POST(request: NextRequest) {
|
||||
typeof DanmakuApiToken !== 'string' ||
|
||||
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
|
||||
(TMDBProxy !== undefined && typeof TMDBProxy !== 'string') ||
|
||||
(TMDBReverseProxy !== undefined && typeof TMDBReverseProxy !== 'string') ||
|
||||
(BannerDataSource !== undefined && typeof BannerDataSource !== 'string') ||
|
||||
(RecommendationDataSource !== undefined && typeof RecommendationDataSource !== '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' ||
|
||||
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
|
||||
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
|
||||
@@ -172,10 +193,17 @@ export async function POST(request: NextRequest) {
|
||||
DanmakuApiToken,
|
||||
TMDBApiKey,
|
||||
TMDBProxy,
|
||||
TMDBReverseProxy,
|
||||
BannerDataSource,
|
||||
RecommendationDataSource,
|
||||
PansouApiUrl,
|
||||
PansouUsername,
|
||||
PansouPassword,
|
||||
PansouKeywordBlocklist,
|
||||
MagnetProxy,
|
||||
MagnetMikanReverseProxy,
|
||||
MagnetDmhyReverseProxy,
|
||||
MagnetAcgripReverseProxy,
|
||||
EnableComments,
|
||||
CustomAdFilterCode,
|
||||
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';
|
||||
|
||||
// 支持的操作类型
|
||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete' | 'toggle_proxy_mode';
|
||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete' | 'toggle_proxy_mode' | 'update_weight';
|
||||
|
||||
interface BaseBody {
|
||||
action?: Action;
|
||||
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
||||
const username = authInfo.username;
|
||||
|
||||
// 基础校验
|
||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete', 'toggle_proxy_mode'];
|
||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete', 'toggle_proxy_mode', 'update_weight'];
|
||||
if (!username || !action || !ACTIONS.includes(action)) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
@@ -65,10 +65,16 @@ export async function POST(request: NextRequest) {
|
||||
if (!key || !name || !api) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
// 禁止添加 openlist 保留关键字
|
||||
if (key === 'openlist') {
|
||||
// 禁止添加保留关键字
|
||||
if (key === 'openlist' || key === 'xiaoya') {
|
||||
return NextResponse.json(
|
||||
{ error: 'openlist 是保留关键字,不能作为视频源 key' },
|
||||
{ error: `${key} 是保留关键字,不能作为视频源 key` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (key.startsWith('emby')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'emby 开头的 key 是保留关键字,不能作为视频源 key' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -167,10 +173,17 @@ export async function POST(request: NextRequest) {
|
||||
if (!Array.isArray(keys) || keys.length === 0) {
|
||||
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
||||
}
|
||||
// 过滤掉 from=config 的源,但不报错
|
||||
const keysToDelete = keys.filter(key => {
|
||||
// 过滤掉 from=config 的源,记录跳过的数量
|
||||
const keysToDelete: string[] = [];
|
||||
const skippedKeys: string[] = [];
|
||||
|
||||
keys.forEach(key => {
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
return entry && entry.from !== 'config';
|
||||
if (entry && entry.from === 'config') {
|
||||
skippedKeys.push(key);
|
||||
} else if (entry) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
// 批量删除
|
||||
@@ -199,6 +212,12 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 保存批量删除的统计信息,稍后返回
|
||||
(body as any)._batchDeleteResult = {
|
||||
deleted: keysToDelete.length,
|
||||
skipped: skippedKeys.length,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'sort': {
|
||||
@@ -235,6 +254,20 @@ export async function POST(request: NextRequest) {
|
||||
entry.proxyMode = !entry.proxyMode;
|
||||
break;
|
||||
}
|
||||
case 'update_weight': {
|
||||
const { key, weight } = body as { key?: string; weight?: number };
|
||||
if (!key)
|
||||
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||
if (weight === undefined || weight === null)
|
||||
return NextResponse.json({ error: '缺少 weight 参数' }, { status: 400 });
|
||||
if (typeof weight !== 'number' || weight < 0 || weight > 100)
|
||||
return NextResponse.json({ error: '权重必须是 0-100 之间的数字' }, { status: 400 });
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
if (!entry)
|
||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||
entry.weight = weight;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
@@ -251,8 +284,17 @@ export async function POST(request: NextRequest) {
|
||||
// 不影响主流程,继续执行
|
||||
}
|
||||
|
||||
// 构建响应数据
|
||||
const responseData: Record<string, any> = { ok: true };
|
||||
|
||||
// 如果是批量删除操作,包含统计信息
|
||||
if (action === 'batch_delete' && (body as any)._batchDeleteResult) {
|
||||
responseData.deleted = (body as any)._batchDeleteResult.deleted;
|
||||
responseData.skipped = (body as any)._batchDeleteResult.skipped;
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true },
|
||||
responseData,
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
|
||||
@@ -36,6 +36,9 @@ export async function POST(request: NextRequest) {
|
||||
cacheMinutes,
|
||||
loginBackgroundImage,
|
||||
registerBackgroundImage,
|
||||
progressThumbType,
|
||||
progressThumbPresetId,
|
||||
progressThumbCustomUrl,
|
||||
} = body as {
|
||||
enableBuiltInTheme: boolean;
|
||||
builtInTheme: string;
|
||||
@@ -44,6 +47,9 @@ export async function POST(request: NextRequest) {
|
||||
cacheMinutes: number;
|
||||
loginBackgroundImage?: 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,
|
||||
loginBackgroundImage: loginBackgroundImage?.trim() || undefined,
|
||||
registerBackgroundImage: registerBackgroundImage?.trim() || undefined,
|
||||
progressThumbType: progressThumbType || 'default',
|
||||
progressThumbPresetId: progressThumbPresetId?.trim() || undefined,
|
||||
progressThumbCustomUrl: progressThumbCustomUrl?.trim() || undefined,
|
||||
};
|
||||
|
||||
// 写入数据库
|
||||
|
||||
@@ -454,21 +454,9 @@ export async function POST(request: NextRequest) {
|
||||
// 权限检查:站长可批量配置所有人的用户组,管理员只能批量配置普通用户
|
||||
if (operatorRole !== 'owner') {
|
||||
for (const targetUsername of usernames) {
|
||||
// 先从配置中查找
|
||||
let targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
|
||||
// 如果配置中没有,从V2存储中查找
|
||||
if (!targetUser) {
|
||||
const userV2 = await db.getUserInfoV2(targetUsername);
|
||||
if (userV2) {
|
||||
targetUser = {
|
||||
username: targetUsername,
|
||||
role: userV2.role,
|
||||
banned: userV2.banned,
|
||||
tags: userV2.tags,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (targetUser && targetUser.role === 'admin' && targetUsername !== username) {
|
||||
// 从V2存储中查找用户
|
||||
const userV2 = await db.getUserInfoV2(targetUsername);
|
||||
if (userV2 && userV2.role === 'admin' && targetUsername !== username) {
|
||||
return NextResponse.json({ error: `管理员无法操作其他管理员 ${targetUsername}` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
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';
|
||||
@@ -24,9 +23,6 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 获取配置
|
||||
const adminConfig = await getConfig();
|
||||
|
||||
// 判定操作者角色
|
||||
let operatorRole: 'owner' | 'admin' | 'user' = 'user';
|
||||
if (authInfo.username === process.env.USERNAME) {
|
||||
|
||||
131
src/app/api/admin/web-live/route.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
const username = authInfo?.username;
|
||||
const config = await getConfig();
|
||||
if (username !== process.env.USERNAME) {
|
||||
const userInfo = await db.getUserInfoV2(username || '');
|
||||
if (!userInfo || userInfo.role !== 'admin' || userInfo.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action } = body;
|
||||
|
||||
if (!config) {
|
||||
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!config.WebLiveConfig) {
|
||||
config.WebLiveConfig = [];
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'toggleEnabled': {
|
||||
const { enabled } = body;
|
||||
config.WebLiveEnabled = enabled;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'add': {
|
||||
const { name, platform, roomId } = body;
|
||||
if (!name || !platform || !roomId) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const key = `web_${Date.now()}`;
|
||||
if (config.WebLiveConfig.some((s) => s.key === key)) {
|
||||
return NextResponse.json({ error: 'Key已存在' }, { status: 400 });
|
||||
}
|
||||
|
||||
config.WebLiveConfig.push({
|
||||
key,
|
||||
name,
|
||||
platform,
|
||||
roomId,
|
||||
from: 'custom',
|
||||
disabled: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const { key } = body;
|
||||
const source = config.WebLiveConfig.find((s) => s.key === key);
|
||||
if (!source) {
|
||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||
}
|
||||
if (source.from === 'config') {
|
||||
return NextResponse.json({ error: '无法删除配置文件中的源' }, { status: 400 });
|
||||
}
|
||||
config.WebLiveConfig = config.WebLiveConfig.filter((s) => s.key !== key);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'enable': {
|
||||
const { key } = body;
|
||||
const source = config.WebLiveConfig.find((s) => s.key === key);
|
||||
if (!source) {
|
||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||
}
|
||||
source.disabled = false;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'disable': {
|
||||
const { key } = body;
|
||||
const source = config.WebLiveConfig.find((s) => s.key === key);
|
||||
if (!source) {
|
||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||
}
|
||||
source.disabled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'edit': {
|
||||
const { key, name, platform, roomId } = body;
|
||||
const source = config.WebLiveConfig.find((s) => s.key === key);
|
||||
if (!source) {
|
||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||
}
|
||||
if (source.from === 'config') {
|
||||
return NextResponse.json({ error: '无法编辑配置文件中的源' }, { status: 400 });
|
||||
}
|
||||
source.name = name;
|
||||
source.platform = platform;
|
||||
source.roomId = roomId;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sort': {
|
||||
const { keys } = body;
|
||||
if (!Array.isArray(keys)) {
|
||||
return NextResponse.json({ error: '无效的排序数据' }, { status: 400 });
|
||||
}
|
||||
const sortedSources = keys
|
||||
.map((key) => config.WebLiveConfig!.find((s) => s.key === key))
|
||||
.filter((s): s is NonNullable<typeof s> => s !== undefined);
|
||||
config.WebLiveConfig = sortedSources;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.saveAdminConfig(config);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '操作失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/app/api/admin/xiaoya/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { XiaoyaClient } from '@/lib/xiaoya.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* POST /api/admin/xiaoya
|
||||
* 管理小雅配置
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, ...configData } = body;
|
||||
|
||||
if (action === 'test') {
|
||||
// 测试连接
|
||||
try {
|
||||
const client = new XiaoyaClient(
|
||||
configData.ServerURL,
|
||||
configData.Username,
|
||||
configData.Password,
|
||||
configData.Token
|
||||
);
|
||||
|
||||
// 尝试列出根目录
|
||||
await client.listDirectory('/');
|
||||
|
||||
return NextResponse.json({ success: true, message: '连接成功' });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: (error as Error).message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'save') {
|
||||
// 保存配置
|
||||
const config = await getConfig();
|
||||
|
||||
config.XiaoyaConfig = {
|
||||
Enabled: configData.Enabled || false,
|
||||
ServerURL: configData.ServerURL || '',
|
||||
Token: configData.Token,
|
||||
Username: configData.Username,
|
||||
Password: configData.Password,
|
||||
DisableVideoPreview: configData.DisableVideoPreview || false,
|
||||
};
|
||||
|
||||
await db.saveAdminConfig(config);
|
||||
|
||||
return NextResponse.json({ success: true, message: '保存成功' });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: '无效的操作' }, { status: 400 });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import {
|
||||
orchestrateDataSources,
|
||||
VideoContext,
|
||||
} from '@/lib/ai-orchestrator';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
@@ -34,8 +34,9 @@ async function streamOpenAIChat(
|
||||
model: string;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
}
|
||||
): Promise<ReadableStream> {
|
||||
},
|
||||
enableStreaming = true
|
||||
): Promise<ReadableStream | Response> {
|
||||
const response = await fetch(`${config.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -47,7 +48,7 @@ async function streamOpenAIChat(
|
||||
messages,
|
||||
temperature: config.temperature,
|
||||
max_tokens: config.maxTokens,
|
||||
stream: true,
|
||||
stream: enableStreaming,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -57,46 +58,7 @@ async function streamOpenAIChat(
|
||||
);
|
||||
}
|
||||
|
||||
return response.body!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude API流式聊天请求
|
||||
*/
|
||||
async function streamClaudeChat(
|
||||
messages: ChatMessage[],
|
||||
systemPrompt: string,
|
||||
config: {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
}
|
||||
): Promise<ReadableStream> {
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': config.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
max_tokens: config.maxTokens,
|
||||
temperature: config.temperature,
|
||||
system: systemPrompt,
|
||||
messages: messages,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Claude API error: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
return response.body!;
|
||||
return enableStreaming ? response.body! : response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,13 +73,25 @@ function transformToSSE(
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
let buffer = ''; // 缓冲区,用于保存不完整的行
|
||||
let contentBuffer = ''; // 累积的内容,用于处理跨chunk的thinking标签
|
||||
let inThinkingBlock = false; // 是否在thinking块内
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
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) {
|
||||
if (line.startsWith('data: ')) {
|
||||
@@ -151,9 +125,32 @@ function transformToSSE(
|
||||
}
|
||||
|
||||
if (text) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`data: ${JSON.stringify({ text })}\n\n`)
|
||||
);
|
||||
// 累积内容并处理thinking标签
|
||||
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) {
|
||||
// 只在非空数据解析失败时打印错误
|
||||
@@ -164,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) {
|
||||
console.error('Stream error:', error);
|
||||
controller.error(error);
|
||||
@@ -239,6 +269,7 @@ export async function POST(request: NextRequest) {
|
||||
// TMDB 配置
|
||||
tmdbApiKey: adminConfig.SiteConfig.TMDBApiKey,
|
||||
tmdbProxy: adminConfig.SiteConfig.TMDBProxy,
|
||||
tmdbReverseProxy: adminConfig.SiteConfig.TMDBReverseProxy,
|
||||
// 决策模型配置(固定使用自定义provider,复用主模型的API配置)
|
||||
enableDecisionModel: aiConfig.EnableDecisionModel,
|
||||
decisionProvider: 'custom',
|
||||
@@ -265,6 +296,7 @@ export async function POST(request: NextRequest) {
|
||||
// 6. 调用自定义API
|
||||
const temperature = aiConfig.Temperature ?? 0.7;
|
||||
const maxTokens = aiConfig.MaxTokens ?? 1000;
|
||||
const enableStreaming = aiConfig.EnableStreaming !== false; // 默认启用流式响应
|
||||
|
||||
if (!aiConfig.CustomApiKey || !aiConfig.CustomBaseURL) {
|
||||
return NextResponse.json(
|
||||
@@ -273,24 +305,37 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const stream = await streamOpenAIChat(messages, {
|
||||
const result = await streamOpenAIChat(messages, {
|
||||
apiKey: aiConfig.CustomApiKey,
|
||||
baseURL: aiConfig.CustomBaseURL,
|
||||
model: aiConfig.CustomModel || 'gpt-3.5-turbo',
|
||||
temperature,
|
||||
maxTokens,
|
||||
});
|
||||
}, enableStreaming);
|
||||
|
||||
// 7. 转换为SSE格式并返回
|
||||
const sseStream = transformToSSE(stream, 'openai');
|
||||
// 7. 根据是否启用流式响应返回不同格式
|
||||
if (enableStreaming) {
|
||||
// 流式响应:转换为SSE格式并返回
|
||||
const sseStream = transformToSSE(result as ReadableStream, 'openai');
|
||||
|
||||
return new NextResponse(sseStream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
return new NextResponse(sseStream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 非流式响应:等待完整响应后返回JSON
|
||||
const response = result as Response;
|
||||
const data = await response.json();
|
||||
let content = data.choices?.[0]?.message?.content || '';
|
||||
|
||||
// 移除thinking标签内容
|
||||
content = content.replace(/<think>[\s\S]*?<\/think>/g, '');
|
||||
|
||||
return NextResponse.json({ content });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ AI聊天API错误:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
89
src/app/api/auth/devices/route.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import {
|
||||
getUserDevices,
|
||||
revokeAllRefreshTokens,
|
||||
revokeRefreshToken,
|
||||
} from '@/lib/refresh-token';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 获取所有设备
|
||||
export async function GET(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const devices = await getUserDevices(authInfo.username);
|
||||
|
||||
// 标记当前设备
|
||||
const devicesWithCurrent = devices.map((device) => ({
|
||||
...device,
|
||||
isCurrent: device.tokenId === authInfo.tokenId,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ devices: devicesWithCurrent });
|
||||
} catch (error) {
|
||||
console.error('Failed to get devices:', error);
|
||||
return NextResponse.json({ error: 'Server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 撤销指定设备
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { tokenId } = await request.json();
|
||||
|
||||
if (!tokenId) {
|
||||
return NextResponse.json({ error: 'Token ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await revokeRefreshToken(authInfo.username, tokenId);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke device:', error);
|
||||
return NextResponse.json({ error: 'Server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 登出所有设备
|
||||
export async function POST(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await revokeAllRefreshTokens(authInfo.username);
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
|
||||
// 清除当前设备的 Cookie
|
||||
response.cookies.set('auth', '', {
|
||||
path: '/',
|
||||
expires: new Date(0),
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke all devices:', error);
|
||||
return NextResponse.json({ error: 'Server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
generateRefreshToken,
|
||||
generateTokenId,
|
||||
storeRefreshToken,
|
||||
TOKEN_CONFIG,
|
||||
} from '@/lib/refresh-token';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -30,18 +36,76 @@ async function generateSignature(
|
||||
.join('');
|
||||
}
|
||||
|
||||
// 获取设备信息
|
||||
function getDeviceInfo(userAgent: string): string {
|
||||
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('android')) return 'Android Mobile';
|
||||
if (ua.includes('iphone')) return 'iPhone';
|
||||
return 'Mobile Device';
|
||||
}
|
||||
|
||||
if (ua.includes('tablet') || ua.includes('ipad')) {
|
||||
return 'Tablet';
|
||||
}
|
||||
|
||||
if (ua.includes('windows')) return 'Windows PC';
|
||||
if (ua.includes('mac')) return 'Mac';
|
||||
if (ua.includes('linux')) return 'Linux';
|
||||
|
||||
return 'Unknown Device';
|
||||
}
|
||||
|
||||
// 生成认证Cookie
|
||||
async function generateAuthCookie(
|
||||
username: string,
|
||||
role: 'owner' | 'admin' | 'user'
|
||||
role: 'owner' | 'admin' | 'user',
|
||||
deviceInfo: string
|
||||
): Promise<string> {
|
||||
const authData: any = { role };
|
||||
|
||||
if (username && process.env.PASSWORD) {
|
||||
authData.username = username;
|
||||
const signature = await generateSignature(username, process.env.PASSWORD);
|
||||
authData.signature = signature;
|
||||
authData.timestamp = Date.now();
|
||||
|
||||
// 生成签名(包含 username, role, timestamp)
|
||||
const dataToSign = JSON.stringify({
|
||||
username: authData.username,
|
||||
role: authData.role,
|
||||
timestamp: authData.timestamp
|
||||
});
|
||||
const signature = await generateSignature(dataToSign, process.env.PASSWORD);
|
||||
authData.signature = signature;
|
||||
|
||||
// 生成双 Token
|
||||
const tokenId = generateTokenId();
|
||||
const refreshToken = generateRefreshToken();
|
||||
const now = Date.now();
|
||||
const refreshExpires = now + TOKEN_CONFIG.REFRESH_TOKEN_AGE;
|
||||
|
||||
authData.tokenId = tokenId;
|
||||
authData.refreshToken = refreshToken;
|
||||
authData.refreshExpires = refreshExpires;
|
||||
|
||||
// 存储 Refresh Token
|
||||
await storeRefreshToken(username, tokenId, {
|
||||
token: refreshToken,
|
||||
deviceInfo,
|
||||
createdAt: now,
|
||||
expiresAt: refreshExpires,
|
||||
lastUsed: now,
|
||||
});
|
||||
}
|
||||
|
||||
return encodeURIComponent(JSON.stringify(authData));
|
||||
@@ -148,12 +212,11 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// 检查用户是否已存在(通过OIDC sub查找)
|
||||
// 优先使用新版本查找
|
||||
let username = await db.getUserByOidcSub(oidcSub);
|
||||
const username = await db.getUserByOidcSub(oidcSub);
|
||||
let userRole: 'owner' | 'admin' | 'user' = 'user';
|
||||
|
||||
if (username) {
|
||||
// 从新版本获取用户信息
|
||||
// 获取用户信息
|
||||
const userInfoV2 = await db.getUserInfoV2(username);
|
||||
if (userInfoV2) {
|
||||
userRole = userInfoV2.role;
|
||||
@@ -164,27 +227,15 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 回退到配置中查找
|
||||
const existingUser = config.UserConfig.Users.find((u: any) => u.oidcSub === oidcSub);
|
||||
if (existingUser) {
|
||||
username = existingUser.username;
|
||||
userRole = existingUser.role || 'user';
|
||||
// 检查用户是否被封禁
|
||||
if (existingUser.banned) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/login?error=' + encodeURIComponent('用户被封禁'), origin)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (username) {
|
||||
// 用户已存在,直接登录
|
||||
const response = NextResponse.redirect(new URL('/', origin));
|
||||
const cookieValue = await generateAuthCookie(username, userRole);
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7);
|
||||
const userAgent = request.headers.get('user-agent') || 'Unknown';
|
||||
const deviceInfo = getDeviceInfo(userAgent);
|
||||
const cookieValue = await generateAuthCookie(username, userRole, deviceInfo);
|
||||
const expires = new Date(Date.now() + TOKEN_CONFIG.REFRESH_TOKEN_AGE);
|
||||
|
||||
response.cookies.set('auth', cookieValue, {
|
||||
path: '/',
|
||||
@@ -221,7 +272,6 @@ export async function GET(request: NextRequest) {
|
||||
response.cookies.set('oidc_session', JSON.stringify(oidcSession), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 600, // 10分钟
|
||||
});
|
||||
|
||||
@@ -3,6 +3,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
generateRefreshToken,
|
||||
generateTokenId,
|
||||
storeRefreshToken,
|
||||
TOKEN_CONFIG,
|
||||
} from '@/lib/refresh-token';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -30,18 +36,76 @@ async function generateSignature(
|
||||
.join('');
|
||||
}
|
||||
|
||||
// 获取设备信息
|
||||
function getDeviceInfo(userAgent: string): string {
|
||||
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('android')) return 'Android Mobile';
|
||||
if (ua.includes('iphone')) return 'iPhone';
|
||||
return 'Mobile Device';
|
||||
}
|
||||
|
||||
if (ua.includes('tablet') || ua.includes('ipad')) {
|
||||
return 'Tablet';
|
||||
}
|
||||
|
||||
if (ua.includes('windows')) return 'Windows PC';
|
||||
if (ua.includes('mac')) return 'Mac';
|
||||
if (ua.includes('linux')) return 'Linux';
|
||||
|
||||
return 'Unknown Device';
|
||||
}
|
||||
|
||||
// 生成认证Cookie
|
||||
async function generateAuthCookie(
|
||||
username: string,
|
||||
role: 'owner' | 'admin' | 'user'
|
||||
role: 'owner' | 'admin' | 'user',
|
||||
deviceInfo: string
|
||||
): Promise<string> {
|
||||
const authData: any = { role };
|
||||
|
||||
if (username && process.env.PASSWORD) {
|
||||
authData.username = username;
|
||||
const signature = await generateSignature(username, process.env.PASSWORD);
|
||||
authData.signature = signature;
|
||||
authData.timestamp = Date.now();
|
||||
|
||||
// 生成签名(包含 username, role, timestamp)
|
||||
const dataToSign = JSON.stringify({
|
||||
username: authData.username,
|
||||
role: authData.role,
|
||||
timestamp: authData.timestamp
|
||||
});
|
||||
const signature = await generateSignature(dataToSign, process.env.PASSWORD);
|
||||
authData.signature = signature;
|
||||
|
||||
// 生成双 Token
|
||||
const tokenId = generateTokenId();
|
||||
const refreshToken = generateRefreshToken();
|
||||
const now = Date.now();
|
||||
const refreshExpires = now + TOKEN_CONFIG.REFRESH_TOKEN_AGE;
|
||||
|
||||
authData.tokenId = tokenId;
|
||||
authData.refreshToken = refreshToken;
|
||||
authData.refreshExpires = refreshExpires;
|
||||
|
||||
// 存储 Refresh Token
|
||||
await storeRefreshToken(username, tokenId, {
|
||||
token: refreshToken,
|
||||
deviceInfo,
|
||||
createdAt: now,
|
||||
expiresAt: refreshExpires,
|
||||
lastUsed: now,
|
||||
});
|
||||
}
|
||||
|
||||
return encodeURIComponent(JSON.stringify(authData));
|
||||
@@ -122,8 +186,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在(优先使用新版本)
|
||||
let userExists = await db.checkUserExistV2(username);
|
||||
// 检查用户名是否已存在
|
||||
const userExists = await db.checkUserExistV2(username);
|
||||
if (userExists) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户名已存在' },
|
||||
@@ -131,24 +195,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 检查配置中是否已存在
|
||||
const existingUser = config.UserConfig.Users.find((u) => u.username === username);
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户名已存在' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查OIDC sub是否已被使用(优先使用新版本)
|
||||
let existingOIDCUsername = await db.getUserByOidcSub(oidcSession.sub);
|
||||
if (!existingOIDCUsername) {
|
||||
// 回退到配置中查找
|
||||
const existingOIDCUser = config.UserConfig.Users.find((u: any) => u.oidcSub === oidcSession.sub);
|
||||
if (existingOIDCUser) {
|
||||
existingOIDCUsername = existingOIDCUser.username;
|
||||
}
|
||||
}
|
||||
// 检查OIDC sub是否已被使用
|
||||
const existingOIDCUsername = await db.getUserByOidcSub(oidcSession.sub);
|
||||
if (existingOIDCUsername) {
|
||||
return NextResponse.json(
|
||||
{ error: '该OIDC账号已被注册' },
|
||||
@@ -171,9 +219,10 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 设置认证cookie
|
||||
const response = NextResponse.json({ ok: true, message: '注册成功' });
|
||||
const cookieValue = await generateAuthCookie(username, 'user');
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7);
|
||||
const userAgent = request.headers.get('user-agent') || 'Unknown';
|
||||
const deviceInfo = getDeviceInfo(userAgent);
|
||||
const cookieValue = await generateAuthCookie(username, 'user', deviceInfo);
|
||||
const expires = new Date(Date.now() + TOKEN_CONFIG.REFRESH_TOKEN_AGE);
|
||||
|
||||
response.cookies.set('auth', cookieValue, {
|
||||
path: '/',
|
||||
|
||||
@@ -47,7 +47,6 @@ export async function GET(request: NextRequest) {
|
||||
response.cookies.set('oidc_state', state, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 600, // 10分钟
|
||||
});
|
||||
|
||||
108
src/app/api/auth/refresh/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/* eslint-disable no-console */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie, parseAuthInfo } from '@/lib/auth';
|
||||
import { refreshAccessToken } from '@/lib/middleware-auth';
|
||||
import { TOKEN_CONFIG } from '@/lib/refresh-token';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const STORAGE_TYPE =
|
||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||
| 'localstorage'
|
||||
| 'redis'
|
||||
| 'upstash'
|
||||
| 'kvrocks'
|
||||
| undefined) || 'localstorage';
|
||||
|
||||
function buildRefreshResponse(authToken?: string | null) {
|
||||
const body: Record<string, unknown> = { ok: true };
|
||||
|
||||
if (authToken) {
|
||||
body.token = authToken;
|
||||
const authInfo = parseAuthInfo(authToken);
|
||||
if (authInfo) {
|
||||
const { password, ...rest } = authInfo;
|
||||
body.auth = rest;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(body);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
|
||||
if (!authInfo) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
if (!authInfo.password || authInfo.password !== process.env.PASSWORD) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const authCookie = request.cookies.get('auth');
|
||||
if (!authCookie?.value) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = buildRefreshResponse(authCookie.value);
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 60);
|
||||
response.cookies.set('auth', authCookie.value, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
if (
|
||||
!authInfo.username ||
|
||||
!authInfo.role ||
|
||||
!authInfo.timestamp ||
|
||||
!authInfo.tokenId ||
|
||||
!authInfo.refreshToken ||
|
||||
!authInfo.refreshExpires
|
||||
) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// 只检查 Refresh Token 是否过期
|
||||
if (now >= authInfo.refreshExpires) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Refresh token expired' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 只要 Refresh Token 有效,就允许刷新(即使 Access Token 已过期)
|
||||
|
||||
const newAuthData = await refreshAccessToken(
|
||||
authInfo.username,
|
||||
authInfo.role,
|
||||
authInfo.tokenId,
|
||||
authInfo.refreshToken,
|
||||
authInfo.refreshExpires
|
||||
);
|
||||
|
||||
if (!newAuthData) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = buildRefreshResponse(newAuthData);
|
||||
const expires = new Date(authInfo.refreshExpires);
|
||||
response.cookies.set('auth', newAuthData, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { getUserDevices, revokeRefreshToken } from '@/lib/refresh-token';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -48,6 +49,25 @@ export async function POST(request: NextRequest) {
|
||||
// 修改密码(只更新V2存储)
|
||||
await db.changePasswordV2(username, newPassword);
|
||||
|
||||
// 撤销除当前设备外的所有 Refresh Token
|
||||
try {
|
||||
const currentTokenId = authInfo.tokenId;
|
||||
const devices = await getUserDevices(username);
|
||||
|
||||
// 撤销所有非当前设备的 token
|
||||
for (const device of devices) {
|
||||
if (device.tokenId !== currentTokenId) {
|
||||
await revokeRefreshToken(username, device.tokenId);
|
||||
console.log(`Revoked token ${device.tokenId} for ${username} after password change`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Password changed for ${username}, revoked ${devices.length - 1} other devices`);
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke other devices after password change:', error);
|
||||
// 不影响密码修改的成功,只记录错误
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error);
|
||||
|
||||
@@ -4,13 +4,13 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { OpenListClient } from '@/lib/openlist.client';
|
||||
import {
|
||||
getCachedMetaInfo,
|
||||
MetaInfo,
|
||||
setCachedMetaInfo,
|
||||
} from '@/lib/openlist-cache';
|
||||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||
import { yellowWords } from '@/lib/yellow';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const apiUrl = searchParams.get('api');
|
||||
const yellowFilter = searchParams.get('yellowFilter') === 'true';
|
||||
|
||||
if (!apiUrl) {
|
||||
return NextResponse.json(
|
||||
@@ -97,18 +98,7 @@ export async function GET(request: NextRequest) {
|
||||
console.log('CMS 代理 origin:', origin);
|
||||
|
||||
// 处理返回数据,替换播放链接为代理链接
|
||||
const processedData = processPlayUrls(data, origin);
|
||||
|
||||
// 输出处理后的第一个视频的播放信息(用于调试)
|
||||
if (processedData.list && processedData.list.length > 0) {
|
||||
const firstItem = processedData.list[0];
|
||||
console.log('第一个视频处理后的播放信息:', {
|
||||
vod_name: firstItem.vod_name,
|
||||
vod_play_from: firstItem.vod_play_from,
|
||||
vod_play_url_length: firstItem.vod_play_url?.length || 0,
|
||||
vod_play_url_preview: firstItem.vod_play_url?.substring(0, 200) || '',
|
||||
});
|
||||
}
|
||||
const processedData = processCmsResponse(data, origin, yellowFilter);
|
||||
|
||||
return NextResponse.json(processedData, {
|
||||
headers: {
|
||||
@@ -142,7 +132,7 @@ export async function GET(request: NextRequest) {
|
||||
/**
|
||||
* 处理 CMS API 返回数据,将播放链接替换为代理链接
|
||||
*/
|
||||
function processPlayUrls(data: any, proxyOrigin: string): any {
|
||||
function processCmsResponse(data: any, proxyOrigin: string, yellowFilter: boolean): any {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return data;
|
||||
}
|
||||
@@ -150,6 +140,28 @@ function processPlayUrls(data: any, proxyOrigin: string): any {
|
||||
// 深拷贝数据,避免修改原始对象
|
||||
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
|
||||
const proxyToken = process.env.NEXT_PUBLIC_PROXY_M3U8_TOKEN || '';
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
@@ -186,6 +198,19 @@ function processPlayUrls(data: any, proxyOrigin: string): any {
|
||||
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#...
|
||||
@@ -271,22 +296,15 @@ async function handleOpenListProxy(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const rootPath = openListConfig.RootPath || '/';
|
||||
const client = new OpenListClient(
|
||||
openListConfig.URL,
|
||||
openListConfig.Username,
|
||||
openListConfig.Password
|
||||
);
|
||||
|
||||
// 读取 metainfo (从数据库或缓存)
|
||||
let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath);
|
||||
let metaInfo: MetaInfo | null = getCachedMetaInfo();
|
||||
|
||||
if (!metaInfo) {
|
||||
try {
|
||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||
if (metainfoJson) {
|
||||
metaInfo = JSON.parse(metainfoJson) as MetaInfo;
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
setCachedMetaInfo(metaInfo);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
@@ -307,7 +325,7 @@ async function handleOpenListProxy(request: NextRequest) {
|
||||
if (wd) {
|
||||
const results = Object.entries(metaInfo.folders)
|
||||
.filter(
|
||||
([key, info]) =>
|
||||
([_key, info]) =>
|
||||
info.folderName.toLowerCase().includes(wd.toLowerCase()) ||
|
||||
info.title.toLowerCase().includes(wd.toLowerCase())
|
||||
)
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { checkAnimeSubscriptions } from '@/lib/anime-subscription';
|
||||
import { getConfig, refineConfig } from '@/lib/config';
|
||||
import { db, getStorage } from '@/lib/db';
|
||||
import { EmailService } from '@/lib/email.service';
|
||||
import { FavoriteUpdate,getBatchFavoriteUpdateEmailTemplate } from '@/lib/email.templates';
|
||||
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
||||
import { refreshLiveChannels } from '@/lib/live';
|
||||
import { startOpenListRefresh } from '@/lib/openlist-refresh';
|
||||
@@ -11,18 +14,71 @@ import { SearchResult } from '@/lib/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// 内存中记录最后执行时间(毫秒时间戳)
|
||||
let lastExecutionTime = 0;
|
||||
const COOLDOWN_MS = 10 * 60 * 1000; // 10分钟冷却时间
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { password: string } }
|
||||
) {
|
||||
console.log(request.url);
|
||||
|
||||
const cronPassword = process.env.CRON_PASSWORD || 'mtvpls';
|
||||
if (params.password !== cronPassword) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查冷却时间
|
||||
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 {
|
||||
console.log('Cron job triggered:', new Date().toISOString());
|
||||
|
||||
cronJob();
|
||||
// 更新最后执行时间
|
||||
lastExecutionTime = now;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Cron job executed successfully',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
// 环境变量控制是否等待定时任务完全结束后再返回响应(默认 false)
|
||||
// 用于防止 Vercel 等平台杀后台进程
|
||||
const waitForCompletion = process.env.CRON_WAIT_FOR_COMPLETION === 'true';
|
||||
|
||||
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) {
|
||||
console.error('Cron job failed:', error);
|
||||
|
||||
@@ -39,10 +95,16 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
async function cronJob() {
|
||||
// 先刷新配置,确保其他任务使用最新配置
|
||||
await refreshConfig();
|
||||
await refreshAllLiveChannels();
|
||||
await refreshOpenList();
|
||||
await refreshRecordAndFavorites();
|
||||
|
||||
// 其余任务并行执行
|
||||
await Promise.all([
|
||||
refreshAllLiveChannels(),
|
||||
refreshOpenList(),
|
||||
refreshRecordAndFavorites(),
|
||||
checkAnimeSubscriptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function refreshAllLiveChannels() {
|
||||
@@ -123,6 +185,19 @@ async function refreshRecordAndFavorites() {
|
||||
if (process.env.USERNAME && !users.includes(process.env.USERNAME)) {
|
||||
users.push(process.env.USERNAME);
|
||||
}
|
||||
|
||||
// 环境变量控制是否跳过特定源(默认为 false,即默认跳过)
|
||||
const includeSpecialSources = process.env.CRON_INCLUDE_SPECIAL_SOURCES === 'true';
|
||||
|
||||
// 检查是否应该跳过该源
|
||||
const shouldSkipSource = (source: string): boolean => {
|
||||
if (includeSpecialSources) {
|
||||
return false; // 如果开启了包含特殊源,则不跳过任何源
|
||||
}
|
||||
// 默认跳过 emby 开头、openlist、xiaoya 和 live 开头的源
|
||||
return source.startsWith('emby') || source === 'openlist' || source === 'xiaoya' || source.startsWith('live');
|
||||
};
|
||||
|
||||
// 函数级缓存:key 为 `${source}+${id}`,值为 Promise<VideoDetail | null>
|
||||
const detailCache = new Map<string, Promise<SearchResult | null>>();
|
||||
|
||||
@@ -135,26 +210,28 @@ async function refreshRecordAndFavorites() {
|
||||
const key = `${source}+${id}`;
|
||||
let promise = detailCache.get(key);
|
||||
if (!promise) {
|
||||
// 立即缓存Promise,避免并发时的竞态条件
|
||||
promise = fetchVideoDetail({
|
||||
source,
|
||||
id,
|
||||
fallbackTitle: fallbackTitle.trim(),
|
||||
})
|
||||
.then((detail) => {
|
||||
// 成功时才缓存结果
|
||||
const successPromise = Promise.resolve(detail);
|
||||
detailCache.set(key, successPromise);
|
||||
return detail;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`获取视频详情失败 (${source}+${id}):`, err);
|
||||
// 失败时从缓存中移除,下次可以重试
|
||||
detailCache.delete(key);
|
||||
return null;
|
||||
});
|
||||
detailCache.set(key, promise);
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
for (const user of users) {
|
||||
// 处理单个用户的函数
|
||||
const processUser = async (user: string) => {
|
||||
console.log(`开始处理用户: ${user}`);
|
||||
const storage = getStorage();
|
||||
|
||||
@@ -172,6 +249,13 @@ async function refreshRecordAndFavorites() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否应该跳过该源
|
||||
if (shouldSkipSource(source)) {
|
||||
console.log(`跳过播放记录 (源被过滤): ${key}`);
|
||||
processedRecords++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const detail = await getDetail(source, id, record.title);
|
||||
if (!detail) {
|
||||
console.warn(`跳过无法获取详情的播放记录: ${key}`);
|
||||
@@ -180,6 +264,14 @@ async function refreshRecordAndFavorites() {
|
||||
|
||||
const episodeCount = detail.episodes?.length || 0;
|
||||
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, {
|
||||
title: detail.title || record.title,
|
||||
source_name: record.source_name,
|
||||
@@ -191,9 +283,10 @@ async function refreshRecordAndFavorites() {
|
||||
total_time: record.total_time,
|
||||
save_time: record.save_time,
|
||||
search_title: record.search_title,
|
||||
new_episodes: updatedNewEpisodes > 0 ? updatedNewEpisodes : undefined,
|
||||
});
|
||||
console.log(
|
||||
`更新播放记录: ${record.title} (${record.total_episodes} -> ${episodeCount})`
|
||||
`更新播放记录: ${record.title} (${record.total_episodes} -> ${episodeCount}, 新增 ${newEpisodesCount} 集)`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,6 +311,7 @@ async function refreshRecordAndFavorites() {
|
||||
const totalFavorites = Object.keys(favorites).length;
|
||||
let processedFavorites = 0;
|
||||
const now = Date.now();
|
||||
const userUpdates: FavoriteUpdate[] = []; // 收集该用户的所有更新
|
||||
|
||||
for (const [key, fav] of Object.entries(favorites)) {
|
||||
try {
|
||||
@@ -227,6 +321,13 @@ async function refreshRecordAndFavorites() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否应该跳过该源
|
||||
if (shouldSkipSource(source)) {
|
||||
console.log(`跳过收藏 (源被过滤): ${key}`);
|
||||
processedFavorites++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const favDetail = await getDetail(source, id, fav.title);
|
||||
if (!favDetail) {
|
||||
console.warn(`跳过无法获取详情的收藏: ${key}`);
|
||||
@@ -267,6 +368,17 @@ async function refreshRecordAndFavorites() {
|
||||
|
||||
await storage.addNotification(user, notification);
|
||||
console.log(`已为用户 ${user} 创建收藏更新通知: ${fav.title}`);
|
||||
|
||||
// 收集更新信息用于邮件
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||
const playUrl = `${siteUrl}/play?source=${source}&id=${id}&title=${encodeURIComponent(fav.title)}`;
|
||||
userUpdates.push({
|
||||
title: fav.title,
|
||||
oldEpisodes: fav.total_episodes,
|
||||
newEpisodes: favEpisodeCount,
|
||||
url: playUrl,
|
||||
cover: favDetail.poster || fav.cover,
|
||||
});
|
||||
}
|
||||
|
||||
processedFavorites++;
|
||||
@@ -277,9 +389,55 @@ async function refreshRecordAndFavorites() {
|
||||
}
|
||||
|
||||
console.log(`收藏处理完成: ${processedFavorites}/${totalFavorites}`);
|
||||
|
||||
// 如果有更新,异步发送汇总邮件(不阻塞主流程)
|
||||
if (userUpdates.length > 0) {
|
||||
(async () => {
|
||||
try {
|
||||
const userEmail = storage.getUserEmail ? await storage.getUserEmail(user) : null;
|
||||
const emailNotifications = storage.getEmailNotificationPreference
|
||||
? await storage.getEmailNotificationPreference(user)
|
||||
: false;
|
||||
|
||||
if (userEmail && emailNotifications) {
|
||||
const config = await getConfig();
|
||||
const emailConfig = config?.EmailConfig;
|
||||
|
||||
if (emailConfig?.enabled) {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||
const siteName = config?.SiteConfig?.SiteName || 'MoonTVPlus';
|
||||
|
||||
await EmailService.send(emailConfig, {
|
||||
to: userEmail,
|
||||
subject: `📺 收藏更新汇总 - ${userUpdates.length} 部影片有更新`,
|
||||
html: getBatchFavoriteUpdateEmailTemplate(
|
||||
user,
|
||||
userUpdates,
|
||||
siteUrl,
|
||||
siteName
|
||||
),
|
||||
});
|
||||
|
||||
console.log(`邮件汇总已发送至: ${userEmail} (${userUpdates.length} 个更新)`);
|
||||
}
|
||||
}
|
||||
} catch (emailError) {
|
||||
console.error(`发送邮件汇总失败 (${user}):`, emailError);
|
||||
}
|
||||
})().catch(err => console.error(`邮件发送异步任务失败 (${user}):`, err));
|
||||
}
|
||||
} catch (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('刷新播放记录/收藏任务完成');
|
||||
@@ -3,7 +3,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { DanmakuFilterConfig } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
|
||||
import { getDetailFromApi } from '@/lib/downstream';
|
||||
import {
|
||||
executeSavedSourceScript,
|
||||
normalizeScriptDetailResult,
|
||||
normalizeScriptSources,
|
||||
parseScriptSourceValue,
|
||||
} from '@/lib/source-script';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -20,6 +26,49 @@ export async function GET(request: NextRequest) {
|
||||
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 源
|
||||
if (sourceCode === 'openlist') {
|
||||
try {
|
||||
@@ -45,13 +94,13 @@ export async function GET(request: NextRequest) {
|
||||
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||
const { db } = await import('@/lib/db');
|
||||
|
||||
metaInfo = getCachedMetaInfo(rootPath);
|
||||
metaInfo = getCachedMetaInfo();
|
||||
|
||||
if (!metaInfo) {
|
||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||
if (metainfoJson) {
|
||||
metaInfo = JSON.parse(metainfoJson);
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
setCachedMetaInfo(metaInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,14 +130,31 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
let videoInfo = getCachedVideoInfo(folderPath);
|
||||
|
||||
const listResponse = await client.listDirectory(folderPath);
|
||||
// 获取所有分页的视频文件
|
||||
const allFiles: any[] = [];
|
||||
let currentPage = 1;
|
||||
const pageSize = 100;
|
||||
let total = 0;
|
||||
|
||||
if (listResponse.code !== 200) {
|
||||
throw new Error('OpenList 列表获取失败');
|
||||
while (true) {
|
||||
const listResponse = await client.listDirectory(folderPath, currentPage, pageSize);
|
||||
|
||||
if (listResponse.code !== 200) {
|
||||
throw new Error('OpenList 列表获取失败1');
|
||||
}
|
||||
|
||||
total = listResponse.data.total;
|
||||
allFiles.push(...listResponse.data.content);
|
||||
|
||||
if (allFiles.length >= total) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentPage++;
|
||||
}
|
||||
|
||||
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob'];
|
||||
const videoFiles = listResponse.data.content.filter((item) => {
|
||||
const videoFiles = allFiles.filter((item) => {
|
||||
if (item.is_dir || item.name.startsWith('.') || item.name.endsWith('.json')) return false;
|
||||
return videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext));
|
||||
});
|
||||
@@ -104,6 +170,7 @@ export async function GET(request: NextRequest) {
|
||||
season: parsed.season,
|
||||
title: parsed.title,
|
||||
parsed_from: 'filename',
|
||||
isOVA: parsed.isOVA,
|
||||
};
|
||||
}
|
||||
setCachedVideoInfo(folderPath, videoInfo);
|
||||
@@ -114,20 +181,26 @@ export async function GET(request: NextRequest) {
|
||||
const parsed = parseVideoFileName(file.name);
|
||||
let episodeInfo;
|
||||
if (parsed.episode) {
|
||||
episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename' };
|
||||
episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename', isOVA: parsed.isOVA };
|
||||
} else {
|
||||
episodeInfo = videoInfo!.episodes[file.name] || { episode: index + 1, season: undefined, title: undefined, parsed_from: 'filename' };
|
||||
}
|
||||
let displayTitle = episodeInfo.title;
|
||||
if (!displayTitle && episodeInfo.episode) {
|
||||
displayTitle = `第${episodeInfo.episode}集`;
|
||||
displayTitle = episodeInfo.isOVA ? `OVA ${episodeInfo.episode}` : `第${episodeInfo.episode}集`;
|
||||
}
|
||||
if (!displayTitle) {
|
||||
displayTitle = file.name;
|
||||
}
|
||||
return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle };
|
||||
return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle, isOVA: episodeInfo.isOVA };
|
||||
})
|
||||
.sort((a, b) => a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName));
|
||||
.sort((a, b) => {
|
||||
// OVA 排在最后
|
||||
if (a.isOVA && !b.isOVA) return 1;
|
||||
if (!a.isOVA && b.isOVA) return -1;
|
||||
// 都是 OVA 或都不是 OVA,按集数排序
|
||||
return a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName);
|
||||
});
|
||||
|
||||
// 3. 从 metainfo 中获取元数据
|
||||
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as cheerio from 'cheerio/slim';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
import { fetchDoubanWithVerification } from '@/lib/douban-anti-crawler';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -25,19 +27,10 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
// 请求豆瓣短评页面
|
||||
// 请求豆瓣短评页面(使用反爬验证)
|
||||
const url = `https://movie.douban.com/subject/${doubanId}/comments?start=${start}&limit=${limit}&status=P&sort=new_score`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
Accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
Referer: 'https://movie.douban.com/',
|
||||
},
|
||||
});
|
||||
const response = await fetchDoubanWithVerification(url);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as cheerio from 'cheerio/slim';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
import { fetchDoubanData } from '@/lib/douban';
|
||||
import { fetchDoubanWithVerification } from '@/lib/douban-anti-crawler';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -26,25 +28,10 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
// 请求豆瓣电影页面,使用和其他豆瓣API相同的请求头
|
||||
// 请求豆瓣电影页面(使用反爬验证)
|
||||
const url = `https://movie.douban.com/subject/${doubanId}/`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
Referer: 'https://movie.douban.com/',
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
Origin: 'https://movie.douban.com',
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const response = await fetchDoubanWithVerification(url);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
|
||||
50
src/app/api/douban/search/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { fetchDoubanData } from '@/lib/douban';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
interface DoubanSearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
year: string;
|
||||
type?: string;
|
||||
sub_title?: string;
|
||||
episode?: string;
|
||||
img?: string;
|
||||
}
|
||||
|
||||
interface DoubanSearchResponse {
|
||||
code: number;
|
||||
data?: DoubanSearchResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/douban/search?q=<query>
|
||||
* 搜索豆瓣影视作品
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const target = `https://movie.douban.com/j/subject_suggest?q=${encodeURIComponent(query)}`;
|
||||
const data = await fetchDoubanData<DoubanSearchResult[]>(target);
|
||||
|
||||
const response: DoubanSearchResponse = {
|
||||
code: 200,
|
||||
data: data,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '搜索豆瓣数据失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
260
src/app/api/emby/cms-proxy/[token]/route.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { EmbyClient } from '@/lib/emby.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Emby CMS 代理接口(动态路由)
|
||||
* 将 Emby 媒体库转换为 TVBox 兼容的 CMS API 格式
|
||||
* 路径格式:/api/emby/cms-proxy/{token}?ac=videolist&...
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { token: string } }
|
||||
) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const ac = searchParams.get('ac');
|
||||
const wd = searchParams.get('wd'); // 搜索关键词
|
||||
const ids = searchParams.get('ids'); // 视频ID
|
||||
|
||||
// 检查必要参数
|
||||
if (ac !== 'videolist' && ac !== 'list' && ac !== 'detail') {
|
||||
return NextResponse.json(
|
||||
{ code: 400, msg: '不支持的操作' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证 TVBox Token(从路径中获取)
|
||||
const requestToken = params.token;
|
||||
const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
|
||||
|
||||
// 检查是否是全局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({
|
||||
code: 401,
|
||||
msg: '无效的访问token',
|
||||
page: 1,
|
||||
pagecount: 0,
|
||||
limit: 0,
|
||||
total: 0,
|
||||
list: [],
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getConfig();
|
||||
|
||||
// 验证 Emby 配置(多源)
|
||||
if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) {
|
||||
return NextResponse.json({
|
||||
code: 0,
|
||||
msg: 'Emby 未配置或未启用',
|
||||
page: 1,
|
||||
pagecount: 0,
|
||||
limit: 0,
|
||||
total: 0,
|
||||
list: [],
|
||||
});
|
||||
}
|
||||
|
||||
// 获取 embyKey 参数
|
||||
const embyKey = searchParams.get('embyKey') || undefined;
|
||||
|
||||
// 使用 EmbyManager 获取客户端
|
||||
const { embyManager } = await import('@/lib/emby-manager');
|
||||
const client = await embyManager.getClient(embyKey);
|
||||
|
||||
// 路由处理
|
||||
if (wd) {
|
||||
// 搜索模式
|
||||
if (ac === 'detail') {
|
||||
return await handleDetailBySearch(client, wd, requestToken, embyKey, request);
|
||||
}
|
||||
return await handleSearch(client, wd, requestToken);
|
||||
} else if (ids || ac === 'detail') {
|
||||
// 详情模式
|
||||
if (!ids) {
|
||||
return NextResponse.json({
|
||||
code: 0,
|
||||
msg: '缺少视频ID',
|
||||
page: 1,
|
||||
pagecount: 0,
|
||||
limit: 0,
|
||||
total: 0,
|
||||
list: [],
|
||||
});
|
||||
}
|
||||
return await handleDetail(client, ids, requestToken, embyKey, request);
|
||||
} else {
|
||||
// 列表模式
|
||||
return await handleSearch(client, '', requestToken);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Emby CMS Proxy] 错误:', error);
|
||||
return NextResponse.json({
|
||||
code: 500,
|
||||
msg: (error as Error).message,
|
||||
page: 1,
|
||||
pagecount: 0,
|
||||
limit: 0,
|
||||
total: 0,
|
||||
list: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理搜索请求
|
||||
*/
|
||||
async function handleSearch(client: EmbyClient, query: string, token: string) {
|
||||
const result = await client.getItems({
|
||||
searchTerm: query || undefined,
|
||||
IncludeItemTypes: 'Movie,Series',
|
||||
Recursive: true,
|
||||
Fields: 'Overview,ProductionYear',
|
||||
Limit: 100,
|
||||
});
|
||||
|
||||
const list = result.Items.map((item) => ({
|
||||
vod_id: item.Id,
|
||||
vod_name: item.Name,
|
||||
vod_pic: client.getImageUrl(item.Id, 'Primary', undefined, token),
|
||||
vod_remarks: item.Type === 'Movie' ? '电影' : '剧集',
|
||||
vod_year: item.ProductionYear?.toString() || '',
|
||||
vod_content: item.Overview || '',
|
||||
type_name: item.Type === 'Movie' ? '电影' : '电视剧',
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
code: 1,
|
||||
msg: '数据列表',
|
||||
page: 1,
|
||||
pagecount: 1,
|
||||
limit: list.length,
|
||||
total: list.length,
|
||||
list,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理通过搜索关键词获取详情的请求
|
||||
*/
|
||||
async function handleDetailBySearch(
|
||||
client: EmbyClient,
|
||||
query: string,
|
||||
token: string,
|
||||
embyKey: string | undefined,
|
||||
request: NextRequest
|
||||
) {
|
||||
const result = await client.getItems({
|
||||
searchTerm: query,
|
||||
IncludeItemTypes: 'Movie,Series',
|
||||
Recursive: true,
|
||||
Fields: 'Overview,ProductionYear',
|
||||
Limit: 1,
|
||||
});
|
||||
|
||||
if (result.Items.length === 0) {
|
||||
return NextResponse.json({
|
||||
code: 0,
|
||||
msg: '未找到该视频',
|
||||
page: 1,
|
||||
pagecount: 0,
|
||||
limit: 0,
|
||||
total: 0,
|
||||
list: [],
|
||||
});
|
||||
}
|
||||
|
||||
return await handleDetail(client, result.Items[0].Id, token, embyKey, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理详情请求
|
||||
*/
|
||||
async function handleDetail(
|
||||
client: EmbyClient,
|
||||
itemId: string,
|
||||
token: string,
|
||||
embyKey: string | undefined,
|
||||
request: NextRequest
|
||||
) {
|
||||
const item = await client.getItem(itemId);
|
||||
|
||||
// 获取当前请求的 baseUrl
|
||||
const host = request.headers.get('host') || request.headers.get('x-forwarded-host');
|
||||
const proto = request.headers.get('x-forwarded-proto') ||
|
||||
(host?.includes('localhost') || host?.includes('127.0.0.1') ? 'http' : 'https');
|
||||
const baseUrl = process.env.SITE_BASE || `${proto}://${host}`;
|
||||
|
||||
const embyKeyParam = embyKey ? `&embyKey=${embyKey}` : '';
|
||||
let vodPlayUrl = '';
|
||||
|
||||
if (item.Type === 'Movie') {
|
||||
// 电影:单个播放链接(使用代理,添加 .mp4 扩展名)
|
||||
const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${item.Id}${embyKeyParam}`;
|
||||
vodPlayUrl = `正片$${proxyUrl}`;
|
||||
} else if (item.Type === 'Series') {
|
||||
// 剧集:获取所有集
|
||||
const allEpisodes = await client.getEpisodes(itemId);
|
||||
|
||||
const episodes = allEpisodes
|
||||
.sort((a, b) => {
|
||||
if (a.ParentIndexNumber !== b.ParentIndexNumber) {
|
||||
return (a.ParentIndexNumber || 0) - (b.ParentIndexNumber || 0);
|
||||
}
|
||||
return (a.IndexNumber || 0) - (b.IndexNumber || 0);
|
||||
})
|
||||
.map((ep) => {
|
||||
const title = `第${ep.IndexNumber}集`;
|
||||
const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${ep.Id}${embyKeyParam}`;
|
||||
return `${title}$${proxyUrl}`;
|
||||
});
|
||||
|
||||
vodPlayUrl = episodes.join('#');
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
code: 1,
|
||||
msg: '数据列表',
|
||||
page: 1,
|
||||
pagecount: 1,
|
||||
limit: 1,
|
||||
total: 1,
|
||||
list: [
|
||||
{
|
||||
vod_id: item.Id,
|
||||
vod_name: item.Name,
|
||||
vod_pic: client.getImageUrl(item.Id, 'Primary', undefined, token),
|
||||
vod_remarks: item.Type === 'Movie' ? '电影' : '剧集',
|
||||
vod_year: item.ProductionYear?.toString() || '',
|
||||
vod_content: item.Overview || '',
|
||||
type_name: item.Type === 'Movie' ? '电影' : '电视剧',
|
||||
vod_play_url: vodPlayUrl,
|
||||
vod_play_from: 'Emby',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
75
src/app/api/emby/detail/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { embyManager } from '@/lib/emby-manager';
|
||||
import { getProxyToken } from '@/lib/emby-token';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const itemId = searchParams.get('id');
|
||||
const embyKey = searchParams.get('embyKey') || undefined;
|
||||
|
||||
if (!itemId) {
|
||||
return NextResponse.json({ error: '缺少媒体ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取Emby客户端
|
||||
const client = await embyManager.getClient(embyKey);
|
||||
|
||||
// 获取代理 token(如果启用了代理)
|
||||
const proxyToken = client.isProxyEnabled() ? await getProxyToken(request) : null;
|
||||
|
||||
// 获取媒体详情
|
||||
const item = await client.getItem(itemId);
|
||||
|
||||
let episodes: any[] = [];
|
||||
|
||||
if (item.Type === 'Series') {
|
||||
// 获取所有剧集
|
||||
const allEpisodes = await client.getEpisodes(itemId);
|
||||
|
||||
episodes = await Promise.all(
|
||||
allEpisodes
|
||||
.sort((a, b) => {
|
||||
if (a.ParentIndexNumber !== b.ParentIndexNumber) {
|
||||
return (a.ParentIndexNumber || 0) - (b.ParentIndexNumber || 0);
|
||||
}
|
||||
return (a.IndexNumber || 0) - (b.IndexNumber || 0);
|
||||
})
|
||||
.map(async (ep) => ({
|
||||
id: ep.Id,
|
||||
title: ep.Name,
|
||||
episode: ep.IndexNumber || 0,
|
||||
season: ep.ParentIndexNumber || 1,
|
||||
overview: ep.Overview || '',
|
||||
playUrl: await client.getStreamUrl(ep.Id),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
item: {
|
||||
id: item.Id,
|
||||
title: item.Name,
|
||||
type: item.Type === 'Movie' ? 'movie' : 'tv',
|
||||
overview: item.Overview || '',
|
||||
poster: client.getImageUrl(item.Id, 'Primary', undefined, proxyToken || undefined),
|
||||
year: item.ProductionYear?.toString() || '',
|
||||
rating: item.CommunityRating || 0,
|
||||
playUrl: item.Type === 'Movie' ? await client.getStreamUrl(item.Id) : undefined,
|
||||
},
|
||||
episodes: item.Type === 'Series' ? episodes : [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取 Emby 详情失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取 Emby 详情失败: ' + (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
85
src/app/api/emby/list/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getCachedEmbyList, setCachedEmbyList } from '@/lib/emby-cache';
|
||||
import { embyManager } from '@/lib/emby-manager';
|
||||
import { getProxyToken } from '@/lib/emby-token';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '20');
|
||||
const parentId = searchParams.get('parentId') || undefined;
|
||||
const embyKey = searchParams.get('embyKey') || undefined;
|
||||
const sortBy = searchParams.get('sortBy') || 'SortName';
|
||||
const sortOrder = searchParams.get('sortOrder') || 'Ascending';
|
||||
|
||||
try {
|
||||
// 判断是否是默认排序(只有默认排序才使用缓存)
|
||||
const isDefaultSort = sortBy === 'SortName' && sortOrder === 'Ascending';
|
||||
|
||||
// 只有默认排序才检查缓存
|
||||
if (isDefaultSort) {
|
||||
const cached = getCachedEmbyList(page, pageSize, parentId, embyKey);
|
||||
if (cached) {
|
||||
return NextResponse.json(cached);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取Emby客户端
|
||||
const client = await embyManager.getClient(embyKey);
|
||||
|
||||
// 获取代理 token(如果启用了代理)
|
||||
const proxyToken = client.isProxyEnabled() ? await getProxyToken(request) : null;
|
||||
|
||||
// 获取媒体列表
|
||||
const result = await client.getItems({
|
||||
ParentId: parentId,
|
||||
IncludeItemTypes: 'Movie,Series',
|
||||
Recursive: true,
|
||||
Fields: 'Overview,ProductionYear',
|
||||
SortBy: sortBy,
|
||||
SortOrder: sortOrder,
|
||||
StartIndex: (page - 1) * pageSize,
|
||||
Limit: pageSize,
|
||||
});
|
||||
|
||||
const list = result.Items.map((item) => ({
|
||||
id: item.Id,
|
||||
title: item.Name,
|
||||
poster: client.getImageUrl(item.Id, 'Primary', undefined, proxyToken || undefined),
|
||||
year: item.ProductionYear?.toString() || '',
|
||||
rating: item.CommunityRating || 0,
|
||||
mediaType: item.Type === 'Movie' ? 'movie' : 'tv',
|
||||
}));
|
||||
|
||||
const totalPages = Math.ceil(result.TotalRecordCount / pageSize);
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
list,
|
||||
totalPages,
|
||||
currentPage: page,
|
||||
total: result.TotalRecordCount,
|
||||
};
|
||||
|
||||
// 只有默认排序才缓存结果
|
||||
if (isDefaultSort) {
|
||||
setCachedEmbyList(page, pageSize, response, parentId, embyKey);
|
||||
}
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('获取 Emby 列表失败:', error);
|
||||
return NextResponse.json({
|
||||
error: '获取 Emby 列表失败: ' + (error as Error).message,
|
||||
list: [],
|
||||
totalPages: 0,
|
||||
currentPage: page,
|
||||
total: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
235
src/app/api/emby/play/[token]/[filename]/route.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* 获取 Emby 客户端
|
||||
*/
|
||||
async function getEmbyClient(embyKey?: string) {
|
||||
const config = await getConfig();
|
||||
|
||||
if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) {
|
||||
throw new Error('Emby 未配置或未启用');
|
||||
}
|
||||
|
||||
const { embyManager } = await import('@/lib/emby-manager');
|
||||
return await embyManager.getClient(embyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/emby/play/{token}/{filename}?itemId=xxx
|
||||
* 代理 Emby 视频播放链接,URL 中包含文件扩展名(如 video.mp4)
|
||||
* 实际返回的内容根据 Emby 服务器的 Content-Type 决定
|
||||
*
|
||||
* 权限验证:TVBox Token(路径参数) 或 用户登录(满足其一即可)
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { token: string; filename: string } }
|
||||
) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// 双重验证:TVBox Token(全局或用户) 或 用户登录
|
||||
const requestToken = params.token;
|
||||
const 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 = searchParams.get('itemId');
|
||||
const embyKey = searchParams.get('embyKey') || undefined;
|
||||
|
||||
if (!itemId) {
|
||||
return NextResponse.json({ error: '缺少 itemId 参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取 Emby 客户端
|
||||
let client = await getEmbyClient(embyKey);
|
||||
|
||||
// 构建 Emby 原始播放链接(强制获取直接URL,避免代理循环)
|
||||
let embyStreamUrl = await client.getStreamUrl(itemId, true, true);
|
||||
console.log(embyStreamUrl)
|
||||
|
||||
// 构建请求头,转发 Range 请求,并添加自定义 User-Agent
|
||||
const requestHeaders: HeadersInit = {
|
||||
'User-Agent': client.getUserAgent(),
|
||||
};
|
||||
const rangeHeader = request.headers.get('range');
|
||||
if (rangeHeader) {
|
||||
requestHeaders['Range'] = rangeHeader;
|
||||
}
|
||||
|
||||
// 创建 AbortController 用于超时控制
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), 300000); // 5分钟超时
|
||||
|
||||
try {
|
||||
// 流式代理视频内容
|
||||
let videoResponse = await fetch(embyStreamUrl, {
|
||||
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) {
|
||||
console.error('[Emby Play] 获取视频流失败:', {
|
||||
itemId,
|
||||
status: videoResponse.status,
|
||||
statusText: videoResponse.statusText,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: '获取视频流失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取 Content-Type
|
||||
const contentType = videoResponse.headers.get('content-type') || 'video/mp4';
|
||||
|
||||
// 构建响应头
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', contentType);
|
||||
|
||||
// 复制重要的响应头
|
||||
const contentLength = videoResponse.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
headers.set('Content-Length', contentLength);
|
||||
}
|
||||
|
||||
const acceptRanges = videoResponse.headers.get('accept-ranges');
|
||||
if (acceptRanges) {
|
||||
headers.set('Accept-Ranges', acceptRanges);
|
||||
}
|
||||
|
||||
const contentRange = videoResponse.headers.get('content-range');
|
||||
if (contentRange) {
|
||||
headers.set('Content-Range', contentRange);
|
||||
}
|
||||
|
||||
// 使用 URL 中的文件名
|
||||
headers.set('Content-Disposition', `inline; filename="${params.filename}"`);
|
||||
|
||||
// 创建一个可以被中断的流
|
||||
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,
|
||||
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) {
|
||||
console.error('[Emby Play] 错误:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '播放失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
28
src/app/api/emby/sources/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { embyManager } from '@/lib/emby-manager';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic'; // 禁用缓存
|
||||
|
||||
/**
|
||||
* 获取所有启用的Emby源列表
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const sources = await embyManager.getEnabledSources();
|
||||
|
||||
return NextResponse.json({
|
||||
sources: sources.map(s => ({
|
||||
key: s.key,
|
||||
name: s.name,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Emby Sources] 获取Emby源列表失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取Emby源列表失败', sources: [] },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/app/api/emby/views/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getCachedEmbyViews, setCachedEmbyViews } from '@/lib/emby-cache';
|
||||
import { embyManager } from '@/lib/emby-manager';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const embyKey = searchParams.get('embyKey') || undefined;
|
||||
|
||||
// 检查缓存(按embyKey缓存)
|
||||
const cacheKey = embyKey || 'default';
|
||||
const cached = getCachedEmbyViews(cacheKey);
|
||||
if (cached) {
|
||||
return NextResponse.json(cached);
|
||||
}
|
||||
|
||||
// 获取Emby客户端
|
||||
const client = await embyManager.getClient(embyKey);
|
||||
|
||||
// 获取媒体库列表
|
||||
const views = await client.getUserViews();
|
||||
|
||||
// 过滤出电影和电视剧媒体库
|
||||
const filteredViews = views.filter(
|
||||
(view) => view.CollectionType === 'movies' || view.CollectionType === 'tvshows'
|
||||
);
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
views: filteredViews.map((view) => ({
|
||||
id: view.Id,
|
||||
name: view.Name,
|
||||
type: view.CollectionType,
|
||||
})),
|
||||
};
|
||||
|
||||
// 缓存结果
|
||||
setCachedEmbyViews(cacheKey, response);
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('获取 Emby 媒体库列表失败:', error);
|
||||
return NextResponse.json({
|
||||
error: '获取 Emby 媒体库列表失败: ' + (error as Error).message,
|
||||
views: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getStorage } from '@/lib/db';
|
||||
import { getAvailableApiSites } from '@/lib/config';
|
||||
import { getDetailFromApi } from '@/lib/downstream';
|
||||
import { Notification } from '@/lib/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = getStorage();
|
||||
const username = authInfo.username;
|
||||
const now = Date.now();
|
||||
|
||||
console.log(`用户 ${username} 请求检查收藏更新`);
|
||||
console.log(`当前时间: ${new Date(now).toLocaleString('zh-CN')}`);
|
||||
console.log(`开始检查收藏更新...`);
|
||||
|
||||
// 获取所有收藏
|
||||
const favorites = await storage.getAllFavorites(username);
|
||||
const favoriteKeys = Object.keys(favorites);
|
||||
|
||||
if (favoriteKeys.length === 0) {
|
||||
return NextResponse.json({
|
||||
message: '没有收藏',
|
||||
updates: [],
|
||||
});
|
||||
}
|
||||
|
||||
// 获取可用的 API 站点
|
||||
const apiSites = await getAvailableApiSites(username);
|
||||
|
||||
// 检查每个收藏的更新
|
||||
const updates: Array<{
|
||||
source: string;
|
||||
id: string;
|
||||
title: string;
|
||||
old_episodes: number;
|
||||
new_episodes: number;
|
||||
}> = [];
|
||||
|
||||
// 限制并发请求数量,避免过载
|
||||
const BATCH_SIZE = 5;
|
||||
for (let i = 0; i < favoriteKeys.length; i += BATCH_SIZE) {
|
||||
const batch = favoriteKeys.slice(i, i + BATCH_SIZE);
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async (key) => {
|
||||
try {
|
||||
const favorite = favorites[key];
|
||||
|
||||
// 跳过 live 类型的收藏
|
||||
if (favorite.origin === 'live') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 跳过已完结的收藏
|
||||
if (favorite.is_completed) {
|
||||
console.log(`跳过已完结的收藏: ${favorite.title}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析 source 和 id
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找对应的 API 站点
|
||||
const apiSite = apiSites.find((site) => site.key === source);
|
||||
if (!apiSite) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取最新详情
|
||||
const detail = await getDetailFromApi(apiSite, id);
|
||||
|
||||
// 比较集数
|
||||
const oldEpisodes = favorite.total_episodes;
|
||||
const newEpisodes = detail.episodes.length;
|
||||
|
||||
console.log(`检查收藏: ${favorite.title} (${source}+${id})`);
|
||||
console.log(` 旧集数: ${oldEpisodes}, 新集数: ${newEpisodes}`);
|
||||
console.log(` 是否完结: ${favorite.is_completed}, 备注: ${favorite.vod_remarks}`);
|
||||
|
||||
if (newEpisodes > oldEpisodes) {
|
||||
updates.push({
|
||||
source,
|
||||
id,
|
||||
title: favorite.title,
|
||||
old_episodes: oldEpisodes,
|
||||
new_episodes: newEpisodes,
|
||||
});
|
||||
|
||||
// 更新收藏的集数和完结状态
|
||||
await storage.setFavorite(username, key, {
|
||||
...favorite,
|
||||
total_episodes: newEpisodes,
|
||||
is_completed: detail.vod_remarks
|
||||
? ['全', '完结', '大结局', 'end', '完'].some((keyword) =>
|
||||
detail.vod_remarks!.toLowerCase().includes(keyword)
|
||||
)
|
||||
: false,
|
||||
vod_remarks: detail.vod_remarks,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`检查收藏更新失败 (${key}):`, error);
|
||||
// 继续处理其他收藏
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`检查完成,发现 ${updates.length} 个更新`);
|
||||
|
||||
// 如果有更新,创建通知
|
||||
if (updates.length > 0) {
|
||||
for (const update of updates) {
|
||||
const notification: Notification = {
|
||||
id: `fav_update_${update.source}_${update.id}_${now}`,
|
||||
type: 'favorite_update',
|
||||
title: '收藏更新',
|
||||
message: `《${update.title}》有新集数更新!从 ${update.old_episodes} 集更新到 ${update.new_episodes} 集`,
|
||||
timestamp: now,
|
||||
read: false,
|
||||
metadata: {
|
||||
source: update.source,
|
||||
id: update.id,
|
||||
title: update.title,
|
||||
old_episodes: update.old_episodes,
|
||||
new_episodes: update.new_episodes,
|
||||
},
|
||||
};
|
||||
|
||||
await storage.addNotification(username, notification);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: updates.length > 0 ? `发现 ${updates.length} 个更新` : '没有更新',
|
||||
updates,
|
||||
checked: favoriteKeys.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('检查收藏更新失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||