Merge branch 'dev'
This commit is contained in:
16
CHANGELOG
16
CHANGELOG
@@ -1,3 +1,19 @@
|
|||||||
|
## [204.0.0] - 2025-12-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- ⚠️⚠️⚠️更新此版本前前务必进行备份!!!⚠️⚠️⚠️
|
||||||
|
- 新增私人影视库功能(实验性)
|
||||||
|
- 增加弹幕热力图
|
||||||
|
- 增加盘搜搜索资源
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 完全重构用户数据存储结构
|
||||||
|
- 提高所有弹幕接口的超时时间
|
||||||
|
- 优化完结标识判断
|
||||||
|
- 即将上映移动端字体大小调整
|
||||||
|
- tmdb增加代理支持
|
||||||
|
- 剧集更新检测改为服务器后台定时执行
|
||||||
|
|
||||||
## [203.2.2] - 2025-12-20
|
## [203.2.2] - 2025-12-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
|||||||
| NEXT_PUBLIC_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback |
|
| NEXT_PUBLIC_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback |
|
||||||
| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false |
|
| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false |
|
||||||
| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data |
|
| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data |
|
||||||
|
| VIDEOINFO_CACHE_MINUTES | 私人影库视频信息在内存中的缓存时长(分钟) | 正整数 | 1440(1天) |
|
||||||
|
|
||||||
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
203.2.2
|
204.0.0
|
||||||
@@ -39,12 +39,15 @@
|
|||||||
"framer-motion": "^12.18.1",
|
"framer-motion": "^12.18.1",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
"hls.js": "^1.6.10",
|
"hls.js": "^1.6.10",
|
||||||
|
"https-proxy-agent": "^7.0.6",
|
||||||
"lucide-react": "^0.438.0",
|
"lucide-react": "^0.438.0",
|
||||||
"media-icons": "^1.1.5",
|
"media-icons": "^1.1.5",
|
||||||
"mux.js": "^6.3.0",
|
"mux.js": "^6.3.0",
|
||||||
"next": "^14.2.33",
|
"next": "^14.2.33",
|
||||||
"next-pwa": "^5.6.0",
|
"next-pwa": "^5.6.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"parse-torrent-name": "^0.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
@@ -66,6 +69,7 @@
|
|||||||
"@types/bs58": "^5.0.0",
|
"@types/bs58": "^5.0.0",
|
||||||
"@types/he": "^1.2.3",
|
"@types/he": "^1.2.3",
|
||||||
"@types/node": "24.0.3",
|
"@types/node": "24.0.3",
|
||||||
|
"@types/node-fetch": "^2.6.13",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@types/testing-library__jest-dom": "^5.14.9",
|
"@types/testing-library__jest-dom": "^5.14.9",
|
||||||
|
|||||||
85
pnpm-lock.yaml
generated
85
pnpm-lock.yaml
generated
@@ -65,6 +65,9 @@ importers:
|
|||||||
hls.js:
|
hls.js:
|
||||||
specifier: ^1.6.10
|
specifier: ^1.6.10
|
||||||
version: 1.6.10
|
version: 1.6.10
|
||||||
|
https-proxy-agent:
|
||||||
|
specifier: ^7.0.6
|
||||||
|
version: 7.0.6
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.438.0
|
specifier: ^0.438.0
|
||||||
version: 0.438.0(react@18.3.1)
|
version: 0.438.0(react@18.3.1)
|
||||||
@@ -83,6 +86,12 @@ importers:
|
|||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
node-fetch:
|
||||||
|
specifier: ^2.7.0
|
||||||
|
version: 2.7.0
|
||||||
|
parse-torrent-name:
|
||||||
|
specifier: ^0.5.4
|
||||||
|
version: 0.5.4
|
||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
@@ -141,6 +150,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 24.0.3
|
specifier: 24.0.3
|
||||||
version: 24.0.3
|
version: 24.0.3
|
||||||
|
'@types/node-fetch':
|
||||||
|
specifier: ^2.6.13
|
||||||
|
version: 2.6.13
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.3.18
|
specifier: ^18.3.18
|
||||||
version: 18.3.23
|
version: 18.3.23
|
||||||
@@ -1559,6 +1571,9 @@ packages:
|
|||||||
'@types/minimist@1.2.5':
|
'@types/minimist@1.2.5':
|
||||||
resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==}
|
resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==}
|
||||||
|
|
||||||
|
'@types/node-fetch@2.6.13':
|
||||||
|
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
|
||||||
|
|
||||||
'@types/node@24.0.3':
|
'@types/node@24.0.3':
|
||||||
resolution: {integrity: sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==}
|
resolution: {integrity: sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==}
|
||||||
|
|
||||||
@@ -1889,6 +1904,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
||||||
|
agent-base@7.1.4:
|
||||||
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
aggregate-error@3.1.0:
|
aggregate-error@3.1.0:
|
||||||
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
|
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2997,6 +3016,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w==}
|
resolution: {integrity: sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
form-data@4.0.5:
|
||||||
|
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
fraction.js@4.3.7:
|
fraction.js@4.3.7:
|
||||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
||||||
|
|
||||||
@@ -3211,6 +3234,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
https-proxy-agent@7.0.6:
|
||||||
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
human-signals@2.1.0:
|
human-signals@2.1.0:
|
||||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||||
engines: {node: '>=10.17.0'}
|
engines: {node: '>=10.17.0'}
|
||||||
@@ -4068,6 +4095,15 @@ packages:
|
|||||||
no-case@3.0.4:
|
no-case@3.0.4:
|
||||||
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
|
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
|
||||||
|
|
||||||
|
node-fetch@2.7.0:
|
||||||
|
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||||
|
engines: {node: 4.x || >=6.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
encoding: ^0.1.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
encoding:
|
||||||
|
optional: true
|
||||||
|
|
||||||
node-int64@0.4.0:
|
node-int64@0.4.0:
|
||||||
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
||||||
|
|
||||||
@@ -4207,6 +4243,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
parse-torrent-name@0.5.4:
|
||||||
|
resolution: {integrity: sha512-digWcT7Zp/oZX8I7iTQSfWd3z3C/0zszo/xYQsmogO2a6XDU0sTlQXYffHRhuwXNivBvMB8mS+EAwciyyVBlGQ==}
|
||||||
|
|
||||||
parse5-htmlparser2-tree-adapter@7.1.0:
|
parse5-htmlparser2-tree-adapter@7.1.0:
|
||||||
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
|
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
|
||||||
|
|
||||||
@@ -5040,6 +5079,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
|
resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tr46@0.0.3:
|
||||||
|
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||||
|
|
||||||
tr46@1.0.1:
|
tr46@1.0.1:
|
||||||
resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
|
resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
|
||||||
|
|
||||||
@@ -5265,6 +5307,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==}
|
resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
webidl-conversions@3.0.1:
|
||||||
|
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||||
|
|
||||||
webidl-conversions@4.0.2:
|
webidl-conversions@4.0.2:
|
||||||
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
|
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
|
||||||
|
|
||||||
@@ -5317,6 +5362,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
|
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
whatwg-url@5.0.0:
|
||||||
|
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||||
|
|
||||||
whatwg-url@7.1.0:
|
whatwg-url@7.1.0:
|
||||||
resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==}
|
resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==}
|
||||||
|
|
||||||
@@ -7211,6 +7259,11 @@ snapshots:
|
|||||||
|
|
||||||
'@types/minimist@1.2.5': {}
|
'@types/minimist@1.2.5': {}
|
||||||
|
|
||||||
|
'@types/node-fetch@2.6.13':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.0.3
|
||||||
|
form-data: 4.0.5
|
||||||
|
|
||||||
'@types/node@24.0.3':
|
'@types/node@24.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.8.0
|
undici-types: 7.8.0
|
||||||
@@ -7539,6 +7592,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
aggregate-error@3.1.0:
|
aggregate-error@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
clean-stack: 2.2.0
|
clean-stack: 2.2.0
|
||||||
@@ -8855,6 +8910,14 @@ snapshots:
|
|||||||
es-set-tostringtag: 2.1.0
|
es-set-tostringtag: 2.1.0
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
|
|
||||||
|
form-data@4.0.5:
|
||||||
|
dependencies:
|
||||||
|
asynckit: 0.4.0
|
||||||
|
combined-stream: 1.0.8
|
||||||
|
es-set-tostringtag: 2.1.0
|
||||||
|
hasown: 2.0.2
|
||||||
|
mime-types: 2.1.35
|
||||||
|
|
||||||
fraction.js@4.3.7: {}
|
fraction.js@4.3.7: {}
|
||||||
|
|
||||||
framer-motion@12.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
framer-motion@12.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
@@ -9088,6 +9151,13 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
https-proxy-agent@7.0.6:
|
||||||
|
dependencies:
|
||||||
|
agent-base: 7.1.4
|
||||||
|
debug: 4.4.1(supports-color@9.4.0)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
human-signals@2.1.0: {}
|
human-signals@2.1.0: {}
|
||||||
|
|
||||||
husky@7.0.4: {}
|
husky@7.0.4: {}
|
||||||
@@ -10223,6 +10293,10 @@ snapshots:
|
|||||||
lower-case: 2.0.2
|
lower-case: 2.0.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
node-fetch@2.7.0:
|
||||||
|
dependencies:
|
||||||
|
whatwg-url: 5.0.0
|
||||||
|
|
||||||
node-int64@0.4.0: {}
|
node-int64@0.4.0: {}
|
||||||
|
|
||||||
node-releases@2.0.19: {}
|
node-releases@2.0.19: {}
|
||||||
@@ -10381,6 +10455,8 @@ snapshots:
|
|||||||
json-parse-even-better-errors: 2.3.1
|
json-parse-even-better-errors: 2.3.1
|
||||||
lines-and-columns: 1.2.4
|
lines-and-columns: 1.2.4
|
||||||
|
|
||||||
|
parse-torrent-name@0.5.4: {}
|
||||||
|
|
||||||
parse5-htmlparser2-tree-adapter@7.1.0:
|
parse5-htmlparser2-tree-adapter@7.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
domhandler: 5.0.3
|
domhandler: 5.0.3
|
||||||
@@ -11229,6 +11305,8 @@ snapshots:
|
|||||||
universalify: 0.2.0
|
universalify: 0.2.0
|
||||||
url-parse: 1.5.10
|
url-parse: 1.5.10
|
||||||
|
|
||||||
|
tr46@0.0.3: {}
|
||||||
|
|
||||||
tr46@1.0.1:
|
tr46@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
@@ -11473,6 +11551,8 @@ snapshots:
|
|||||||
glob-to-regexp: 0.4.1
|
glob-to-regexp: 0.4.1
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
|
webidl-conversions@3.0.1: {}
|
||||||
|
|
||||||
webidl-conversions@4.0.2: {}
|
webidl-conversions@4.0.2: {}
|
||||||
|
|
||||||
webidl-conversions@5.0.0: {}
|
webidl-conversions@5.0.0: {}
|
||||||
@@ -11543,6 +11623,11 @@ snapshots:
|
|||||||
|
|
||||||
whatwg-mimetype@4.0.0: {}
|
whatwg-mimetype@4.0.0: {}
|
||||||
|
|
||||||
|
whatwg-url@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
tr46: 0.0.3
|
||||||
|
webidl-conversions: 3.0.1
|
||||||
|
|
||||||
whatwg-url@7.1.0:
|
whatwg-url@7.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
lodash.sortby: 4.7.0
|
lodash.sortby: 4.7.0
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,14 +34,31 @@ export async function GET(request: NextRequest) {
|
|||||||
if (username === process.env.USERNAME) {
|
if (username === process.env.USERNAME) {
|
||||||
result.Role = 'owner';
|
result.Role = 'owner';
|
||||||
} else {
|
} else {
|
||||||
const user = config.UserConfig.Users.find((u) => u.username === username);
|
// 优先从新版本获取用户信息
|
||||||
if (user && user.role === 'admin' && !user.banned) {
|
const { db } = await import('@/lib/db');
|
||||||
result.Role = 'admin';
|
const userInfoV2 = await db.getUserInfoV2(username);
|
||||||
|
|
||||||
|
if (userInfoV2) {
|
||||||
|
// 使用新版本用户信息
|
||||||
|
if (userInfoV2.role === 'admin' && !userInfoV2.banned) {
|
||||||
|
result.Role = 'admin';
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '你是管理员吗你就访问?' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json(
|
// 回退到配置中查找
|
||||||
{ error: '你是管理员吗你就访问?' },
|
const user = config.UserConfig.Users.find((u) => u.username === username);
|
||||||
{ status: 401 }
|
if (user && user.role === 'admin' && !user.banned) {
|
||||||
);
|
result.Role = 'admin';
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '你是管理员吗你就访问?' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,14 +54,26 @@ export async function POST(req: NextRequest) {
|
|||||||
// 管理员配置
|
// 管理员配置
|
||||||
adminConfig: config,
|
adminConfig: config,
|
||||||
// 所有用户数据
|
// 所有用户数据
|
||||||
userData: {} as { [username: string]: any }
|
userData: {} as { [username: string]: any },
|
||||||
|
// V2用户信息
|
||||||
|
usersV2: [] as any[]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取所有用户
|
// 获取所有V2用户
|
||||||
|
const usersV2Result = await db.getUserListV2(0, 10000, process.env.USERNAME);
|
||||||
|
exportData.data.usersV2 = usersV2Result.users;
|
||||||
|
|
||||||
|
// 获取所有用户(包括旧版用户)
|
||||||
let allUsers = await db.getAllUsers();
|
let allUsers = await db.getAllUsers();
|
||||||
// 添加站长用户
|
// 添加站长用户
|
||||||
allUsers.push(process.env.USERNAME);
|
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));
|
allUsers = Array.from(new Set(allUsers));
|
||||||
|
|
||||||
// 为每个用户收集数据
|
// 为每个用户收集数据
|
||||||
@@ -76,7 +88,9 @@ export async function POST(req: NextRequest) {
|
|||||||
// 跳过片头片尾配置
|
// 跳过片头片尾配置
|
||||||
skipConfigs: await db.getAllSkipConfigs(username),
|
skipConfigs: await db.getAllSkipConfigs(username),
|
||||||
// 用户密码(通过验证空密码来检查用户是否存在,然后获取密码)
|
// 用户密码(通过验证空密码来检查用户是否存在,然后获取密码)
|
||||||
password: await getUserPassword(username)
|
password: await getUserPassword(username),
|
||||||
|
// V2用户的加密密码
|
||||||
|
passwordV2: await getUserPasswordV2(username)
|
||||||
};
|
};
|
||||||
|
|
||||||
exportData.data.userData[username] = userData;
|
exportData.data.userData[username] = userData;
|
||||||
@@ -134,3 +148,19 @@ async function getUserPassword(username: string): Promise<string | null> {
|
|||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`获取用户 ${username} V2密码失败:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,13 +94,80 @@ export async function POST(req: NextRequest) {
|
|||||||
// 不影响主流程,继续执行
|
// 不影响主流程,继续执行
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导入V2用户信息
|
||||||
|
if (importData.data.usersV2 && Array.isArray(importData.data.usersV2)) {
|
||||||
|
for (const userV2 of importData.data.usersV2) {
|
||||||
|
try {
|
||||||
|
// 跳过环境变量中的站长(站长使用环境变量认证)
|
||||||
|
if (userV2.username === process.env.USERNAME) {
|
||||||
|
console.log(`跳过站长 ${userV2.username} 的导入`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户的加密密码
|
||||||
|
const userData = importData.data.userData[userV2.username];
|
||||||
|
const passwordV2 = userData?.passwordV2;
|
||||||
|
|
||||||
|
if (passwordV2) {
|
||||||
|
// 将站长角色转换为普通角色
|
||||||
|
const importedRole = userV2.role === 'owner' ? 'user' : userV2.role;
|
||||||
|
if (userV2.role === 'owner') {
|
||||||
|
console.log(`用户 ${userV2.username} 的角色从 owner 转换为 user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接使用加密后的密码创建用户
|
||||||
|
const storage = (db as any).storage;
|
||||||
|
if (storage && typeof storage.client?.hset === 'function') {
|
||||||
|
const userInfoKey = `user:${userV2.username}:info`;
|
||||||
|
const createdAt = userV2.created_at || Date.now();
|
||||||
|
|
||||||
|
const userInfo: any = {
|
||||||
|
role: importedRole,
|
||||||
|
banned: userV2.banned,
|
||||||
|
password: passwordV2,
|
||||||
|
created_at: createdAt.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (userV2.tags && userV2.tags.length > 0) {
|
||||||
|
userInfo.tags = JSON.stringify(userV2.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userV2.oidcSub) {
|
||||||
|
userInfo.oidcSub = userV2.oidcSub;
|
||||||
|
// 创建OIDC映射
|
||||||
|
const oidcSubKey = `oidc:sub:${userV2.oidcSub}`;
|
||||||
|
await storage.client.set(oidcSubKey, userV2.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userV2.enabledApis && userV2.enabledApis.length > 0) {
|
||||||
|
userInfo.enabledApis = JSON.stringify(userV2.enabledApis);
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.client.hset(userInfoKey, userInfo);
|
||||||
|
|
||||||
|
// 添加到用户列表(Sorted Set)
|
||||||
|
const userListKey = 'user:list';
|
||||||
|
await storage.client.zadd(userListKey, {
|
||||||
|
score: createdAt,
|
||||||
|
member: userV2.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`V2用户 ${userV2.username} 导入成功`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`导入V2用户 ${userV2.username} 失败:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 导入用户数据
|
// 导入用户数据
|
||||||
const userData = importData.data.userData;
|
const userData = importData.data.userData;
|
||||||
for (const username in userData) {
|
for (const username in userData) {
|
||||||
const user = userData[username];
|
const user = userData[username];
|
||||||
|
|
||||||
// 重新注册用户(包含密码)
|
// 重新注册用户(包含密码)- 仅用于旧版用户
|
||||||
if (user.password) {
|
if (user.password && !importData.data.usersV2?.find((u: any) => u.username === username)) {
|
||||||
await db.registerUser(username, user.password);
|
await db.registerUser(username, user.password);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +206,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: '数据导入成功',
|
message: '数据导入成功',
|
||||||
importedUsers: Object.keys(userData).length,
|
importedUsers: Object.keys(userData).length,
|
||||||
|
importedUsersV2: importData.data.usersV2?.length || 0,
|
||||||
timestamp: importData.timestamp,
|
timestamp: importData.timestamp,
|
||||||
serverVersion: typeof importData.serverVersion === 'string' ? importData.serverVersion : '未知版本'
|
serverVersion: typeof importData.serverVersion === 'string' ? importData.serverVersion : '未知版本'
|
||||||
});
|
});
|
||||||
|
|||||||
76
src/app/api/admin/migrate-users/route.ts
Normal file
76
src/app/api/admin/migrate-users/route.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/* eslint-disable no-console,@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';
|
||||||
|
|
||||||
|
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: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
const adminConfig = await getConfig();
|
||||||
|
|
||||||
|
// 检查是否有需要迁移的用户(排除站长)
|
||||||
|
const usersToMigrate = adminConfig.UserConfig.Users.filter(
|
||||||
|
u => u.role !== 'owner'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!usersToMigrate || usersToMigrate.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '没有需要迁移的用户' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行迁移
|
||||||
|
await db.migrateUsersFromConfig(adminConfig);
|
||||||
|
|
||||||
|
// 迁移完成后,清空配置中的用户列表
|
||||||
|
adminConfig.UserConfig.Users = [];
|
||||||
|
await db.saveAdminConfig(adminConfig);
|
||||||
|
|
||||||
|
// 更新配置缓存
|
||||||
|
const { setCachedConfig } = await import('@/lib/config');
|
||||||
|
await setCachedConfig(adminConfig);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: true, message: '用户数据迁移成功' },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('用户数据迁移失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: '用户数据迁移失败',
|
||||||
|
details: (error as Error).message,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/app/api/admin/openlist/route.ts
Normal file
129
src/app/api/admin/openlist/route.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/* 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 { OpenListClient } from '@/lib/openlist.client';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/openlist
|
||||||
|
* 保存 OpenList 配置
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||||
|
if (storageType === 'localstorage') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: '不支持本地存储进行管理员配置',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { action, Enabled, URL, Username, Password, RootPath, ScanInterval } = 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 userEntry = adminConfig.UserConfig.Users.find(
|
||||||
|
(u) => u.username === username
|
||||||
|
);
|
||||||
|
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'save') {
|
||||||
|
// 如果功能未启用,允许保存空配置
|
||||||
|
if (!Enabled) {
|
||||||
|
adminConfig.OpenListConfig = {
|
||||||
|
Enabled: false,
|
||||||
|
URL: URL || '',
|
||||||
|
Username: Username || '',
|
||||||
|
Password: Password || '',
|
||||||
|
RootPath: RootPath || '/',
|
||||||
|
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||||
|
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
||||||
|
ScanInterval: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.saveAdminConfig(adminConfig);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '保存成功',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 功能启用时,验证必填字段
|
||||||
|
if (!URL || !Username || !Password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '请提供 URL、账号和密码' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证扫描间隔
|
||||||
|
let scanInterval = parseInt(ScanInterval) || 0;
|
||||||
|
if (scanInterval > 0 && scanInterval < 60) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '定时扫描间隔最低为 60 分钟' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证账号密码是否正确
|
||||||
|
try {
|
||||||
|
console.log('[OpenList Config] 验证账号密码');
|
||||||
|
await OpenListClient.login(URL, Username, Password);
|
||||||
|
console.log('[OpenList Config] 账号密码验证成功');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[OpenList Config] 账号密码验证失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '账号密码验证失败: ' + (error as Error).message },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
adminConfig.OpenListConfig = {
|
||||||
|
Enabled: true,
|
||||||
|
URL,
|
||||||
|
Username,
|
||||||
|
Password,
|
||||||
|
RootPath: RootPath || '/',
|
||||||
|
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||||
|
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
||||||
|
ScanInterval: scanInterval,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.saveAdminConfig(adminConfig);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '保存成功',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OpenList 配置操作失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '操作失败', details: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ export async function POST(request: NextRequest) {
|
|||||||
DanmakuApiBase,
|
DanmakuApiBase,
|
||||||
DanmakuApiToken,
|
DanmakuApiToken,
|
||||||
TMDBApiKey,
|
TMDBApiKey,
|
||||||
|
TMDBProxy,
|
||||||
|
PansouApiUrl,
|
||||||
|
PansouUsername,
|
||||||
|
PansouPassword,
|
||||||
EnableComments,
|
EnableComments,
|
||||||
CustomAdFilterCode,
|
CustomAdFilterCode,
|
||||||
CustomAdFilterVersion,
|
CustomAdFilterVersion,
|
||||||
@@ -74,6 +78,10 @@ export async function POST(request: NextRequest) {
|
|||||||
DanmakuApiBase: string;
|
DanmakuApiBase: string;
|
||||||
DanmakuApiToken: string;
|
DanmakuApiToken: string;
|
||||||
TMDBApiKey?: string;
|
TMDBApiKey?: string;
|
||||||
|
TMDBProxy?: string;
|
||||||
|
PansouApiUrl?: string;
|
||||||
|
PansouUsername?: string;
|
||||||
|
PansouPassword?: string;
|
||||||
EnableComments: boolean;
|
EnableComments: boolean;
|
||||||
CustomAdFilterCode?: string;
|
CustomAdFilterCode?: string;
|
||||||
CustomAdFilterVersion?: number;
|
CustomAdFilterVersion?: number;
|
||||||
@@ -109,6 +117,7 @@ export async function POST(request: NextRequest) {
|
|||||||
typeof DanmakuApiBase !== 'string' ||
|
typeof DanmakuApiBase !== 'string' ||
|
||||||
typeof DanmakuApiToken !== 'string' ||
|
typeof DanmakuApiToken !== 'string' ||
|
||||||
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
|
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
|
||||||
|
(TMDBProxy !== undefined && typeof TMDBProxy !== 'string') ||
|
||||||
typeof EnableComments !== 'boolean' ||
|
typeof EnableComments !== 'boolean' ||
|
||||||
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
|
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
|
||||||
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
|
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
|
||||||
@@ -159,6 +168,10 @@ export async function POST(request: NextRequest) {
|
|||||||
DanmakuApiBase,
|
DanmakuApiBase,
|
||||||
DanmakuApiToken,
|
DanmakuApiToken,
|
||||||
TMDBApiKey,
|
TMDBApiKey,
|
||||||
|
TMDBProxy,
|
||||||
|
PansouApiUrl,
|
||||||
|
PansouUsername,
|
||||||
|
PansouPassword,
|
||||||
EnableComments,
|
EnableComments,
|
||||||
CustomAdFilterCode,
|
CustomAdFilterCode,
|
||||||
CustomAdFilterVersion,
|
CustomAdFilterVersion,
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!key || !name || !api) {
|
if (!key || !name || !api) {
|
||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
// 禁止添加 openlist 保留关键字
|
||||||
|
if (key === 'openlist') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'openlist 是保留关键字,不能作为视频源 key' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
if (adminConfig.SourceConfig.some((s) => s.key === key)) {
|
if (adminConfig.SourceConfig.some((s) => s.key === key)) {
|
||||||
return NextResponse.json({ error: '该源已存在' }, { status: 400 });
|
return NextResponse.json({ error: '该源已存在' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,24 +85,50 @@ export async function POST(request: NextRequest) {
|
|||||||
if (username === process.env.USERNAME) {
|
if (username === process.env.USERNAME) {
|
||||||
operatorRole = 'owner';
|
operatorRole = 'owner';
|
||||||
} else {
|
} else {
|
||||||
const userEntry = adminConfig.UserConfig.Users.find(
|
// 优先从新版本获取用户信息
|
||||||
(u) => u.username === username
|
const operatorInfo = await db.getUserInfoV2(username);
|
||||||
);
|
if (operatorInfo) {
|
||||||
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
if (operatorInfo.role !== 'admin' || operatorInfo.banned) {
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
|
}
|
||||||
|
operatorRole = 'admin';
|
||||||
|
} else {
|
||||||
|
// 回退到配置中查找
|
||||||
|
const userEntry = adminConfig.UserConfig.Users.find(
|
||||||
|
(u) => u.username === username
|
||||||
|
);
|
||||||
|
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
|
}
|
||||||
|
operatorRole = 'admin';
|
||||||
}
|
}
|
||||||
operatorRole = 'admin';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找目标用户条目(用户组操作和批量操作不需要)
|
// 查找目标用户条目(用户组操作和批量操作不需要)
|
||||||
let targetEntry: any = null;
|
let targetEntry: any = null;
|
||||||
let isTargetAdmin = false;
|
let isTargetAdmin = false;
|
||||||
|
let targetUserV2: any = null;
|
||||||
|
|
||||||
if (!['userGroup', 'batchUpdateUserGroups'].includes(action) && targetUsername) {
|
if (!['userGroup', 'batchUpdateUserGroups'].includes(action) && targetUsername) {
|
||||||
|
// 先从配置中查找
|
||||||
targetEntry = adminConfig.UserConfig.Users.find(
|
targetEntry = adminConfig.UserConfig.Users.find(
|
||||||
(u) => u.username === targetUsername
|
(u) => u.username === targetUsername
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 如果配置中没有,从新版本存储中查找
|
||||||
|
if (!targetEntry) {
|
||||||
|
targetUserV2 = await db.getUserInfoV2(targetUsername);
|
||||||
|
if (targetUserV2) {
|
||||||
|
// 构造一个兼容的targetEntry对象
|
||||||
|
targetEntry = {
|
||||||
|
username: targetUsername,
|
||||||
|
role: targetUserV2.role,
|
||||||
|
banned: targetUserV2.banned,
|
||||||
|
tags: targetUserV2.tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
targetEntry &&
|
targetEntry &&
|
||||||
targetEntry.role === 'owner' &&
|
targetEntry.role === 'owner' &&
|
||||||
@@ -120,33 +146,35 @@ export async function POST(request: NextRequest) {
|
|||||||
if (targetEntry) {
|
if (targetEntry) {
|
||||||
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
|
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
// 检查新版本中是否已存在
|
||||||
|
const existsV2 = await db.checkUserExistV2(targetUsername!);
|
||||||
|
if (existsV2) {
|
||||||
|
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
|
||||||
|
}
|
||||||
if (!targetPassword) {
|
if (!targetPassword) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '缺少目标用户密码' },
|
{ error: '缺少目标用户密码' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await db.registerUser(targetUsername!, targetPassword);
|
|
||||||
|
|
||||||
// 获取用户组信息
|
// 获取用户组信息
|
||||||
const { userGroup } = body as { userGroup?: string };
|
const { userGroup } = body as { userGroup?: string };
|
||||||
|
const tags = userGroup && userGroup.trim() ? [userGroup] : undefined;
|
||||||
|
|
||||||
// 更新配置
|
// 使用新版本创建用户
|
||||||
const newUser: any = {
|
await db.createUserV2(targetUsername!, targetPassword, 'user', tags);
|
||||||
|
|
||||||
|
// 同时在旧版本存储中创建(保持兼容性)
|
||||||
|
await db.registerUser(targetUsername!, targetPassword);
|
||||||
|
|
||||||
|
// 不再更新配置,因为用户已经存储在新版本中
|
||||||
|
// 构造一个虚拟的targetEntry用于后续逻辑
|
||||||
|
targetEntry = {
|
||||||
username: targetUsername!,
|
username: targetUsername!,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
|
tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 如果指定了用户组,添加到tags中
|
|
||||||
if (userGroup && userGroup.trim()) {
|
|
||||||
newUser.tags = [userGroup];
|
|
||||||
}
|
|
||||||
|
|
||||||
adminConfig.UserConfig.Users.push(newUser);
|
|
||||||
targetEntry =
|
|
||||||
adminConfig.UserConfig.Users[
|
|
||||||
adminConfig.UserConfig.Users.length - 1
|
|
||||||
];
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ban': {
|
case 'ban': {
|
||||||
@@ -165,7 +193,9 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
targetEntry.banned = true;
|
|
||||||
|
// 只更新V2存储
|
||||||
|
await db.updateUserInfoV2(targetUsername!, { banned: true });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'unban': {
|
case 'unban': {
|
||||||
@@ -183,7 +213,9 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
targetEntry.banned = false;
|
|
||||||
|
// 只更新V2存储
|
||||||
|
await db.updateUserInfoV2(targetUsername!, { banned: false });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'setAdmin': {
|
case 'setAdmin': {
|
||||||
@@ -205,7 +237,9 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
targetEntry.role = 'admin';
|
|
||||||
|
// 只更新V2存储
|
||||||
|
await db.updateUserInfoV2(targetUsername!, { role: 'admin' });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'cancelAdmin': {
|
case 'cancelAdmin': {
|
||||||
@@ -227,7 +261,9 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
targetEntry.role = 'user';
|
|
||||||
|
// 只更新V2存储
|
||||||
|
await db.updateUserInfoV2(targetUsername!, { role: 'user' });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'changePassword': {
|
case 'changePassword': {
|
||||||
@@ -260,6 +296,9 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用新版本修改密码(SHA256加密)
|
||||||
|
await db.changePasswordV2(targetUsername!, targetPassword);
|
||||||
|
// 同时更新旧版本(保持兼容性)
|
||||||
await db.changePassword(targetUsername!, targetPassword);
|
await db.changePassword(targetUsername!, targetPassword);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -286,16 +325,11 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只删除V2存储中的用户
|
||||||
|
await db.deleteUserV2(targetUsername!);
|
||||||
|
// 同时删除旧版本(保持兼容性)
|
||||||
await db.deleteUser(targetUsername!);
|
await db.deleteUser(targetUsername!);
|
||||||
|
|
||||||
// 从配置中移除用户
|
|
||||||
const userIndex = adminConfig.UserConfig.Users.findIndex(
|
|
||||||
(u) => u.username === targetUsername
|
|
||||||
);
|
|
||||||
if (userIndex > -1) {
|
|
||||||
adminConfig.UserConfig.Users.splice(userIndex, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'updateUserApis': {
|
case 'updateUserApis': {
|
||||||
@@ -320,13 +354,10 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新用户的采集源权限
|
// 更新V2存储中的采集源权限
|
||||||
if (enabledApis && enabledApis.length > 0) {
|
await db.updateUserInfoV2(targetUsername!, {
|
||||||
targetEntry.enabledApis = enabledApis;
|
enabledApis: enabledApis && enabledApis.length > 0 ? enabledApis : []
|
||||||
} else {
|
});
|
||||||
// 如果为空数组或未提供,则删除该字段,表示无限制
|
|
||||||
delete targetEntry.enabledApis;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -368,19 +399,17 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '用户组不存在' }, { status: 404 });
|
return NextResponse.json({ error: '用户组不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找使用该用户组的所有用户
|
// 查找使用该用户组的所有用户(从V2存储中查找)
|
||||||
const affectedUsers: string[] = [];
|
const affectedUsers = await db.getUsersByTag(groupName);
|
||||||
adminConfig.UserConfig.Users.forEach(user => {
|
|
||||||
if (user.tags && user.tags.includes(groupName)) {
|
// 从用户的tags中移除该用户组
|
||||||
affectedUsers.push(user.username);
|
for (const username of affectedUsers) {
|
||||||
// 从用户的tags中移除该用户组
|
const userInfo = await db.getUserInfoV2(username);
|
||||||
user.tags = user.tags.filter(tag => tag !== groupName);
|
if (userInfo && userInfo.tags) {
|
||||||
// 如果用户没有其他标签了,删除tags字段
|
const newTags = userInfo.tags.filter(tag => tag !== groupName);
|
||||||
if (user.tags.length === 0) {
|
await db.updateUserInfoV2(username, { tags: newTags });
|
||||||
delete user.tags;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// 删除用户组
|
// 删除用户组
|
||||||
adminConfig.UserConfig.Tags.splice(groupIndex, 1);
|
adminConfig.UserConfig.Tags.splice(groupIndex, 1);
|
||||||
@@ -413,10 +442,11 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// 更新用户的用户组
|
// 更新用户的用户组
|
||||||
if (userGroups && userGroups.length > 0) {
|
if (userGroups && userGroups.length > 0) {
|
||||||
targetEntry.tags = userGroups;
|
// 只更新V2存储
|
||||||
|
await db.updateUserInfoV2(targetUsername!, { tags: userGroups });
|
||||||
} else {
|
} else {
|
||||||
// 如果为空数组或未提供,则删除该字段,表示无用户组
|
// 如果为空数组或未提供,则删除该字段,表示无用户组
|
||||||
delete targetEntry.tags;
|
await db.updateUserInfoV2(targetUsername!, { tags: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
@@ -431,7 +461,20 @@ export async function POST(request: NextRequest) {
|
|||||||
// 权限检查:站长可批量配置所有人的用户组,管理员只能批量配置普通用户
|
// 权限检查:站长可批量配置所有人的用户组,管理员只能批量配置普通用户
|
||||||
if (operatorRole !== 'owner') {
|
if (operatorRole !== 'owner') {
|
||||||
for (const targetUsername of usernames) {
|
for (const targetUsername of usernames) {
|
||||||
const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
|
// 先从配置中查找
|
||||||
|
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) {
|
if (targetUser && targetUser.role === 'admin' && targetUsername !== username) {
|
||||||
return NextResponse.json({ error: `管理员无法操作其他管理员 ${targetUsername}` }, { status: 400 });
|
return NextResponse.json({ error: `管理员无法操作其他管理员 ${targetUsername}` }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -440,14 +483,11 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// 批量更新用户组
|
// 批量更新用户组
|
||||||
for (const targetUsername of usernames) {
|
for (const targetUsername of usernames) {
|
||||||
const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
|
// 只更新V2存储
|
||||||
if (targetUser) {
|
if (userGroups && userGroups.length > 0) {
|
||||||
if (userGroups && userGroups.length > 0) {
|
await db.updateUserInfoV2(targetUsername, { tags: userGroups });
|
||||||
targetUser.tags = userGroups;
|
} else {
|
||||||
} else {
|
await db.updateUserInfoV2(targetUsername, { tags: [] });
|
||||||
// 如果为空数组或未提供,则删除该字段,表示无用户组
|
|
||||||
delete targetUser.tags;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
src/app/api/admin/users/route.ts
Normal file
129
src/app/api/admin/users/route.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/* eslint-disable no-console,@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';
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
const adminConfig = await getConfig();
|
||||||
|
|
||||||
|
// 判定操作者角色
|
||||||
|
let operatorRole: 'owner' | 'admin' | 'user' = 'user';
|
||||||
|
if (authInfo.username === process.env.USERNAME) {
|
||||||
|
operatorRole = 'owner';
|
||||||
|
} else {
|
||||||
|
// 优先从新版本获取用户信息
|
||||||
|
const operatorInfo = await db.getUserInfoV2(authInfo.username);
|
||||||
|
if (operatorInfo) {
|
||||||
|
operatorRole = operatorInfo.role;
|
||||||
|
} else {
|
||||||
|
// 回退到配置中查找
|
||||||
|
const userEntry = adminConfig.UserConfig.Users.find(
|
||||||
|
(u) => u.username === authInfo.username
|
||||||
|
);
|
||||||
|
if (userEntry) {
|
||||||
|
operatorRole = userEntry.role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有站长和管理员可以查看用户列表
|
||||||
|
if (operatorRole !== 'owner' && operatorRole !== 'admin') {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分页参数
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '10', 10);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// 获取用户列表(优先使用新版本)
|
||||||
|
const result = await db.getUserListV2(offset, limit, process.env.USERNAME);
|
||||||
|
|
||||||
|
if (result.users.length > 0) {
|
||||||
|
// 使用新版本数据
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
users: result.users,
|
||||||
|
total: result.total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(result.total / limit),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到配置中的用户列表
|
||||||
|
const configUsers = adminConfig.UserConfig.Users || [];
|
||||||
|
const total = configUsers.length;
|
||||||
|
|
||||||
|
// 排序:站长始终在第一位,其他用户按用户名排序
|
||||||
|
const sortedUsers = [...configUsers].sort((a, b) => {
|
||||||
|
if (a.username === process.env.USERNAME) return -1;
|
||||||
|
if (b.username === process.env.USERNAME) return 1;
|
||||||
|
return a.username.localeCompare(b.username);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const paginatedUsers = sortedUsers.slice(offset, offset + limit);
|
||||||
|
|
||||||
|
// 转换为统一格式
|
||||||
|
const users = paginatedUsers.map((u) => ({
|
||||||
|
username: u.username,
|
||||||
|
role: u.role,
|
||||||
|
banned: u.banned || false,
|
||||||
|
tags: u.tags,
|
||||||
|
created_at: 0, // 配置中没有创建时间
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
users,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户列表失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: '获取用户列表失败',
|
||||||
|
details: (error as Error).message,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,15 +148,41 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户是否已存在(通过OIDC sub查找)
|
// 检查用户是否已存在(通过OIDC sub查找)
|
||||||
const existingUser = config.UserConfig.Users.find((u: any) => u.oidcSub === oidcSub);
|
// 优先使用新版本查找
|
||||||
|
let username = await db.getUserByOidcSub(oidcSub);
|
||||||
|
let userRole: 'owner' | 'admin' | 'user' = 'user';
|
||||||
|
|
||||||
if (existingUser) {
|
if (username) {
|
||||||
|
// 从新版本获取用户信息
|
||||||
|
const userInfoV2 = await db.getUserInfoV2(username);
|
||||||
|
if (userInfoV2) {
|
||||||
|
userRole = userInfoV2.role;
|
||||||
|
// 检查用户是否被封禁
|
||||||
|
if (userInfoV2.banned) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL('/login?error=' + encodeURIComponent('用户被封禁'), origin)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 response = NextResponse.redirect(new URL('/', origin));
|
||||||
const cookieValue = await generateAuthCookie(
|
const cookieValue = await generateAuthCookie(username, userRole);
|
||||||
existingUser.username,
|
|
||||||
existingUser.role || 'user'
|
|
||||||
);
|
|
||||||
const expires = new Date();
|
const expires = new Date();
|
||||||
expires.setDate(expires.getDate() + 7);
|
expires.setDate(expires.getDate() + 7);
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,20 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户名是否已存在
|
// 检查用户名是否已存在(优先使用新版本)
|
||||||
|
let userExists = await db.checkUserExistV2(username);
|
||||||
|
if (!userExists) {
|
||||||
|
// 回退到旧版本检查
|
||||||
|
userExists = await db.checkUserExist(username);
|
||||||
|
}
|
||||||
|
if (userExists) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '用户名已存在' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查配置中是否已存在
|
||||||
const existingUser = config.UserConfig.Users.find((u) => u.username === username);
|
const existingUser = config.UserConfig.Users.find((u) => u.username === username);
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -119,9 +132,16 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查OIDC sub是否已被使用
|
// 检查OIDC sub是否已被使用(优先使用新版本)
|
||||||
const existingOIDCUser = config.UserConfig.Users.find((u: any) => u.oidcSub === oidcSession.sub);
|
let existingOIDCUsername = await db.getUserByOidcSub(oidcSession.sub);
|
||||||
if (existingOIDCUser) {
|
if (!existingOIDCUsername) {
|
||||||
|
// 回退到配置中查找
|
||||||
|
const existingOIDCUser = config.UserConfig.Users.find((u: any) => u.oidcSub === oidcSession.sub);
|
||||||
|
if (existingOIDCUser) {
|
||||||
|
existingOIDCUsername = existingOIDCUser.username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (existingOIDCUsername) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '该OIDC账号已被注册' },
|
{ error: '该OIDC账号已被注册' },
|
||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
@@ -132,25 +152,14 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
// 生成随机密码(OIDC用户不需要密码登录)
|
// 生成随机密码(OIDC用户不需要密码登录)
|
||||||
const randomPassword = crypto.randomUUID();
|
const randomPassword = crypto.randomUUID();
|
||||||
await db.registerUser(username, randomPassword);
|
|
||||||
|
|
||||||
// 将用户添加到配置中
|
// 获取默认用户组
|
||||||
const newUser: any = {
|
const defaultTags = siteConfig.DefaultUserTags && siteConfig.DefaultUserTags.length > 0
|
||||||
username: username,
|
? siteConfig.DefaultUserTags
|
||||||
role: 'user',
|
: undefined;
|
||||||
banned: false,
|
|
||||||
oidcSub: oidcSession.sub, // 保存OIDC标识符
|
|
||||||
};
|
|
||||||
|
|
||||||
// 如果配置了默认用户组,分配给新用户
|
// 使用新版本创建用户(带SHA256加密和OIDC绑定)
|
||||||
if (siteConfig.DefaultUserTags && siteConfig.DefaultUserTags.length > 0) {
|
await db.createUserV2(username, randomPassword, 'user', defaultTags, oidcSession.sub);
|
||||||
newUser.tags = siteConfig.DefaultUserTags;
|
|
||||||
}
|
|
||||||
|
|
||||||
config.UserConfig.Users.push(newUser);
|
|
||||||
|
|
||||||
// 保存配置
|
|
||||||
await db.saveAdminConfig(config);
|
|
||||||
|
|
||||||
// 设置认证cookie
|
// 设置认证cookie
|
||||||
const response = NextResponse.json({ ok: true, message: '注册成功' });
|
const response = NextResponse.json({ ok: true, message: '注册成功' });
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改密码
|
// 修改密码(只更新V2存储)
|
||||||
await db.changePassword(username, newPassword);
|
await db.changePasswordV2(username, newPassword);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
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';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,6 +31,11 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 特殊处理 openlist
|
||||||
|
if (apiUrl === 'openlist') {
|
||||||
|
return handleOpenListProxy(request);
|
||||||
|
}
|
||||||
|
|
||||||
// 构建完整的 API 请求 URL,包含所有查询参数
|
// 构建完整的 API 请求 URL,包含所有查询参数
|
||||||
const targetUrl = new URL(apiUrl);
|
const targetUrl = new URL(apiUrl);
|
||||||
|
|
||||||
@@ -237,3 +252,170 @@ function processUrl(url: string, playFrom: string, proxyOrigin: string, tokenPar
|
|||||||
// 非 m3u8 链接不处理
|
// 非 m3u8 链接不处理
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 OpenList 代理请求
|
||||||
|
*/
|
||||||
|
async function handleOpenListProxy(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const wd = searchParams.get('wd'); // 搜索关键词
|
||||||
|
const ids = searchParams.get('ids'); // 详情ID
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const openListConfig = config.OpenListConfig;
|
||||||
|
|
||||||
|
if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ code: 0, msg: 'OpenList 未配置', list: [] },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
|
const client = new OpenListClient(
|
||||||
|
openListConfig.URL,
|
||||||
|
openListConfig.Username,
|
||||||
|
openListConfig.Password
|
||||||
|
);
|
||||||
|
|
||||||
|
// 读取 metainfo (从数据库或缓存)
|
||||||
|
let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath);
|
||||||
|
|
||||||
|
if (!metaInfo) {
|
||||||
|
try {
|
||||||
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
|
if (metainfoJson) {
|
||||||
|
metaInfo = JSON.parse(metainfoJson) as MetaInfo;
|
||||||
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ code: 0, msg: 'metainfo 不存在', list: [] },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metaInfo) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ code: 0, msg: '无数据', list: [] },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索模式
|
||||||
|
if (wd) {
|
||||||
|
const results = Object.entries(metaInfo.folders)
|
||||||
|
.filter(
|
||||||
|
([folderName, info]) =>
|
||||||
|
folderName.toLowerCase().includes(wd.toLowerCase()) ||
|
||||||
|
info.title.toLowerCase().includes(wd.toLowerCase())
|
||||||
|
)
|
||||||
|
.map(([folderName, info]) => ({
|
||||||
|
vod_id: folderName,
|
||||||
|
vod_name: info.title,
|
||||||
|
vod_pic: getTMDBImageUrl(info.poster_path),
|
||||||
|
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
||||||
|
vod_year: info.release_date.split('-')[0] || '',
|
||||||
|
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 1,
|
||||||
|
msg: '数据列表',
|
||||||
|
page: 1,
|
||||||
|
pagecount: 1,
|
||||||
|
limit: results.length,
|
||||||
|
total: results.length,
|
||||||
|
list: results,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详情模式
|
||||||
|
if (ids) {
|
||||||
|
const folderName = ids;
|
||||||
|
const info = metaInfo.folders[folderName];
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ code: 0, msg: '视频不存在', list: [] },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取视频详情
|
||||||
|
try {
|
||||||
|
const detailResponse = await fetch(
|
||||||
|
`${request.headers.get('x-forwarded-proto') || 'http'}://${request.headers.get('host')}/api/openlist/detail?folder=${encodeURIComponent(folderName)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!detailResponse.ok) {
|
||||||
|
throw new Error('获取视频详情失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailData = await detailResponse.json();
|
||||||
|
|
||||||
|
if (!detailData.success) {
|
||||||
|
throw new Error('获取视频详情失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建播放列表
|
||||||
|
const playUrls = detailData.episodes
|
||||||
|
.map((ep: any) => {
|
||||||
|
const title = ep.title || `第${ep.episode}集`;
|
||||||
|
return `${title}$${ep.playUrl}`;
|
||||||
|
})
|
||||||
|
.join('#');
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 1,
|
||||||
|
msg: '数据列表',
|
||||||
|
page: 1,
|
||||||
|
pagecount: 1,
|
||||||
|
limit: 1,
|
||||||
|
total: 1,
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
vod_id: folderName,
|
||||||
|
vod_name: info.title,
|
||||||
|
vod_pic: getTMDBImageUrl(info.poster_path),
|
||||||
|
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
||||||
|
vod_year: info.release_date.split('-')[0] || '',
|
||||||
|
vod_content: info.overview,
|
||||||
|
vod_play_from: 'OpenList',
|
||||||
|
vod_play_url: playUrls,
|
||||||
|
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 OpenList 视频详情失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ code: 0, msg: '获取详情失败', list: [] },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回所有视频
|
||||||
|
const results = Object.entries(metaInfo.folders).map(
|
||||||
|
([folderName, info]) => ({
|
||||||
|
vod_id: folderName,
|
||||||
|
vod_name: info.title,
|
||||||
|
vod_pic: getTMDBImageUrl(info.poster_path),
|
||||||
|
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
||||||
|
vod_year: info.release_date.split('-')[0] || '',
|
||||||
|
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 1,
|
||||||
|
msg: '数据列表',
|
||||||
|
page: 1,
|
||||||
|
pagecount: 1,
|
||||||
|
limit: results.length,
|
||||||
|
total: results.length,
|
||||||
|
list: results,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getConfig, refineConfig } from '@/lib/config';
|
import { getConfig, refineConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db, getStorage } from '@/lib/db';
|
||||||
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
||||||
import { refreshLiveChannels } from '@/lib/live';
|
import { refreshLiveChannels } from '@/lib/live';
|
||||||
import { SearchResult } from '@/lib/types';
|
import { SearchResult } from '@/lib/types';
|
||||||
@@ -41,6 +40,7 @@ export async function GET(request: NextRequest) {
|
|||||||
async function cronJob() {
|
async function cronJob() {
|
||||||
await refreshConfig();
|
await refreshConfig();
|
||||||
await refreshAllLiveChannels();
|
await refreshAllLiveChannels();
|
||||||
|
await refreshOpenList();
|
||||||
await refreshRecordAndFavorites();
|
await refreshRecordAndFavorites();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +155,7 @@ async function refreshRecordAndFavorites() {
|
|||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
console.log(`开始处理用户: ${user}`);
|
console.log(`开始处理用户: ${user}`);
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
// 播放记录
|
// 播放记录
|
||||||
try {
|
try {
|
||||||
@@ -215,6 +216,7 @@ async function refreshRecordAndFavorites() {
|
|||||||
);
|
);
|
||||||
const totalFavorites = Object.keys(favorites).length;
|
const totalFavorites = Object.keys(favorites).length;
|
||||||
let processedFavorites = 0;
|
let processedFavorites = 0;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
for (const [key, fav] of Object.entries(favorites)) {
|
for (const [key, fav] of Object.entries(favorites)) {
|
||||||
try {
|
try {
|
||||||
@@ -244,6 +246,26 @@ async function refreshRecordAndFavorites() {
|
|||||||
console.log(
|
console.log(
|
||||||
`更新收藏: ${fav.title} (${fav.total_episodes} -> ${favEpisodeCount})`
|
`更新收藏: ${fav.title} (${fav.total_episodes} -> ${favEpisodeCount})`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 创建通知
|
||||||
|
const notification = {
|
||||||
|
id: `fav_update_${source}_${id}_${now}`,
|
||||||
|
type: 'favorite_update' as const,
|
||||||
|
title: '收藏更新',
|
||||||
|
message: `《${fav.title}》有新集数更新!从 ${fav.total_episodes} 集更新到 ${favEpisodeCount} 集`,
|
||||||
|
timestamp: now,
|
||||||
|
read: false,
|
||||||
|
metadata: {
|
||||||
|
source,
|
||||||
|
id,
|
||||||
|
title: fav.title,
|
||||||
|
old_episodes: fav.total_episodes,
|
||||||
|
new_episodes: favEpisodeCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await storage.addNotification(user, notification);
|
||||||
|
console.log(`已为用户 ${user} 创建收藏更新通知: ${fav.title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
processedFavorites++;
|
processedFavorites++;
|
||||||
@@ -264,3 +286,66 @@ async function refreshRecordAndFavorites() {
|
|||||||
console.error('刷新播放记录/收藏任务启动失败', err);
|
console.error('刷新播放记录/收藏任务启动失败', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshOpenList() {
|
||||||
|
try {
|
||||||
|
const config = await getConfig();
|
||||||
|
const openListConfig = config.OpenListConfig;
|
||||||
|
|
||||||
|
// 检查功能是否启用
|
||||||
|
if (!openListConfig || !openListConfig.Enabled) {
|
||||||
|
console.log('跳过 OpenList 扫描:功能未启用');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否配置了 OpenList 和定时扫描
|
||||||
|
if (!openListConfig.URL || !openListConfig.Username || !openListConfig.Password) {
|
||||||
|
console.log('跳过 OpenList 扫描:未配置');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanInterval = openListConfig.ScanInterval || 0;
|
||||||
|
if (scanInterval === 0) {
|
||||||
|
console.log('跳过 OpenList 扫描:定时扫描已关闭');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查间隔时间是否满足最低要求(60分钟)
|
||||||
|
if (scanInterval < 60) {
|
||||||
|
console.log(`跳过 OpenList 扫描:间隔时间 ${scanInterval} 分钟小于最低要求 60 分钟`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查上次扫描时间
|
||||||
|
const lastRefreshTime = openListConfig.LastRefreshTime || 0;
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastRefresh = now - lastRefreshTime;
|
||||||
|
const intervalMs = scanInterval * 60 * 1000;
|
||||||
|
|
||||||
|
if (timeSinceLastRefresh < intervalMs) {
|
||||||
|
const remainingMinutes = Math.ceil((intervalMs - timeSinceLastRefresh) / 60000);
|
||||||
|
console.log(`跳过 OpenList 扫描:距离上次扫描仅 ${Math.floor(timeSinceLastRefresh / 60000)} 分钟,还需等待 ${remainingMinutes} 分钟`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`开始 OpenList 定时扫描(间隔: ${scanInterval} 分钟)`);
|
||||||
|
|
||||||
|
// 调用扫描接口(立即扫描模式,不清空 metainfo)
|
||||||
|
const response = await fetch(`${process.env.SITE_BASE || 'http://localhost:3000'}/api/openlist/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ clearMetaInfo: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`扫描请求失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('OpenList 定时扫描已启动,任务ID:', result.taskId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('OpenList 定时扫描失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,16 +16,13 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
|
||||||
// 非站长,检查用户存在或被封禁
|
// 非站长,检查用户存在或被封禁
|
||||||
const user = config.UserConfig.Users.find(
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
(u) => u.username === authInfo.username
|
if (!userInfoV2) {
|
||||||
);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (user.banned) {
|
if (userInfoV2.banned) {
|
||||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,16 +52,13 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminConfig = await getConfig();
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
|
||||||
// 非站长,检查用户存在或被封禁
|
// 非站长,检查用户存在或被封禁
|
||||||
const user = adminConfig.UserConfig.Users.find(
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
(u) => u.username === authInfo.username
|
if (!userInfoV2) {
|
||||||
);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (user.banned) {
|
if (userInfoV2.banned) {
|
||||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// 添加超时控制和重试机制
|
// 添加超时控制和重试机制
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
const timeoutId = setTimeout(() => controller.abort(), 30000); // 10秒超时
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(apiUrl, {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// 添加超时控制
|
// 添加超时控制
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
const timeoutId = setTimeout(() => controller.abort(), 30000); // 10秒超时
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(apiUrl, {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// 添加超时控制
|
// 添加超时控制
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
const timeoutId = setTimeout(() => controller.abort(), 30000); // 10秒超时
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(apiUrl, {
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
|
||||||
|
|
||||||
// 调试信息
|
|
||||||
const debugInfo = {
|
|
||||||
storageType,
|
|
||||||
envVars: {
|
|
||||||
hasRedisUrl: !!process.env.REDIS_URL,
|
|
||||||
hasUpstashUrl: !!process.env.UPSTASH_REDIS_REST_URL,
|
|
||||||
hasUpstashToken: !!process.env.UPSTASH_REDIS_REST_TOKEN,
|
|
||||||
hasKvrocksUrl: !!process.env.KVROCKS_URL,
|
|
||||||
watchRoomEnabled: process.env.WATCH_ROOM_ENABLED,
|
|
||||||
watchRoomServerType: process.env.WATCH_ROOM_SERVER_TYPE,
|
|
||||||
hasWatchRoomExternalUrl: !!process.env.WATCH_ROOM_EXTERNAL_SERVER_URL,
|
|
||||||
hasWatchRoomExternalAuth: !!process.env.WATCH_ROOM_EXTERNAL_SERVER_AUTH,
|
|
||||||
},
|
|
||||||
watchRoomConfig: {
|
|
||||||
enabled: process.env.WATCH_ROOM_ENABLED === 'true',
|
|
||||||
serverType: process.env.WATCH_ROOM_SERVER_TYPE || 'internal',
|
|
||||||
externalServerUrl: process.env.WATCH_ROOM_EXTERNAL_SERVER_URL,
|
|
||||||
externalServerAuth: process.env.WATCH_ROOM_EXTERNAL_SERVER_AUTH ? '***' : undefined,
|
|
||||||
},
|
|
||||||
configReadError: null as string | null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 尝试读取配置(验证数据库连接)
|
|
||||||
try {
|
|
||||||
await getConfig();
|
|
||||||
} catch (error) {
|
|
||||||
debugInfo.configReadError = (error as Error).message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(debugInfo, {
|
|
||||||
headers: {
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Debug API error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'Failed to get debug info',
|
|
||||||
details: (error as Error).message,
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
|
||||||
import { getDetailFromApi } from '@/lib/downstream';
|
import { getDetailFromApi } from '@/lib/downstream';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -20,6 +20,149 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 特殊处理 openlist 源
|
||||||
|
if (sourceCode === 'openlist') {
|
||||||
|
try {
|
||||||
|
const config = await getConfig();
|
||||||
|
const openListConfig = config.OpenListConfig;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!openListConfig ||
|
||||||
|
!openListConfig.Enabled ||
|
||||||
|
!openListConfig.URL ||
|
||||||
|
!openListConfig.Username ||
|
||||||
|
!openListConfig.Password
|
||||||
|
) {
|
||||||
|
throw new Error('OpenList 未配置或未启用');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
|
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${id}`;
|
||||||
|
|
||||||
|
// 1. 读取 metainfo 获取元数据
|
||||||
|
let metaInfo: any = null;
|
||||||
|
try {
|
||||||
|
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||||
|
const { db } = await import('@/lib/db');
|
||||||
|
|
||||||
|
metaInfo = getCachedMetaInfo(rootPath);
|
||||||
|
|
||||||
|
if (!metaInfo) {
|
||||||
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
|
if (metainfoJson) {
|
||||||
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 直接调用 OpenList 客户端获取视频列表
|
||||||
|
const { OpenListClient } = await import('@/lib/openlist.client');
|
||||||
|
const { getCachedVideoInfo, setCachedVideoInfo } = await import('@/lib/openlist-cache');
|
||||||
|
const { parseVideoFileName } = await import('@/lib/video-parser');
|
||||||
|
|
||||||
|
const client = new OpenListClient(
|
||||||
|
openListConfig.URL,
|
||||||
|
openListConfig.Username,
|
||||||
|
openListConfig.Password
|
||||||
|
);
|
||||||
|
|
||||||
|
let videoInfo = getCachedVideoInfo(folderPath);
|
||||||
|
|
||||||
|
if (!videoInfo) {
|
||||||
|
try {
|
||||||
|
const videoinfoPath = `${folderPath}/videoinfo.json`;
|
||||||
|
const fileResponse = await client.getFile(videoinfoPath);
|
||||||
|
|
||||||
|
if (fileResponse.code === 200 && fileResponse.data.raw_url) {
|
||||||
|
const contentResponse = await fetch(fileResponse.data.raw_url);
|
||||||
|
const content = await contentResponse.text();
|
||||||
|
videoInfo = JSON.parse(content);
|
||||||
|
if (videoInfo) {
|
||||||
|
setCachedVideoInfo(folderPath, videoInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listResponse = await client.listDirectory(folderPath);
|
||||||
|
|
||||||
|
if (listResponse.code !== 200) {
|
||||||
|
throw new Error('OpenList 列表获取失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
if (item.is_dir || item.name.startsWith('.') || item.name.endsWith('.json')) return false;
|
||||||
|
return videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!videoInfo) {
|
||||||
|
videoInfo = { episodes: {}, last_updated: Date.now() };
|
||||||
|
videoFiles.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
for (let i = 0; i < videoFiles.length; i++) {
|
||||||
|
const file = videoFiles[i];
|
||||||
|
const parsed = parseVideoFileName(file.name);
|
||||||
|
videoInfo.episodes[file.name] = {
|
||||||
|
episode: parsed.episode || (i + 1),
|
||||||
|
season: parsed.season,
|
||||||
|
title: parsed.title,
|
||||||
|
parsed_from: 'filename',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setCachedVideoInfo(folderPath, videoInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodes = videoFiles
|
||||||
|
.map((file, index) => {
|
||||||
|
const parsed = parseVideoFileName(file.name);
|
||||||
|
let episodeInfo;
|
||||||
|
if (parsed.episode) {
|
||||||
|
episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename' };
|
||||||
|
} 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}集`;
|
||||||
|
}
|
||||||
|
if (!displayTitle) {
|
||||||
|
displayTitle = file.name;
|
||||||
|
}
|
||||||
|
return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName));
|
||||||
|
|
||||||
|
// 3. 从 metainfo 中获取元数据
|
||||||
|
const folderMeta = metaInfo?.folders?.[id];
|
||||||
|
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
source: 'openlist',
|
||||||
|
source_name: '私人影库',
|
||||||
|
id: id,
|
||||||
|
title: folderMeta?.title || id,
|
||||||
|
poster: folderMeta?.poster_path ? getTMDBImageUrl(folderMeta.poster_path) : '',
|
||||||
|
year: folderMeta?.release_date ? folderMeta.release_date.split('-')[0] : '',
|
||||||
|
douban_id: 0,
|
||||||
|
desc: folderMeta?.overview || '',
|
||||||
|
episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(id)}&fileName=${encodeURIComponent(ep.fileName)}`),
|
||||||
|
episodes_titles: episodes.map((ep) => ep.title),
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!/^[\w-]+$/.test(id)) {
|
if (!/^[\w-]+$/.test(id)) {
|
||||||
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
|
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,16 +24,13 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
|
||||||
// 非站长,检查用户存在或被封禁
|
// 非站长,检查用户存在或被封禁
|
||||||
const user = config.UserConfig.Users.find(
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
(u) => u.username === authInfo.username
|
if (!userInfoV2) {
|
||||||
);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (user.banned) {
|
if (userInfoV2.banned) {
|
||||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,16 +75,13 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
|
||||||
// 非站长,检查用户存在或被封禁
|
// 非站长,检查用户存在或被封禁
|
||||||
const user = config.UserConfig.Users.find(
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
(u) => u.username === authInfo.username
|
if (!userInfoV2) {
|
||||||
);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (user.banned) {
|
if (userInfoV2.banned) {
|
||||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,16 +143,13 @@ export async function DELETE(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
|
||||||
// 非站长,检查用户存在或被封禁
|
// 非站长,检查用户存在或被封禁
|
||||||
const user = config.UserConfig.Users.find(
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
(u) => u.username === authInfo.username
|
if (!userInfoV2) {
|
||||||
);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (user.banned) {
|
if (userInfoV2.banned) {
|
||||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,44 +217,69 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const user = config.UserConfig.Users.find((u) => u.username === username);
|
const user = config.UserConfig.Users.find((u) => u.username === username);
|
||||||
if (user && user.banned) {
|
|
||||||
|
// 优先使用新版本的用户验证
|
||||||
|
let pass = false;
|
||||||
|
let userRole: 'owner' | 'admin' | 'user' = 'user';
|
||||||
|
let isBanned = false;
|
||||||
|
|
||||||
|
// 尝试使用新版本验证
|
||||||
|
const userInfoV2 = await db.getUserInfoV2(username);
|
||||||
|
|
||||||
|
if (userInfoV2) {
|
||||||
|
// 使用新版本验证
|
||||||
|
pass = await db.verifyUserV2(username, password);
|
||||||
|
userRole = userInfoV2.role;
|
||||||
|
isBanned = userInfoV2.banned;
|
||||||
|
} else {
|
||||||
|
// 回退到旧版本验证
|
||||||
|
try {
|
||||||
|
pass = await db.verifyUser(username, password);
|
||||||
|
// 从配置中获取角色和封禁状态
|
||||||
|
if (user) {
|
||||||
|
userRole = user.role;
|
||||||
|
isBanned = user.banned || false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('数据库验证失败', err);
|
||||||
|
return NextResponse.json({ error: '数据库错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否被封禁
|
||||||
|
if (isBanned) {
|
||||||
return NextResponse.json({ error: '用户被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验用户密码
|
if (!pass) {
|
||||||
try {
|
return NextResponse.json(
|
||||||
const pass = await db.verifyUser(username, password);
|
{ error: '用户名或密码错误' },
|
||||||
if (!pass) {
|
{ status: 401 }
|
||||||
return NextResponse.json(
|
);
|
||||||
{ error: '用户名或密码错误' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证成功,设置认证cookie
|
|
||||||
const response = NextResponse.json({ ok: true });
|
|
||||||
const cookieValue = await generateAuthCookie(
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
user?.role || 'user',
|
|
||||||
false
|
|
||||||
); // 数据库模式不包含 password
|
|
||||||
const expires = new Date();
|
|
||||||
expires.setDate(expires.getDate() + 7); // 7天过期
|
|
||||||
|
|
||||||
response.cookies.set('auth', cookieValue, {
|
|
||||||
path: '/',
|
|
||||||
expires,
|
|
||||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
|
||||||
httpOnly: false, // PWA 需要客户端可访问
|
|
||||||
secure: false, // 根据协议自动设置
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('数据库验证失败', err);
|
|
||||||
return NextResponse.json({ error: '数据库错误' }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证成功,设置认证cookie
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
const cookieValue = await generateAuthCookie(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
userRole,
|
||||||
|
false
|
||||||
|
); // 数据库模式不包含 password
|
||||||
|
const expires = new Date();
|
||||||
|
expires.setDate(expires.getDate() + 7); // 7天过期
|
||||||
|
|
||||||
|
response.cookies.set('auth', cookieValue, {
|
||||||
|
path: '/',
|
||||||
|
expires,
|
||||||
|
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||||
|
httpOnly: false, // PWA 需要客户端可访问
|
||||||
|
secure: false, // 根据协议自动设置
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Cookie已设置`);
|
||||||
|
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('登录接口异常', error);
|
console.error('登录接口异常', error);
|
||||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||||
|
|||||||
61
src/app/api/openlist/check/route.ts
Normal file
61
src/app/api/openlist/check/route.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { OpenListClient } from '@/lib/openlist.client';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/openlist/check
|
||||||
|
* 检查 OpenList 连通性
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 权限检查
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取请求参数
|
||||||
|
const body = await request.json();
|
||||||
|
const { url, username, password } = body;
|
||||||
|
|
||||||
|
if (!url || !username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '缺少必要参数' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建客户端并检查连通性
|
||||||
|
const client = new OpenListClient(url, username, password);
|
||||||
|
const result = await client.checkConnectivity();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: result.message,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查 OpenList 连通性失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '检查失败',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/app/api/openlist/correct/route.ts
Normal file
122
src/app/api/openlist/correct/route.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/* 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';
|
||||||
|
import { OpenListClient } from '@/lib/openlist.client';
|
||||||
|
import {
|
||||||
|
getCachedMetaInfo,
|
||||||
|
invalidateMetaInfoCache,
|
||||||
|
MetaInfo,
|
||||||
|
setCachedMetaInfo,
|
||||||
|
} from '@/lib/openlist-cache';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/openlist/correct
|
||||||
|
* 纠正视频的TMDB映射
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { folder, tmdbId, title, posterPath, releaseDate, overview, voteAverage, mediaType } = body;
|
||||||
|
|
||||||
|
if (!folder || !tmdbId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '缺少必要参数' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const openListConfig = config.OpenListConfig;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!openListConfig ||
|
||||||
|
!openListConfig.Enabled ||
|
||||||
|
!openListConfig.URL ||
|
||||||
|
!openListConfig.Username ||
|
||||||
|
!openListConfig.Password
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'OpenList 未配置或未启用' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
|
const client = new OpenListClient(
|
||||||
|
openListConfig.URL,
|
||||||
|
openListConfig.Username,
|
||||||
|
openListConfig.Password
|
||||||
|
);
|
||||||
|
|
||||||
|
// 读取现有 metainfo (从数据库或缓存)
|
||||||
|
let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath);
|
||||||
|
|
||||||
|
if (!metaInfo) {
|
||||||
|
try {
|
||||||
|
console.log('[OpenList Correct] 尝试从数据库读取 metainfo');
|
||||||
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
|
|
||||||
|
if (metainfoJson) {
|
||||||
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[OpenList Correct] 从数据库读取 metainfo 失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'metainfo 读取失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metaInfo) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'metainfo.json 不存在' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新视频信息
|
||||||
|
metaInfo.folders[folder] = {
|
||||||
|
tmdb_id: tmdbId,
|
||||||
|
title: title,
|
||||||
|
poster_path: posterPath,
|
||||||
|
release_date: releaseDate || '',
|
||||||
|
overview: overview || '',
|
||||||
|
vote_average: voteAverage || 0,
|
||||||
|
media_type: mediaType,
|
||||||
|
last_updated: Date.now(),
|
||||||
|
failed: false, // 纠错后标记为成功
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存 metainfo 到数据库
|
||||||
|
const metainfoContent = JSON.stringify(metaInfo);
|
||||||
|
|
||||||
|
await db.setGlobalValue('video.metainfo', metainfoContent);
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
invalidateMetaInfoCache(rootPath);
|
||||||
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '纠错成功',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('视频纠错失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '纠错失败', details: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/app/api/openlist/delete/route.ts
Normal file
101
src/app/api/openlist/delete/route.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/* 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';
|
||||||
|
import {
|
||||||
|
getCachedMetaInfo,
|
||||||
|
invalidateMetaInfoCache,
|
||||||
|
MetaInfo,
|
||||||
|
setCachedMetaInfo,
|
||||||
|
} from '@/lib/openlist-cache';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/openlist/delete
|
||||||
|
* 删除私人影库中的视频记录
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 权限检查
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取请求参数
|
||||||
|
const body = await request.json();
|
||||||
|
const { folder } = body;
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
return NextResponse.json({ error: '缺少 folder 参数' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
const config = await getConfig();
|
||||||
|
const openListConfig = config.OpenListConfig;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!openListConfig ||
|
||||||
|
!openListConfig.Enabled ||
|
||||||
|
!openListConfig.URL
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'OpenList 未配置或未启用' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
|
|
||||||
|
// 从数据库读取 metainfo
|
||||||
|
const metainfoContent = await db.getGlobalValue('video.metainfo');
|
||||||
|
if (!metainfoContent) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '未找到视频元数据' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaInfo: MetaInfo = JSON.parse(metainfoContent);
|
||||||
|
|
||||||
|
// 检查文件夹是否存在
|
||||||
|
if (!metaInfo.folders[folder]) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '未找到该视频记录' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除文件夹记录
|
||||||
|
delete metaInfo.folders[folder];
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
const updatedMetainfoContent = JSON.stringify(metaInfo);
|
||||||
|
await db.setGlobalValue('video.metainfo', updatedMetainfoContent);
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
invalidateMetaInfoCache(rootPath);
|
||||||
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
|
|
||||||
|
// 更新配置中的资源数量
|
||||||
|
if (config.OpenListConfig) {
|
||||||
|
config.OpenListConfig.ResourceCount = Object.keys(metaInfo.folders).length;
|
||||||
|
await db.saveAdminConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '删除成功',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除视频记录失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '删除失败', details: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
231
src/app/api/openlist/detail/route.ts
Normal file
231
src/app/api/openlist/detail/route.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
/* 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 { OpenListClient } from '@/lib/openlist.client';
|
||||||
|
import {
|
||||||
|
getCachedVideoInfo,
|
||||||
|
setCachedVideoInfo,
|
||||||
|
VideoInfo,
|
||||||
|
} from '@/lib/openlist-cache';
|
||||||
|
import { parseVideoFileName } from '@/lib/video-parser';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/openlist/detail?folder=xxx
|
||||||
|
* 获取视频文件夹的详细信息
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const folderName = searchParams.get('folder');
|
||||||
|
|
||||||
|
if (!folderName) {
|
||||||
|
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const openListConfig = config.OpenListConfig;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!openListConfig ||
|
||||||
|
!openListConfig.Enabled ||
|
||||||
|
!openListConfig.URL ||
|
||||||
|
!openListConfig.Username ||
|
||||||
|
!openListConfig.Password
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
|
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`;
|
||||||
|
const client = new OpenListClient(
|
||||||
|
openListConfig.URL,
|
||||||
|
openListConfig.Username,
|
||||||
|
openListConfig.Password
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. 尝试读取缓存的 videoinfo.json
|
||||||
|
let videoInfo: VideoInfo | null = getCachedVideoInfo(folderPath);
|
||||||
|
|
||||||
|
if (!videoInfo) {
|
||||||
|
// 2. 尝试从 OpenList 读取 videoinfo.json
|
||||||
|
try {
|
||||||
|
const videoinfoPath = `${folderPath}/videoinfo.json`;
|
||||||
|
const fileResponse = await client.getFile(videoinfoPath);
|
||||||
|
|
||||||
|
if (fileResponse.code === 200 && fileResponse.data.raw_url) {
|
||||||
|
const downloadUrl = fileResponse.data.raw_url;
|
||||||
|
const contentResponse = await fetch(downloadUrl, {
|
||||||
|
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': '*/*',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const content = await contentResponse.text();
|
||||||
|
videoInfo = JSON.parse(content);
|
||||||
|
|
||||||
|
// 缓存
|
||||||
|
if (videoInfo) {
|
||||||
|
setCachedVideoInfo(folderPath, videoInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('videoinfo.json 不存在,将解析文件名');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 如果没有 videoinfo.json,列出文件夹并解析
|
||||||
|
if (!videoInfo) {
|
||||||
|
const listResponse = await client.listDirectory(folderPath);
|
||||||
|
|
||||||
|
if (listResponse.code !== 200) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'OpenList 列表获取失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤视频文件
|
||||||
|
const videoFiles = listResponse.data.content.filter(
|
||||||
|
(item) =>
|
||||||
|
!item.is_dir &&
|
||||||
|
!item.name.endsWith('.json') && // 排除 JSON 文件
|
||||||
|
!item.name.startsWith('.') && // 排除隐藏文件
|
||||||
|
(item.name.endsWith('.mp4') ||
|
||||||
|
item.name.endsWith('.mkv') ||
|
||||||
|
item.name.endsWith('.avi') ||
|
||||||
|
item.name.endsWith('.m3u8') ||
|
||||||
|
item.name.endsWith('.flv') ||
|
||||||
|
item.name.endsWith('.ts'))
|
||||||
|
);
|
||||||
|
|
||||||
|
videoInfo = {
|
||||||
|
episodes: {},
|
||||||
|
last_updated: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 按文件名排序,确保顺序一致
|
||||||
|
videoFiles.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
// 解析文件名
|
||||||
|
for (let i = 0; i < videoFiles.length; i++) {
|
||||||
|
const file = videoFiles[i];
|
||||||
|
const parsed = parseVideoFileName(file.name);
|
||||||
|
|
||||||
|
videoInfo.episodes[file.name] = {
|
||||||
|
episode: parsed.episode || (i + 1), // 如果解析失败,使用索引+1作为集数
|
||||||
|
season: parsed.season,
|
||||||
|
title: parsed.title,
|
||||||
|
parsed_from: 'filename',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅缓存到内存,不再持久化到 OpenList
|
||||||
|
setCachedVideoInfo(folderPath, videoInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 获取视频文件列表(不获取播放链接,使用懒加载)
|
||||||
|
const listResponse = await client.listDirectory(folderPath);
|
||||||
|
|
||||||
|
// 定义视频文件扩展名(不区分大小写)
|
||||||
|
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) => {
|
||||||
|
// 排除文件夹
|
||||||
|
if (item.is_dir) return false;
|
||||||
|
|
||||||
|
// 排除隐藏文件
|
||||||
|
if (item.name.startsWith('.')) return false;
|
||||||
|
|
||||||
|
// 排除 JSON 文件
|
||||||
|
if (item.name.endsWith('.json')) return false;
|
||||||
|
|
||||||
|
// 检查是否是视频文件(不区分大小写)
|
||||||
|
const lowerName = item.name.toLowerCase();
|
||||||
|
return videoExtensions.some(ext => lowerName.endsWith(ext));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 构建集数信息(不包含播放链接)
|
||||||
|
// 确保所有视频文件都被显示,即使 videoInfo 中没有记录
|
||||||
|
const episodes = videoFiles
|
||||||
|
.map((file, index) => {
|
||||||
|
// 总是重新解析文件名,确保使用最新的解析逻辑
|
||||||
|
const parsed = parseVideoFileName(file.name);
|
||||||
|
|
||||||
|
// 如果解析成功,使用解析结果;否则使用 videoInfo 中的记录或索引
|
||||||
|
let episodeInfo;
|
||||||
|
if (parsed.episode) {
|
||||||
|
episodeInfo = {
|
||||||
|
episode: parsed.episode,
|
||||||
|
season: parsed.season,
|
||||||
|
title: parsed.title,
|
||||||
|
parsed_from: 'filename',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 如果解析失败,尝试从 videoInfo 获取
|
||||||
|
episodeInfo = videoInfo!.episodes[file.name];
|
||||||
|
if (!episodeInfo) {
|
||||||
|
// 如果 videoInfo 中也没有,使用索引
|
||||||
|
episodeInfo = {
|
||||||
|
episode: index + 1,
|
||||||
|
season: undefined,
|
||||||
|
title: undefined,
|
||||||
|
parsed_from: 'filename',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先使用解析出的标题,其次是"第X集"格式,最后才是文件名
|
||||||
|
let displayTitle = episodeInfo.title;
|
||||||
|
if (!displayTitle && episodeInfo.episode) {
|
||||||
|
// 支持小数集数显示
|
||||||
|
displayTitle = `第${episodeInfo.episode}集`;
|
||||||
|
}
|
||||||
|
if (!displayTitle) {
|
||||||
|
displayTitle = file.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileName: file.name,
|
||||||
|
episode: episodeInfo.episode || 0,
|
||||||
|
season: episodeInfo.season,
|
||||||
|
title: displayTitle,
|
||||||
|
size: file.size,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// 确保排序稳定,即使 episode 相同也按文件名排序
|
||||||
|
if (a.episode !== b.episode) {
|
||||||
|
return a.episode - b.episode;
|
||||||
|
}
|
||||||
|
return a.fileName.localeCompare(b.fileName);
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
folder: folderName,
|
||||||
|
episodes,
|
||||||
|
videoInfo,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取视频详情失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取失败', details: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src/app/api/openlist/list/route.ts
Normal file
166
src/app/api/openlist/list/route.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/* 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 { OpenListClient } from '@/lib/openlist.client';
|
||||||
|
import {
|
||||||
|
getCachedMetaInfo,
|
||||||
|
MetaInfo,
|
||||||
|
setCachedMetaInfo,
|
||||||
|
} from '@/lib/openlist-cache';
|
||||||
|
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/openlist/list?page=1&pageSize=20&includeFailed=false&noCache=false
|
||||||
|
* 获取私人影库视频列表
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = parseInt(searchParams.get('page') || '1');
|
||||||
|
const pageSize = parseInt(searchParams.get('pageSize') || '20');
|
||||||
|
const includeFailed = searchParams.get('includeFailed') === 'true';
|
||||||
|
const noCache = searchParams.get('noCache') === 'true';
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const openListConfig = config.OpenListConfig;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!openListConfig ||
|
||||||
|
!openListConfig.Enabled ||
|
||||||
|
!openListConfig.URL ||
|
||||||
|
!openListConfig.Username ||
|
||||||
|
!openListConfig.Password
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'OpenList 未配置或未启用', list: [], total: 0 },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
|
const client = new OpenListClient(
|
||||||
|
openListConfig.URL,
|
||||||
|
openListConfig.Username,
|
||||||
|
openListConfig.Password
|
||||||
|
);
|
||||||
|
|
||||||
|
// 读取 metainfo (从数据库或缓存)
|
||||||
|
let metaInfo: MetaInfo | null = null;
|
||||||
|
|
||||||
|
// 如果不使用缓存,直接从数据库读取
|
||||||
|
if (noCache) {
|
||||||
|
// noCache 模式:跳过缓存
|
||||||
|
} else {
|
||||||
|
metaInfo = getCachedMetaInfo(rootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metaInfo) {
|
||||||
|
try {
|
||||||
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
|
|
||||||
|
if (metainfoJson) {
|
||||||
|
try {
|
||||||
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
|
|
||||||
|
// 验证数据结构
|
||||||
|
if (!metaInfo || typeof metaInfo !== 'object') {
|
||||||
|
throw new Error('metaInfo 不是有效对象');
|
||||||
|
}
|
||||||
|
if (!metaInfo.folders || typeof metaInfo.folders !== 'object') {
|
||||||
|
throw new Error('metaInfo.folders 不存在或不是对象');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有在不是 noCache 模式时才更新缓存
|
||||||
|
if (!noCache) {
|
||||||
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('[OpenList List] JSON 解析或验证失败:', parseError);
|
||||||
|
throw new Error(`JSON 解析失败: ${(parseError as Error).message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('数据库中没有 metainfo 数据');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[OpenList List] 从数据库读取 metainfo 失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'metainfo 读取失败',
|
||||||
|
details: (error as Error).message,
|
||||||
|
list: [],
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metaInfo) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '无数据', list: [], total: 0 },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 metaInfo 结构
|
||||||
|
if (!metaInfo.folders || typeof metaInfo.folders !== 'object') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'metainfo.json 结构无效', list: [], total: 0 },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为数组并分页
|
||||||
|
const allVideos = Object.entries(metaInfo.folders)
|
||||||
|
.filter(([, info]) => includeFailed || !info.failed) // 根据参数过滤失败的视频
|
||||||
|
.map(
|
||||||
|
([folderName, info]) => ({
|
||||||
|
id: folderName,
|
||||||
|
folder: folderName,
|
||||||
|
tmdbId: info.tmdb_id,
|
||||||
|
title: info.title,
|
||||||
|
poster: getTMDBImageUrl(info.poster_path),
|
||||||
|
releaseDate: info.release_date,
|
||||||
|
overview: info.overview,
|
||||||
|
voteAverage: info.vote_average,
|
||||||
|
mediaType: info.media_type,
|
||||||
|
lastUpdated: info.last_updated,
|
||||||
|
failed: info.failed || false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 按更新时间倒序排序
|
||||||
|
allVideos.sort((a, b) => b.lastUpdated - a.lastUpdated);
|
||||||
|
|
||||||
|
const total = allVideos.length;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const end = start + pageSize;
|
||||||
|
const list = allVideos.slice(start, end);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取视频列表失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取失败', details: (error as Error).message, list: [], total: 0 },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/app/api/openlist/play/route.ts
Normal file
78
src/app/api/openlist/play/route.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/* 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 { OpenListClient } from '@/lib/openlist.client';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/openlist/play?folder=xxx&fileName=xxx
|
||||||
|
* 获取单个视频文件的播放链接(懒加载)
|
||||||
|
* 返回重定向到真实播放 URL
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const folderName = searchParams.get('folder');
|
||||||
|
const fileName = searchParams.get('fileName');
|
||||||
|
|
||||||
|
if (!folderName || !fileName) {
|
||||||
|
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const openListConfig = config.OpenListConfig;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!openListConfig ||
|
||||||
|
!openListConfig.Enabled ||
|
||||||
|
!openListConfig.URL ||
|
||||||
|
!openListConfig.Username ||
|
||||||
|
!openListConfig.Password
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
|
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`;
|
||||||
|
const filePath = `${folderPath}/${fileName}`;
|
||||||
|
|
||||||
|
const client = new OpenListClient(
|
||||||
|
openListConfig.URL,
|
||||||
|
openListConfig.Username,
|
||||||
|
openListConfig.Password
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取文件的播放链接
|
||||||
|
const fileResponse = await client.getFile(filePath);
|
||||||
|
|
||||||
|
if (fileResponse.code !== 200 || !fileResponse.data.raw_url) {
|
||||||
|
console.error('[OpenList Play] 获取播放URL失败:', {
|
||||||
|
fileName,
|
||||||
|
code: fileResponse.code,
|
||||||
|
message: fileResponse.message,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取播放链接失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回重定向到真实播放 URL
|
||||||
|
return NextResponse.redirect(fileResponse.data.raw_url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取播放链接失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取失败', details: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/app/api/openlist/refresh-video/route.ts
Normal file
74
src/app/api/openlist/refresh-video/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { OpenListClient } from '@/lib/openlist.client';
|
||||||
|
import { invalidateVideoInfoCache } from '@/lib/openlist-cache';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/openlist/refresh-video
|
||||||
|
* 刷新单个视频的 videoinfo.json
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { folder } = body;
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const openListConfig = config.OpenListConfig;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!openListConfig ||
|
||||||
|
!openListConfig.Enabled ||
|
||||||
|
!openListConfig.URL ||
|
||||||
|
!openListConfig.Username ||
|
||||||
|
!openListConfig.Password
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
|
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folder}`;
|
||||||
|
const client = new OpenListClient(
|
||||||
|
openListConfig.URL,
|
||||||
|
openListConfig.Username,
|
||||||
|
openListConfig.Password
|
||||||
|
);
|
||||||
|
|
||||||
|
// 删除 videoinfo.json
|
||||||
|
const videoinfoPath = `${folderPath}/videoinfo.json`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.deleteFile(videoinfoPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('videoinfo.json 不存在或删除失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除缓存
|
||||||
|
invalidateVideoInfoCache(folderPath);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '刷新成功',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新视频失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '刷新失败', details: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
290
src/app/api/openlist/refresh/route.ts
Normal file
290
src/app/api/openlist/refresh/route.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
/* 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';
|
||||||
|
import { OpenListClient } from '@/lib/openlist.client';
|
||||||
|
import {
|
||||||
|
getCachedMetaInfo,
|
||||||
|
invalidateMetaInfoCache,
|
||||||
|
MetaInfo,
|
||||||
|
setCachedMetaInfo,
|
||||||
|
} from '@/lib/openlist-cache';
|
||||||
|
import {
|
||||||
|
cleanupOldTasks,
|
||||||
|
completeScanTask,
|
||||||
|
createScanTask,
|
||||||
|
failScanTask,
|
||||||
|
updateScanTaskProgress,
|
||||||
|
} from '@/lib/scan-task';
|
||||||
|
import { searchTMDB } from '@/lib/tmdb.search';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/openlist/refresh
|
||||||
|
* 刷新私人影库元数据(后台任务模式)
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 权限检查
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取请求参数
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const clearMetaInfo = body.clearMetaInfo === true; // 是否清空 metainfo
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
const config = await getConfig();
|
||||||
|
const openListConfig = config.OpenListConfig;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!openListConfig ||
|
||||||
|
!openListConfig.Enabled ||
|
||||||
|
!openListConfig.URL ||
|
||||||
|
!openListConfig.Username ||
|
||||||
|
!openListConfig.Password
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'OpenList 未配置或未启用' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
||||||
|
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
||||||
|
|
||||||
|
if (!tmdbApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'TMDB API Key 未配置' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理旧任务
|
||||||
|
cleanupOldTasks();
|
||||||
|
|
||||||
|
// 创建后台任务
|
||||||
|
const taskId = createScanTask();
|
||||||
|
|
||||||
|
// 启动后台扫描
|
||||||
|
performScan(
|
||||||
|
taskId,
|
||||||
|
openListConfig.URL,
|
||||||
|
openListConfig.RootPath || '/',
|
||||||
|
tmdbApiKey,
|
||||||
|
tmdbProxy,
|
||||||
|
openListConfig.Username,
|
||||||
|
openListConfig.Password,
|
||||||
|
clearMetaInfo
|
||||||
|
).catch((error) => {
|
||||||
|
console.error('[OpenList Refresh] 后台扫描失败:', error);
|
||||||
|
failScanTask(taskId, (error as Error).message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
taskId,
|
||||||
|
message: '扫描任务已启动',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('启动刷新任务失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '启动失败', details: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行扫描任务
|
||||||
|
*/
|
||||||
|
async function performScan(
|
||||||
|
taskId: string,
|
||||||
|
url: string,
|
||||||
|
rootPath: string,
|
||||||
|
tmdbApiKey: string,
|
||||||
|
tmdbProxy?: string,
|
||||||
|
username?: string,
|
||||||
|
password?: string,
|
||||||
|
clearMetaInfo?: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const client = new OpenListClient(url, username!, password!);
|
||||||
|
|
||||||
|
// 立即更新进度,确保任务可被查询
|
||||||
|
updateScanTaskProgress(taskId, 0, 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 根据参数决定是否读取现有数据
|
||||||
|
let metaInfo: MetaInfo;
|
||||||
|
|
||||||
|
if (clearMetaInfo) {
|
||||||
|
// 重新扫描:清空现有数据
|
||||||
|
metaInfo = {
|
||||||
|
folders: {},
|
||||||
|
last_refresh: Date.now(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 立即扫描:保留现有数据,从数据库读取
|
||||||
|
try {
|
||||||
|
const metainfoContent = await db.getGlobalValue('video.metainfo');
|
||||||
|
if (metainfoContent) {
|
||||||
|
metaInfo = JSON.parse(metainfoContent);
|
||||||
|
} else {
|
||||||
|
metaInfo = {
|
||||||
|
folders: {},
|
||||||
|
last_refresh: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[OpenList Refresh] 读取现有 metainfo 失败:', error);
|
||||||
|
metaInfo = {
|
||||||
|
folders: {},
|
||||||
|
last_refresh: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除缓存,确保后续读取的是新数据
|
||||||
|
invalidateMetaInfoCache(rootPath);
|
||||||
|
|
||||||
|
// 2. 列出根目录下的所有文件夹(强制刷新 OpenList 缓存)
|
||||||
|
// 循环获取所有页的数据
|
||||||
|
const folders: any[] = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
const pageSize = 100;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const listResponse = await client.listDirectory(rootPath, currentPage, pageSize, true);
|
||||||
|
|
||||||
|
if (listResponse.code !== 200) {
|
||||||
|
throw new Error('OpenList 列表获取失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
total = listResponse.data.total;
|
||||||
|
const pageFolders = listResponse.data.content.filter((item) => item.is_dir);
|
||||||
|
folders.push(...pageFolders);
|
||||||
|
|
||||||
|
// 如果已经获取了所有数据,退出循环
|
||||||
|
if (folders.length >= total) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPage++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新任务进度
|
||||||
|
updateScanTaskProgress(taskId, 0, folders.length);
|
||||||
|
|
||||||
|
// 3. 遍历文件夹,搜索 TMDB
|
||||||
|
let newCount = 0;
|
||||||
|
let existingCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < folders.length; i++) {
|
||||||
|
const folder = folders[i];
|
||||||
|
|
||||||
|
// 更新进度
|
||||||
|
updateScanTaskProgress(taskId, i + 1, folders.length, folder.name);
|
||||||
|
|
||||||
|
// 如果是立即扫描(不清空 metainfo),且文件夹已存在,跳过
|
||||||
|
if (!clearMetaInfo && metaInfo.folders[folder.name]) {
|
||||||
|
existingCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 搜索 TMDB
|
||||||
|
const searchResult = await searchTMDB(
|
||||||
|
tmdbApiKey,
|
||||||
|
folder.name,
|
||||||
|
tmdbProxy
|
||||||
|
);
|
||||||
|
|
||||||
|
if (searchResult.code === 200 && searchResult.result) {
|
||||||
|
const result = searchResult.result;
|
||||||
|
|
||||||
|
metaInfo.folders[folder.name] = {
|
||||||
|
tmdb_id: result.id,
|
||||||
|
title: result.title || result.name || folder.name,
|
||||||
|
poster_path: result.poster_path,
|
||||||
|
release_date: result.release_date || result.first_air_date || '',
|
||||||
|
overview: result.overview,
|
||||||
|
vote_average: result.vote_average,
|
||||||
|
media_type: result.media_type,
|
||||||
|
last_updated: Date.now(),
|
||||||
|
failed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
newCount++;
|
||||||
|
} else {
|
||||||
|
// 记录失败的文件夹
|
||||||
|
metaInfo.folders[folder.name] = {
|
||||||
|
tmdb_id: 0,
|
||||||
|
title: folder.name,
|
||||||
|
poster_path: null,
|
||||||
|
release_date: '',
|
||||||
|
overview: '',
|
||||||
|
vote_average: 0,
|
||||||
|
media_type: 'movie',
|
||||||
|
last_updated: Date.now(),
|
||||||
|
failed: true,
|
||||||
|
};
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 避免请求过快
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[OpenList Refresh] 处理文件夹失败: ${folder.name}`, error);
|
||||||
|
// 记录失败的文件夹
|
||||||
|
metaInfo.folders[folder.name] = {
|
||||||
|
tmdb_id: 0,
|
||||||
|
title: folder.name,
|
||||||
|
poster_path: null,
|
||||||
|
release_date: '',
|
||||||
|
overview: '',
|
||||||
|
vote_average: 0,
|
||||||
|
media_type: 'movie',
|
||||||
|
last_updated: Date.now(),
|
||||||
|
failed: true,
|
||||||
|
};
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 保存 metainfo 到数据库
|
||||||
|
metaInfo.last_refresh = Date.now();
|
||||||
|
|
||||||
|
const metainfoContent = JSON.stringify(metaInfo);
|
||||||
|
await db.setGlobalValue('video.metainfo', metainfoContent);
|
||||||
|
|
||||||
|
// 5. 更新缓存
|
||||||
|
invalidateMetaInfoCache(rootPath);
|
||||||
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
|
|
||||||
|
// 6. 更新配置
|
||||||
|
const config = await getConfig();
|
||||||
|
config.OpenListConfig!.LastRefreshTime = Date.now();
|
||||||
|
config.OpenListConfig!.ResourceCount = Object.keys(metaInfo.folders).length;
|
||||||
|
await db.saveAdminConfig(config);
|
||||||
|
|
||||||
|
// 完成任务
|
||||||
|
completeScanTask(taskId, {
|
||||||
|
total: folders.length,
|
||||||
|
new: newCount,
|
||||||
|
existing: existingCount,
|
||||||
|
errors: errorCount,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[OpenList Refresh] 扫描失败:', error);
|
||||||
|
failScanTask(taskId, (error as Error).message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/app/api/openlist/scan-progress/route.ts
Normal file
45
src/app/api/openlist/scan-progress/route.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getScanTask } from '@/lib/scan-task';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/openlist/scan-progress?taskId=xxx
|
||||||
|
* 获取扫描任务进度
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const taskId = searchParams.get('taskId');
|
||||||
|
|
||||||
|
if (!taskId) {
|
||||||
|
return NextResponse.json({ error: '缺少 taskId' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = getScanTask(taskId);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return NextResponse.json({ error: '任务不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
task,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取扫描进度失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取失败', details: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/app/api/pansou/search/route.ts
Normal file
61
src/app/api/pansou/search/route.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { searchPansou } from '@/lib/pansou.client';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { keyword } = body;
|
||||||
|
|
||||||
|
if (!keyword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '关键词不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从系统配置中获取 Pansou 配置
|
||||||
|
const config = await getConfig();
|
||||||
|
const apiUrl = config.SiteConfig.PansouApiUrl;
|
||||||
|
const username = config.SiteConfig.PansouUsername;
|
||||||
|
const password = config.SiteConfig.PansouPassword;
|
||||||
|
|
||||||
|
console.log('Pansou 搜索请求:', {
|
||||||
|
keyword,
|
||||||
|
apiUrl: apiUrl ? '已配置' : '未配置',
|
||||||
|
hasAuth: !!(username && password),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '未配置 Pansou API 地址,请在管理面板配置' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 Pansou 搜索
|
||||||
|
const results = await searchPansou(apiUrl, keyword, {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Pansou 搜索结果:', {
|
||||||
|
total: results.total,
|
||||||
|
hasData: !!results.merged_by_type,
|
||||||
|
types: results.merged_by_type ? Object.keys(results.merged_by_type) : [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(results);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Pansou 搜索失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || '搜索失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,16 +17,13 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
|
||||||
// 非站长,检查用户存在或被封禁
|
// 非站长,检查用户存在或被封禁
|
||||||
const user = config.UserConfig.Users.find(
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
(u) => u.username === authInfo.username
|
if (!userInfoV2) {
|
||||||
);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (user.banned) {
|
if (userInfoV2.banned) {
|
||||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,16 +47,13 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
|
||||||
// 非站长,检查用户存在或被封禁
|
// 非站长,检查用户存在或被封禁
|
||||||
const user = config.UserConfig.Users.find(
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
(u) => u.username === authInfo.username
|
if (!userInfoV2) {
|
||||||
);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (user.banned) {
|
if (userInfoV2.banned) {
|
||||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,16 +110,13 @@ export async function DELETE(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
|
||||||
// 非站长,检查用户存在或被封禁
|
// 非站长,检查用户存在或被封禁
|
||||||
const user = config.UserConfig.Users.find(
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
(u) => u.username === authInfo.username
|
if (!userInfoV2) {
|
||||||
);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (user.banned) {
|
if (userInfoV2.banned) {
|
||||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { lockManager } from '@/lib/lock';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -93,69 +94,78 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户是否已存在
|
// 获取用户名锁,防止并发注册
|
||||||
const existingUser = config.UserConfig.Users.find((u) => u.username === username);
|
let releaseLock: (() => void) | null = null;
|
||||||
if (existingUser) {
|
try {
|
||||||
|
releaseLock = await lockManager.acquire(`register:${username}`);
|
||||||
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '用户名已存在' },
|
{ error: '服务器繁忙,请稍后重试' },
|
||||||
{ status: 409 }
|
{ status: 503 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果开启了Turnstile验证
|
|
||||||
if (siteConfig.RegistrationRequireTurnstile) {
|
|
||||||
if (!turnstileToken) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '请完成人机验证' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!siteConfig.TurnstileSecretKey) {
|
|
||||||
console.error('Turnstile Secret Key未配置');
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '服务器配置错误' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证Turnstile Token
|
|
||||||
const isValid = await verifyTurnstileToken(turnstileToken, siteConfig.TurnstileSecretKey);
|
|
||||||
if (!isValid) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '人机验证失败,请重试' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建用户
|
|
||||||
try {
|
try {
|
||||||
// 1. 在数据库中创建用户密码
|
// 检查用户是否已存在(只检查V2存储)
|
||||||
await db.registerUser(username, password);
|
const userExists = await db.checkUserExistV2(username);
|
||||||
|
if (userExists) {
|
||||||
// 2. 将用户添加到管理员配置的用户列表中
|
return NextResponse.json(
|
||||||
const newUser: any = {
|
{ error: '用户名已存在' },
|
||||||
username: username,
|
{ status: 409 }
|
||||||
role: 'user',
|
);
|
||||||
banned: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. 如果配置了默认用户组,分配给新用户
|
|
||||||
if (siteConfig.DefaultUserTags && siteConfig.DefaultUserTags.length > 0) {
|
|
||||||
newUser.tags = siteConfig.DefaultUserTags;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
config.UserConfig.Users.push(newUser);
|
// 如果开启了Turnstile验证
|
||||||
|
if (siteConfig.RegistrationRequireTurnstile) {
|
||||||
|
if (!turnstileToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '请完成人机验证' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 保存更新后的配置
|
if (!siteConfig.TurnstileSecretKey) {
|
||||||
await db.saveAdminConfig(config);
|
console.error('Turnstile Secret Key未配置');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '服务器配置错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 注册成功
|
// 验证Turnstile Token
|
||||||
return NextResponse.json({ ok: true, message: '注册成功' });
|
const isValid = await verifyTurnstileToken(turnstileToken, siteConfig.TurnstileSecretKey);
|
||||||
} catch (err) {
|
if (!isValid) {
|
||||||
console.error('创建用户失败', err);
|
return NextResponse.json(
|
||||||
return NextResponse.json({ error: '注册失败,请稍后重试' }, { status: 500 });
|
{ error: '人机验证失败,请重试' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建用户
|
||||||
|
try {
|
||||||
|
// 使用新版本创建用户(带SHA256加密)
|
||||||
|
const defaultTags = siteConfig.DefaultUserTags && siteConfig.DefaultUserTags.length > 0
|
||||||
|
? siteConfig.DefaultUserTags
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await db.createUserV2(username, password, 'user', defaultTags);
|
||||||
|
|
||||||
|
// 注册成功
|
||||||
|
return NextResponse.json({ ok: true, message: '注册成功' });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('创建用户失败', err);
|
||||||
|
// 如果是用户已存在的错误,返回409
|
||||||
|
if (err.message === '用户已存在') {
|
||||||
|
return NextResponse.json({ error: '用户名已存在' }, { status: 409 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: '注册失败,请稍后重试' }, { status: 500 });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 释放锁
|
||||||
|
if (releaseLock) {
|
||||||
|
releaseLock();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('注册接口异常', error);
|
console.error('注册接口异常', error);
|
||||||
|
|||||||
@@ -36,6 +36,66 @@ export async function GET(request: NextRequest) {
|
|||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||||
|
|
||||||
|
// 检查是否配置了 OpenList
|
||||||
|
const hasOpenList = !!(
|
||||||
|
config.OpenListConfig?.Enabled &&
|
||||||
|
config.OpenListConfig?.URL &&
|
||||||
|
config.OpenListConfig?.Username &&
|
||||||
|
config.OpenListConfig?.Password
|
||||||
|
);
|
||||||
|
|
||||||
|
// 搜索 OpenList(如果配置了)
|
||||||
|
let openlistResults: any[] = [];
|
||||||
|
if (hasOpenList) {
|
||||||
|
try {
|
||||||
|
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||||
|
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||||||
|
const { db } = await import('@/lib/db');
|
||||||
|
|
||||||
|
const rootPath = config.OpenListConfig!.RootPath || '/';
|
||||||
|
let metaInfo = getCachedMetaInfo(rootPath);
|
||||||
|
|
||||||
|
// 如果没有缓存,尝试从数据库读取
|
||||||
|
if (!metaInfo) {
|
||||||
|
try {
|
||||||
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
|
if (metainfoJson) {
|
||||||
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
|
if (metaInfo) {
|
||||||
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Search] 从数据库读取 metainfo 失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metaInfo && metaInfo.folders) {
|
||||||
|
openlistResults = Object.entries(metaInfo.folders)
|
||||||
|
.filter(([folderName, info]: [string, any]) => {
|
||||||
|
const matchFolder = folderName.toLowerCase().includes(query.toLowerCase());
|
||||||
|
const matchTitle = info.title.toLowerCase().includes(query.toLowerCase());
|
||||||
|
return matchFolder || matchTitle;
|
||||||
|
})
|
||||||
|
.map(([folderName, info]: [string, any]) => ({
|
||||||
|
id: folderName,
|
||||||
|
source: 'openlist',
|
||||||
|
source_name: '私人影库',
|
||||||
|
title: info.title,
|
||||||
|
poster: getTMDBImageUrl(info.poster_path),
|
||||||
|
episodes: [],
|
||||||
|
episodes_titles: [],
|
||||||
|
year: info.release_date.split('-')[0] || '',
|
||||||
|
desc: info.overview,
|
||||||
|
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
||||||
|
douban_id: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Search] 搜索 OpenList 失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 添加超时控制和错误处理,避免慢接口拖累整体响应
|
// 添加超时控制和错误处理,避免慢接口拖累整体响应
|
||||||
const searchPromises = apiSites.map((site) =>
|
const searchPromises = apiSites.map((site) =>
|
||||||
Promise.race([
|
Promise.race([
|
||||||
@@ -54,7 +114,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const successResults = results
|
const successResults = results
|
||||||
.filter((result) => result.status === 'fulfilled')
|
.filter((result) => result.status === 'fulfilled')
|
||||||
.map((result) => (result as PromiseFulfilledResult<any>).value);
|
.map((result) => (result as PromiseFulfilledResult<any>).value);
|
||||||
let flattenedResults = successResults.flat();
|
let flattenedResults = [...openlistResults, ...successResults.flat()];
|
||||||
if (!config.SiteConfig.DisableYellowFilter) {
|
if (!config.SiteConfig.DisableYellowFilter) {
|
||||||
flattenedResults = flattenedResults.filter((result) => {
|
flattenedResults = flattenedResults.filter((result) => {
|
||||||
const typeName = result.type_name || '';
|
const typeName = result.type_name || '';
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ export async function GET(request: NextRequest) {
|
|||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||||
|
|
||||||
|
// 检查是否配置了 OpenList
|
||||||
|
const hasOpenList = !!(
|
||||||
|
config.OpenListConfig?.Enabled &&
|
||||||
|
config.OpenListConfig?.URL &&
|
||||||
|
config.OpenListConfig?.Username &&
|
||||||
|
config.OpenListConfig?.Password
|
||||||
|
);
|
||||||
|
|
||||||
// 共享状态
|
// 共享状态
|
||||||
let streamClosed = false;
|
let streamClosed = false;
|
||||||
|
|
||||||
@@ -62,7 +70,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const startEvent = `data: ${JSON.stringify({
|
const startEvent = `data: ${JSON.stringify({
|
||||||
type: 'start',
|
type: 'start',
|
||||||
query,
|
query,
|
||||||
totalSources: apiSites.length,
|
totalSources: apiSites.length + (hasOpenList ? 1 : 0),
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})}\n\n`;
|
})}\n\n`;
|
||||||
|
|
||||||
@@ -74,6 +82,111 @@ export async function GET(request: NextRequest) {
|
|||||||
let completedSources = 0;
|
let completedSources = 0;
|
||||||
const allResults: any[] = [];
|
const allResults: any[] = [];
|
||||||
|
|
||||||
|
// 搜索 OpenList(如果配置了)
|
||||||
|
if (hasOpenList) {
|
||||||
|
try {
|
||||||
|
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||||
|
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||||||
|
const { db } = await import('@/lib/db');
|
||||||
|
|
||||||
|
const rootPath = config.OpenListConfig!.RootPath || '/';
|
||||||
|
let metaInfo = getCachedMetaInfo(rootPath);
|
||||||
|
|
||||||
|
// 如果没有缓存,尝试从数据库读取
|
||||||
|
if (!metaInfo) {
|
||||||
|
try {
|
||||||
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
|
if (metainfoJson) {
|
||||||
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
|
if (metaInfo) {
|
||||||
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Search WS] 从数据库读取 metainfo 失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metaInfo && metaInfo.folders) {
|
||||||
|
const openlistResults = Object.entries(metaInfo.folders)
|
||||||
|
.filter(([folderName, info]: [string, any]) => {
|
||||||
|
const matchFolder = folderName.toLowerCase().includes(query.toLowerCase());
|
||||||
|
const matchTitle = info.title.toLowerCase().includes(query.toLowerCase());
|
||||||
|
return matchFolder || matchTitle;
|
||||||
|
})
|
||||||
|
.map(([folderName, info]: [string, any]) => ({
|
||||||
|
id: folderName,
|
||||||
|
source: 'openlist',
|
||||||
|
source_name: '私人影库',
|
||||||
|
title: info.title,
|
||||||
|
poster: getTMDBImageUrl(info.poster_path),
|
||||||
|
episodes: [],
|
||||||
|
episodes_titles: [],
|
||||||
|
year: info.release_date.split('-')[0] || '',
|
||||||
|
desc: info.overview,
|
||||||
|
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
||||||
|
douban_id: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
completedSources++;
|
||||||
|
|
||||||
|
if (!streamClosed) {
|
||||||
|
const sourceEvent = `data: ${JSON.stringify({
|
||||||
|
type: 'source_result',
|
||||||
|
source: 'openlist',
|
||||||
|
sourceName: '私人影库',
|
||||||
|
results: openlistResults,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})}\n\n`;
|
||||||
|
|
||||||
|
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
||||||
|
streamClosed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openlistResults.length > 0) {
|
||||||
|
allResults.push(...openlistResults);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
completedSources++;
|
||||||
|
|
||||||
|
if (!streamClosed) {
|
||||||
|
const sourceEvent = `data: ${JSON.stringify({
|
||||||
|
type: 'source_result',
|
||||||
|
source: 'openlist',
|
||||||
|
sourceName: '私人影库',
|
||||||
|
results: [],
|
||||||
|
timestamp: Date.now()
|
||||||
|
})}\n\n`;
|
||||||
|
|
||||||
|
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
||||||
|
streamClosed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Search WS] 搜索 OpenList 失败:', error);
|
||||||
|
completedSources++;
|
||||||
|
|
||||||
|
if (!streamClosed) {
|
||||||
|
const errorEvent = `data: ${JSON.stringify({
|
||||||
|
type: 'source_error',
|
||||||
|
source: 'openlist',
|
||||||
|
sourceName: '私人影库',
|
||||||
|
error: error instanceof Error ? error.message : '搜索失败',
|
||||||
|
timestamp: Date.now()
|
||||||
|
})}\n\n`;
|
||||||
|
|
||||||
|
if (!safeEnqueue(encoder.encode(errorEvent))) {
|
||||||
|
streamClosed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 为每个源创建搜索 Promise
|
// 为每个源创建搜索 Promise
|
||||||
const searchPromises = apiSites.map(async (site) => {
|
const searchPromises = apiSites.map(async (site) => {
|
||||||
try {
|
try {
|
||||||
@@ -141,7 +254,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否所有源都已完成
|
// 检查是否所有源都已完成
|
||||||
if (completedSources === apiSites.length) {
|
if (completedSources === apiSites.length + (hasOpenList ? 1 : 0)) {
|
||||||
if (!streamClosed) {
|
if (!streamClosed) {
|
||||||
// 发送最终完成事件
|
// 发送最终完成事件
|
||||||
const completeEvent = `data: ${JSON.stringify({
|
const completeEvent = `data: ${JSON.stringify({
|
||||||
|
|||||||
@@ -23,16 +23,13 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
|
||||||
// 非站长,检查用户存在或被封禁
|
// 非站长,检查用户存在或被封禁
|
||||||
const user = config.UserConfig.Users.find(
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
(u) => u.username === authInfo.username
|
if (!userInfoV2) {
|
||||||
);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (user.banned) {
|
if (userInfoV2.banned) {
|
||||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,16 +57,13 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
|
||||||
// 非站长,检查用户存在或被封禁
|
// 非站长,检查用户存在或被封禁
|
||||||
const user = config.UserConfig.Users.find(
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
(u) => u.username === authInfo.username
|
if (!userInfoV2) {
|
||||||
);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (user.banned) {
|
if (userInfoV2.banned) {
|
||||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,16 +106,13 @@ export async function DELETE(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
|
||||||
// 非站长,检查用户存在或被封禁
|
// 非站长,检查用户存在或被封禁
|
||||||
const user = config.UserConfig.Users.find(
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
(u) => u.username === authInfo.username
|
if (!userInfoV2) {
|
||||||
);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (user.banned) {
|
if (userInfoV2.banned) {
|
||||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,13 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
|
||||||
// 非站长,检查用户存在或被封禁
|
// 非站长,检查用户存在或被封禁
|
||||||
const user = config.UserConfig.Users.find(
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
(u) => u.username === authInfo.username
|
if (!userInfoV2) {
|
||||||
);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (user.banned) {
|
if (userInfoV2.banned) {
|
||||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,16 +56,13 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminConfig = await getConfig();
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
|
||||||
// 非站长,检查用户存在或被封禁
|
// 非站长,检查用户存在或被封禁
|
||||||
const user = adminConfig.UserConfig.Users.find(
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
(u) => u.username === authInfo.username
|
if (!userInfoV2) {
|
||||||
);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (user.banned) {
|
if (userInfoV2.banned) {
|
||||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,16 +106,13 @@ export async function DELETE(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminConfig = await getConfig();
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
|
||||||
// 非站长,检查用户存在或被封禁
|
// 非站长,检查用户存在或被封禁
|
||||||
const user = adminConfig.UserConfig.Users.find(
|
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||||
(u) => u.username === authInfo.username
|
if (!userInfoV2) {
|
||||||
);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (user.banned) {
|
if (userInfoV2.banned) {
|
||||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/app/api/tmdb/search/route.ts
Normal file
86
src/app/api/tmdb/search/route.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/* 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 { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
|
import nodeFetch from 'node-fetch';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tmdb/search?query=xxx
|
||||||
|
* 搜索TMDB,返回多个结果供用户选择
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const query = searchParams.get('query');
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return NextResponse.json({ error: '缺少查询参数' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
||||||
|
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
||||||
|
|
||||||
|
if (!tmdbApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'TMDB API Key 未配置' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 multi search 同时搜索电影和电视剧
|
||||||
|
const url = `https://api.themoviedb.org/3/search/multi?api_key=${tmdbApiKey}&language=zh-CN&query=${encodeURIComponent(query)}&page=1`;
|
||||||
|
|
||||||
|
const fetchOptions: any = tmdbProxy
|
||||||
|
? {
|
||||||
|
agent: new HttpsProxyAgent(tmdbProxy, {
|
||||||
|
timeout: 30000,
|
||||||
|
keepAlive: false,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 node-fetch 而不是原生 fetch
|
||||||
|
const response = await nodeFetch(url, fetchOptions);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('TMDB 搜索失败:', response.status, response.statusText);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'TMDB 搜索失败', code: response.status },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = await response.json();
|
||||||
|
|
||||||
|
// 过滤出电影和电视剧
|
||||||
|
const validResults = data.results.filter(
|
||||||
|
(item: any) => item.media_type === 'movie' || item.media_type === 'tv'
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
results: validResults,
|
||||||
|
total: validResults.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TMDB搜索失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '搜索失败', details: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export async function GET(request: NextRequest) {
|
|||||||
// 缓存不存在或已过期,获取新数据
|
// 缓存不存在或已过期,获取新数据
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const tmdbApiKey = config.SiteConfig?.TMDBApiKey;
|
const tmdbApiKey = config.SiteConfig?.TMDBApiKey;
|
||||||
|
const tmdbProxy = config.SiteConfig?.TMDBProxy;
|
||||||
|
|
||||||
if (!tmdbApiKey) {
|
if (!tmdbApiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -36,7 +37,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 调用TMDB API获取数据
|
// 调用TMDB API获取数据
|
||||||
const result = await getTMDBUpcomingContent(tmdbApiKey);
|
const result = await getTMDBUpcomingContent(tmdbApiKey, tmdbProxy);
|
||||||
|
|
||||||
if (result.code !== 200) {
|
if (result.code !== 200) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export default async function RootLayout({
|
|||||||
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
|
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
|
||||||
let enableComments = false;
|
let enableComments = false;
|
||||||
let tmdbApiKey = '';
|
let tmdbApiKey = '';
|
||||||
|
let openListEnabled = false;
|
||||||
let customCategories = [] as {
|
let customCategories = [] as {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'movie' | 'tv';
|
type: 'movie' | 'tv';
|
||||||
@@ -87,6 +88,13 @@ export default async function RootLayout({
|
|||||||
fluidSearch = config.SiteConfig.FluidSearch;
|
fluidSearch = config.SiteConfig.FluidSearch;
|
||||||
enableComments = config.SiteConfig.EnableComments;
|
enableComments = config.SiteConfig.EnableComments;
|
||||||
tmdbApiKey = config.SiteConfig.TMDBApiKey || '';
|
tmdbApiKey = config.SiteConfig.TMDBApiKey || '';
|
||||||
|
// 检查是否启用了 OpenList 功能
|
||||||
|
openListEnabled = !!(
|
||||||
|
config.OpenListConfig?.Enabled &&
|
||||||
|
config.OpenListConfig?.URL &&
|
||||||
|
config.OpenListConfig?.Username &&
|
||||||
|
config.OpenListConfig?.Password
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||||
@@ -103,6 +111,7 @@ export default async function RootLayout({
|
|||||||
ENABLE_TVBOX_SUBSCRIBE: process.env.ENABLE_TVBOX_SUBSCRIBE === 'true',
|
ENABLE_TVBOX_SUBSCRIBE: process.env.ENABLE_TVBOX_SUBSCRIBE === 'true',
|
||||||
ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
||||||
VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback',
|
VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback',
|
||||||
|
OPENLIST_ENABLED: openListEnabled,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -57,55 +57,6 @@ function HomeClient() {
|
|||||||
}
|
}
|
||||||
}, [announcement]);
|
}, [announcement]);
|
||||||
|
|
||||||
// 首次进入时检查收藏更新(带前端冷却检查)
|
|
||||||
useEffect(() => {
|
|
||||||
const checkFavoriteUpdates = async () => {
|
|
||||||
try {
|
|
||||||
// 检查冷却时间(前端 localStorage)
|
|
||||||
const COOLDOWN_TIME = 30 * 60 * 1000; // 30分钟
|
|
||||||
const lastCheckTime = localStorage.getItem('lastFavoriteCheckTime');
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (lastCheckTime) {
|
|
||||||
const timeSinceLastCheck = now - parseInt(lastCheckTime, 10);
|
|
||||||
if (timeSinceLastCheck < COOLDOWN_TIME) {
|
|
||||||
const remainingMinutes = Math.ceil((COOLDOWN_TIME - timeSinceLastCheck) / 1000 / 60);
|
|
||||||
console.log(`收藏更新检查冷却中,还需等待 ${remainingMinutes} 分钟`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('开始检查收藏更新...');
|
|
||||||
const response = await fetch('/api/favorites/check-updates', {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// 更新本地检查时间
|
|
||||||
localStorage.setItem('lastFavoriteCheckTime', now.toString());
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.updates && data.updates.length > 0) {
|
|
||||||
console.log(`发现 ${data.updates.length} 个收藏更新`);
|
|
||||||
// 触发通知更新事件
|
|
||||||
window.dispatchEvent(new Event('notificationsUpdated'));
|
|
||||||
} else {
|
|
||||||
console.log('没有收藏更新');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('检查收藏更新失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 延迟3秒后检查,避免影响首页加载
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
checkFavoriteUpdates();
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 收藏夹数据
|
// 收藏夹数据
|
||||||
type FavoriteItem = {
|
type FavoriteItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Heart } from 'lucide-react';
|
import { Heart, Search, X, Cloud } from 'lucide-react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ import DoubanComments from '@/components/DoubanComments';
|
|||||||
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
|
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
|
||||||
import Toast, { ToastProps } from '@/components/Toast';
|
import Toast, { ToastProps } from '@/components/Toast';
|
||||||
import { useEnableComments } from '@/hooks/useEnableComments';
|
import { useEnableComments } from '@/hooks/useEnableComments';
|
||||||
|
import PansouSearch from '@/components/PansouSearch';
|
||||||
|
|
||||||
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
||||||
declare global {
|
declare global {
|
||||||
@@ -96,6 +97,9 @@ function PlayPageClient() {
|
|||||||
// 收藏状态
|
// 收藏状态
|
||||||
const [favorited, setFavorited] = useState(false);
|
const [favorited, setFavorited] = useState(false);
|
||||||
|
|
||||||
|
// 网盘搜索弹窗状态
|
||||||
|
const [showPansouDialog, setShowPansouDialog] = useState(false);
|
||||||
|
|
||||||
// 跳过片头片尾配置
|
// 跳过片头片尾配置
|
||||||
const [skipConfig, setSkipConfig] = useState<{
|
const [skipConfig, setSkipConfig] = useState<{
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
@@ -289,6 +293,32 @@ function PlayPageClient() {
|
|||||||
const danmakuPluginRef = useRef<any>(null);
|
const danmakuPluginRef = useRef<any>(null);
|
||||||
const danmakuSettingsRef = useRef(danmakuSettings);
|
const danmakuSettingsRef = useRef(danmakuSettings);
|
||||||
|
|
||||||
|
// 弹幕热力图完全禁用开关(默认不禁用,即启用热力图功能)
|
||||||
|
const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState<boolean>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const v = localStorage.getItem('danmaku_heatmap_disabled');
|
||||||
|
if (v !== null) return v === 'true';
|
||||||
|
}
|
||||||
|
return false; // 默认不禁用
|
||||||
|
});
|
||||||
|
const danmakuHeatmapDisabledRef = useRef(danmakuHeatmapDisabled);
|
||||||
|
useEffect(() => {
|
||||||
|
danmakuHeatmapDisabledRef.current = danmakuHeatmapDisabled;
|
||||||
|
}, [danmakuHeatmapDisabled]);
|
||||||
|
|
||||||
|
// 弹幕热力图开关(默认开启)
|
||||||
|
const [danmakuHeatmapEnabled, setDanmakuHeatmapEnabled] = useState<boolean>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const v = localStorage.getItem('danmaku_heatmap_enabled');
|
||||||
|
if (v !== null) return v === 'true';
|
||||||
|
}
|
||||||
|
return true; // 默认开启
|
||||||
|
});
|
||||||
|
const danmakuHeatmapEnabledRef = useRef(danmakuHeatmapEnabled);
|
||||||
|
useEffect(() => {
|
||||||
|
danmakuHeatmapEnabledRef.current = danmakuHeatmapEnabled;
|
||||||
|
}, [danmakuHeatmapEnabled]);
|
||||||
|
|
||||||
// 多条弹幕匹配结果
|
// 多条弹幕匹配结果
|
||||||
const [danmakuMatches, setDanmakuMatches] = useState<DanmakuAnime[]>([]);
|
const [danmakuMatches, setDanmakuMatches] = useState<DanmakuAnime[]>([]);
|
||||||
const [showDanmakuSourceSelector, setShowDanmakuSourceSelector] = useState(false);
|
const [showDanmakuSourceSelector, setShowDanmakuSourceSelector] = useState(false);
|
||||||
@@ -602,9 +632,9 @@ function PlayPageClient() {
|
|||||||
// 工具函数(Utils)
|
// 工具函数(Utils)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
// 判断剧集是否已完结
|
// 判断剧集状态
|
||||||
const isSeriesCompleted = (detail: SearchResult | null): boolean => {
|
const getSeriesStatus = (detail: SearchResult | null): 'completed' | 'ongoing' | 'unknown' => {
|
||||||
if (!detail) return false;
|
if (!detail) return 'unknown';
|
||||||
|
|
||||||
// 方法1:通过 vod_remarks 判断
|
// 方法1:通过 vod_remarks 判断
|
||||||
if (detail.vod_remarks) {
|
if (detail.vod_remarks) {
|
||||||
@@ -616,23 +646,27 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
// 如果包含连载关键词,则为连载中
|
// 如果包含连载关键词,则为连载中
|
||||||
if (ongoingKeywords.some(keyword => remarks.includes(keyword))) {
|
if (ongoingKeywords.some(keyword => remarks.includes(keyword))) {
|
||||||
return false;
|
return 'ongoing';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果包含完结关键词,则为已完结
|
// 如果包含完结关键词,则为已完结
|
||||||
if (completedKeywords.some(keyword => remarks.includes(keyword))) {
|
if (completedKeywords.some(keyword => remarks.includes(keyword))) {
|
||||||
return true;
|
return 'completed';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方法2:通过 vod_total 和实际集数对比判断
|
// 方法2:通过 vod_total 和实际集数对比判断
|
||||||
if (detail.vod_total && detail.vod_total > 0 && detail.episodes && detail.episodes.length > 0) {
|
if (detail.vod_total && detail.vod_total > 0 && detail.episodes && detail.episodes.length > 0) {
|
||||||
// 如果实际集数 >= 总集数,则为已完结
|
// 如果实际集数 >= 总集数,则为已完结
|
||||||
return detail.episodes.length >= detail.vod_total;
|
if (detail.episodes.length >= detail.vod_total) {
|
||||||
|
return 'completed';
|
||||||
|
}
|
||||||
|
// 如果实际集数 < 总集数,则为连载中
|
||||||
|
return 'ongoing';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无法判断,默认返回 false(连载中)
|
// 无法判断,返回 unknown
|
||||||
return false;
|
return 'unknown';
|
||||||
};
|
};
|
||||||
|
|
||||||
// 播放源优选函数
|
// 播放源优选函数
|
||||||
@@ -1772,7 +1806,9 @@ function PlayPageClient() {
|
|||||||
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
|
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
|
||||||
: true) &&
|
: true) &&
|
||||||
(searchType
|
(searchType
|
||||||
? (searchType === 'tv' && result.episodes.length > 1) ||
|
? // openlist 源跳过 episodes 长度检查,因为搜索时不返回详细播放列表
|
||||||
|
result.source === 'openlist' ||
|
||||||
|
(searchType === 'tv' && result.episodes.length > 1) ||
|
||||||
(searchType === 'movie' && result.episodes.length === 1)
|
(searchType === 'movie' && result.episodes.length === 1)
|
||||||
: true)
|
: true)
|
||||||
);
|
);
|
||||||
@@ -1825,6 +1861,15 @@ function PlayPageClient() {
|
|||||||
);
|
);
|
||||||
if (target) {
|
if (target) {
|
||||||
detailData = target;
|
detailData = target;
|
||||||
|
|
||||||
|
// 如果是 openlist 源且 episodes 为空,需要调用 detail 接口获取完整信息
|
||||||
|
if (detailData.source === 'openlist' && (!detailData.episodes || detailData.episodes.length === 0)) {
|
||||||
|
console.log('[Play] OpenList source has no episodes, fetching detail...');
|
||||||
|
const detailSources = await fetchSourceDetail(currentSource, currentId);
|
||||||
|
if (detailSources.length > 0) {
|
||||||
|
detailData = detailSources[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setError('未找到匹配结果');
|
setError('未找到匹配结果');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -1845,6 +1890,15 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
console.log(detailData.source, detailData.id);
|
console.log(detailData.source, detailData.id);
|
||||||
|
|
||||||
|
// 如果是 openlist 源且 episodes 为空,需要调用 detail 接口获取完整信息
|
||||||
|
if (detailData.source === 'openlist' && (!detailData.episodes || detailData.episodes.length === 0)) {
|
||||||
|
console.log('[Play] OpenList source has no episodes after selection, fetching detail...');
|
||||||
|
const detailSources = await fetchSourceDetail(detailData.source, detailData.id);
|
||||||
|
if (detailSources.length > 0) {
|
||||||
|
detailData = detailSources[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setNeedPrefer(false);
|
setNeedPrefer(false);
|
||||||
setCurrentSource(detailData.source);
|
setCurrentSource(detailData.source);
|
||||||
setCurrentId(detailData.id);
|
setCurrentId(detailData.id);
|
||||||
@@ -2283,6 +2337,17 @@ function PlayPageClient() {
|
|||||||
setDanmakuCount(0);
|
setDanmakuCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
setDanmakuLoading(false);
|
setDanmakuLoading(false);
|
||||||
|
|
||||||
|
// 弹幕加载完成后,根据用户设置显示或隐藏热力图(仅在未禁用热力图时)
|
||||||
|
if (!danmakuHeatmapDisabledRef.current) {
|
||||||
|
const heatmapElement = document.querySelector('.art-control-heatmap') as HTMLElement;
|
||||||
|
if (heatmapElement) {
|
||||||
|
const isEnabled = danmakuHeatmapEnabledRef.current;
|
||||||
|
heatmapElement.style.opacity = isEnabled ? '1' : '0';
|
||||||
|
heatmapElement.style.pointerEvents = isEnabled ? 'auto' : 'none';
|
||||||
|
console.log('弹幕加载完成,热力图状态:', isEnabled ? '显示' : '隐藏');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2837,7 +2902,7 @@ function PlayPageClient() {
|
|||||||
total_episodes: detailRef.current?.episodes.length || 1,
|
total_episodes: detailRef.current?.episodes.length || 1,
|
||||||
save_time: Date.now(),
|
save_time: Date.now(),
|
||||||
search_title: searchTitle,
|
search_title: searchTitle,
|
||||||
is_completed: isSeriesCompleted(detailRef.current),
|
is_completed: getSeriesStatus(detailRef.current) === 'completed',
|
||||||
vod_remarks: detailRef.current?.vod_remarks,
|
vod_remarks: detailRef.current?.vod_remarks,
|
||||||
});
|
});
|
||||||
setFavorited(true);
|
setFavorited(true);
|
||||||
@@ -3108,6 +3173,7 @@ function PlayPageClient() {
|
|||||||
antiOverlap: true,
|
antiOverlap: true,
|
||||||
synchronousPlayback: danmakuSettingsRef.current.synchronousPlayback,
|
synchronousPlayback: danmakuSettingsRef.current.synchronousPlayback,
|
||||||
emitter: false,
|
emitter: false,
|
||||||
|
heatmap: !danmakuHeatmapDisabledRef.current, // 根据禁用状态决定是否创建热力图
|
||||||
// 主题
|
// 主题
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
filter: (danmu: any) => {
|
filter: (danmu: any) => {
|
||||||
@@ -3188,6 +3254,33 @@ function PlayPageClient() {
|
|||||||
return '打开设置';
|
return '打开设置';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// 只有在未禁用热力图时才显示热力图开关
|
||||||
|
...(!danmakuHeatmapDisabledRef.current ? [{
|
||||||
|
name: '弹幕热力',
|
||||||
|
html: '弹幕热力',
|
||||||
|
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" fill="#ffffff"/></svg>',
|
||||||
|
switch: danmakuHeatmapEnabledRef.current,
|
||||||
|
onSwitch: function (item: any) {
|
||||||
|
const newVal = !item.switch;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('danmaku_heatmap_enabled', String(newVal));
|
||||||
|
setDanmakuHeatmapEnabled(newVal);
|
||||||
|
|
||||||
|
// 使用 opacity 控制热力图显示/隐藏
|
||||||
|
const heatmapElement = document.querySelector('.art-control-heatmap') as HTMLElement;
|
||||||
|
if (heatmapElement) {
|
||||||
|
heatmapElement.style.opacity = newVal ? '1' : '0';
|
||||||
|
heatmapElement.style.pointerEvents = newVal ? 'auto' : 'none';
|
||||||
|
console.log('弹幕热力已', newVal ? '开启' : '关闭');
|
||||||
|
} else {
|
||||||
|
console.warn('未找到热力图元素');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('切换弹幕热力失败:', err);
|
||||||
|
}
|
||||||
|
return newVal;
|
||||||
|
},
|
||||||
|
}] : []),
|
||||||
...(webGPUSupported ? [
|
...(webGPUSupported ? [
|
||||||
{
|
{
|
||||||
name: 'Anime4K超分',
|
name: 'Anime4K超分',
|
||||||
@@ -3872,6 +3965,16 @@ function PlayPageClient() {
|
|||||||
danmakuPluginRef.current.hide();
|
danmakuPluginRef.current.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始隐藏热力图,等待弹幕加载完成后再显示(仅在未禁用热力图时)
|
||||||
|
if (!danmakuHeatmapDisabledRef.current) {
|
||||||
|
const heatmapElement = document.querySelector('.art-control-heatmap') as HTMLElement;
|
||||||
|
if (heatmapElement) {
|
||||||
|
heatmapElement.style.opacity = '0';
|
||||||
|
heatmapElement.style.pointerEvents = 'none';
|
||||||
|
console.log('热力图初始状态: 隐藏(等待弹幕加载)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 自动搜索并加载弹幕
|
// 自动搜索并加载弹幕
|
||||||
await autoSearchDanmaku();
|
await autoSearchDanmaku();
|
||||||
}
|
}
|
||||||
@@ -4404,17 +4507,22 @@ function PlayPageClient() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{/* 完结状态标识 */}
|
{/* 完结状态标识 */}
|
||||||
{detail && totalEpisodes > 1 && (
|
{detail && totalEpisodes > 1 && (() => {
|
||||||
<span
|
const status = getSeriesStatus(detail);
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
if (status === 'unknown') return null;
|
||||||
isSeriesCompleted(detail)
|
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
return (
|
||||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
<span
|
||||||
}`}
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
>
|
status === 'completed'
|
||||||
{isSeriesCompleted(detail) ? '已完结' : '连载中'}
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||||
</span>
|
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
)}
|
}`}
|
||||||
|
>
|
||||||
|
{status === 'completed' ? '已完结' : '连载中'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
{/* 第二行:播放器和选集 */}
|
{/* 第二行:播放器和选集 */}
|
||||||
@@ -4890,6 +4998,17 @@ function PlayPageClient() {
|
|||||||
>
|
>
|
||||||
<FavoriteIcon filled={favorited} />
|
<FavoriteIcon filled={favorited} />
|
||||||
</button>
|
</button>
|
||||||
|
{/* 网盘搜索按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPansouDialog(true);
|
||||||
|
}}
|
||||||
|
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||||
|
title='搜索网盘资源'
|
||||||
|
>
|
||||||
|
<Cloud className='h-6 w-6 text-gray-700 dark:text-gray-300' />
|
||||||
|
</button>
|
||||||
{/* 豆瓣评分显示 */}
|
{/* 豆瓣评分显示 */}
|
||||||
{doubanRating && doubanRating.value > 0 && (
|
{doubanRating && doubanRating.value > 0 && (
|
||||||
<div className='flex items-center gap-2 text-base font-normal'>
|
<div className='flex items-center gap-2 text-base font-normal'>
|
||||||
@@ -5107,6 +5226,40 @@ function PlayPageClient() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 网盘搜索弹窗 */}
|
||||||
|
{showPansouDialog && (
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 z-[10000] flex items-center justify-center bg-black/50'
|
||||||
|
onClick={() => setShowPansouDialog(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='relative w-full max-w-4xl max-h-[80vh] overflow-y-auto bg-white dark:bg-gray-900 rounded-lg shadow-xl m-4'
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 弹窗头部 */}
|
||||||
|
<div className='sticky top-0 z-10 flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900'>
|
||||||
|
<h2 className='text-xl font-bold text-gray-900 dark:text-gray-100'>
|
||||||
|
搜索网盘资源: {detail?.title || ''}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPansouDialog(false)}
|
||||||
|
className='p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
<X className='h-5 w-5 text-gray-600 dark:text-gray-400' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 弹窗内容 */}
|
||||||
|
<div className='p-4'>
|
||||||
|
<PansouSearch
|
||||||
|
keyword={detail?.title || ''}
|
||||||
|
triggerSearch={showPansouDialog}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
148
src/app/private-library/page.tsx
Normal file
148
src/app/private-library/page.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import PageLayout from '@/components/PageLayout';
|
||||||
|
import VideoCard from '@/components/VideoCard';
|
||||||
|
|
||||||
|
interface Video {
|
||||||
|
id: string;
|
||||||
|
folder: string;
|
||||||
|
tmdbId: number;
|
||||||
|
title: string;
|
||||||
|
poster: string;
|
||||||
|
releaseDate: string;
|
||||||
|
overview: string;
|
||||||
|
voteAverage: number;
|
||||||
|
mediaType: 'movie' | 'tv';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PrivateLibraryPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [videos, setVideos] = useState<Video[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVideos();
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const fetchVideos = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/openlist/list?page=${page}&pageSize=${pageSize}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取视频列表失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
setError(data.error);
|
||||||
|
setVideos([]);
|
||||||
|
} else {
|
||||||
|
setVideos(data.list || []);
|
||||||
|
setTotalPages(data.totalPages || 1);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取视频列表失败:', err);
|
||||||
|
setError('获取视频列表失败');
|
||||||
|
setVideos([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoClick = (video: Video) => {
|
||||||
|
// 跳转到播放页面
|
||||||
|
router.push(`/play?source=openlist&id=${encodeURIComponent(video.folder)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout activePath='/private-library'>
|
||||||
|
<div className='container mx-auto px-4 py-6'>
|
||||||
|
<div className='mb-6'>
|
||||||
|
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100'>
|
||||||
|
私人影库
|
||||||
|
</h1>
|
||||||
|
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
观看自我收藏的高清视频吧
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6'>
|
||||||
|
<p className='text-red-800 dark:text-red-200'>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
|
||||||
|
{Array.from({ length: pageSize }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='animate-pulse bg-gray-200 dark:bg-gray-700 rounded-lg aspect-[2/3]'
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : videos.length === 0 ? (
|
||||||
|
<div className='text-center py-12'>
|
||||||
|
<p className='text-gray-500 dark:text-gray-400'>
|
||||||
|
暂无视频,请在管理面板配置 OpenList 并刷新
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
|
||||||
|
{videos.map((video) => (
|
||||||
|
<VideoCard
|
||||||
|
key={video.id}
|
||||||
|
id={video.folder}
|
||||||
|
source='openlist'
|
||||||
|
title={video.title}
|
||||||
|
poster={video.poster}
|
||||||
|
year={video.releaseDate.split('-')[0]}
|
||||||
|
from='search'
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className='flex justify-center items-center gap-4 mt-8'>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className='px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className='text-gray-700 dark:text-gray-300'>
|
||||||
|
第 {page} / {totalPages} 页
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className='px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,12 +18,17 @@ import PageLayout from '@/components/PageLayout';
|
|||||||
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
|
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
|
||||||
import SearchSuggestions from '@/components/SearchSuggestions';
|
import SearchSuggestions from '@/components/SearchSuggestions';
|
||||||
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
|
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
|
||||||
|
import PansouSearch from '@/components/PansouSearch';
|
||||||
|
|
||||||
function SearchPageClient() {
|
function SearchPageClient() {
|
||||||
// 搜索历史
|
// 搜索历史
|
||||||
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||||
// 返回顶部按钮显示状态
|
// 返回顶部按钮显示状态
|
||||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||||
|
// 选项卡状态: 'video' 或 'pansou'
|
||||||
|
const [activeTab, setActiveTab] = useState<'video' | 'pansou'>('video');
|
||||||
|
// Pansou 搜索触发标志
|
||||||
|
const [triggerPansouSearch, setTriggerPansouSearch] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -381,6 +386,14 @@ function SearchPageClient() {
|
|||||||
});
|
});
|
||||||
}, [aggregatedResults, filterAgg, searchQuery]);
|
}, [aggregatedResults, filterAgg, searchQuery]);
|
||||||
|
|
||||||
|
// 监听选项卡切换,自动执行搜索
|
||||||
|
useEffect(() => {
|
||||||
|
// 如果切换到网盘搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索
|
||||||
|
if (activeTab === 'pansou' && searchQuery.trim() && showResults) {
|
||||||
|
setTriggerPansouSearch(prev => !prev);
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 无搜索参数时聚焦搜索框
|
// 无搜索参数时聚焦搜索框
|
||||||
!searchParams.get('q') && document.getElementById('searchInput')?.focus();
|
!searchParams.get('q') && document.getElementById('searchInput')?.focus();
|
||||||
@@ -693,8 +706,15 @@ function SearchPageClient() {
|
|||||||
setShowResults(true);
|
setShowResults(true);
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
|
|
||||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
// 根据当前选项卡执行不同的搜索
|
||||||
// 其余由 searchParams 变化的 effect 处理
|
if (activeTab === 'video') {
|
||||||
|
// 影视搜索
|
||||||
|
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||||
|
// 其余由 searchParams 变化的 effect 处理
|
||||||
|
} else if (activeTab === 'pansou') {
|
||||||
|
// 网盘搜索 - 触发搜索
|
||||||
|
setTriggerPansouSearch(prev => !prev); // 切换状态来触发搜索
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSuggestionSelect = (suggestion: string) => {
|
const handleSuggestionSelect = (suggestion: string) => {
|
||||||
@@ -778,14 +798,41 @@ function SearchPageClient() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* 选项卡 */}
|
||||||
|
<div className='flex justify-center gap-2 mt-6'>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('video')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'video'
|
||||||
|
? 'bg-green-600 text-white dark:bg-green-600'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
影视搜索
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('pansou')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'pansou'
|
||||||
|
? 'bg-green-600 text-white dark:bg-green-600'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
网盘搜索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 搜索结果或搜索历史 */}
|
{/* 搜索结果或搜索历史 */}
|
||||||
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
|
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
|
||||||
{showResults ? (
|
{showResults ? (
|
||||||
<section className='mb-12'>
|
<section className='mb-12'>
|
||||||
{/* 标题 */}
|
{activeTab === 'video' ? (
|
||||||
<div className='mb-4 flex items-center justify-between'>
|
<>
|
||||||
|
{/* 影视搜索结果 */}
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className='mb-4 flex items-center justify-between'>
|
||||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||||
搜索结果
|
搜索结果
|
||||||
{isFromCache ? (
|
{isFromCache ? (
|
||||||
@@ -930,6 +977,21 @@ function SearchPageClient() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 网盘搜索结果 */}
|
||||||
|
<div className='mb-4'>
|
||||||
|
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||||
|
网盘搜索结果
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<PansouSearch
|
||||||
|
keyword={searchQuery}
|
||||||
|
triggerSearch={triggerPansouSearch}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : searchHistory.length > 0 ? (
|
) : searchHistory.length > 0 ? (
|
||||||
// 搜索历史
|
// 搜索历史
|
||||||
@@ -953,9 +1015,18 @@ function SearchPageClient() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchQuery(item);
|
setSearchQuery(item);
|
||||||
router.push(
|
setShowResults(true);
|
||||||
`/search?q=${encodeURIComponent(item.trim())}`
|
|
||||||
);
|
// 根据当前选项卡执行不同的搜索
|
||||||
|
if (activeTab === 'video') {
|
||||||
|
// 影视搜索
|
||||||
|
router.push(
|
||||||
|
`/search?q=${encodeURIComponent(item.trim())}`
|
||||||
|
);
|
||||||
|
} else if (activeTab === 'pansou') {
|
||||||
|
// 网盘搜索
|
||||||
|
setTriggerPansouSearch(prev => !prev);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300'
|
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300'
|
||||||
>
|
>
|
||||||
|
|||||||
236
src/components/CorrectDialog.tsx
Normal file
236
src/components/CorrectDialog.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Search, X } from 'lucide-react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||||
|
import { processImageUrl } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TMDBResult {
|
||||||
|
id: number;
|
||||||
|
title?: string;
|
||||||
|
name?: string;
|
||||||
|
poster_path: string | null;
|
||||||
|
release_date?: string;
|
||||||
|
first_air_date?: string;
|
||||||
|
overview: string;
|
||||||
|
vote_average: number;
|
||||||
|
media_type: 'movie' | 'tv';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CorrectDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
folder: string;
|
||||||
|
currentTitle: string;
|
||||||
|
onCorrect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CorrectDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
folder,
|
||||||
|
currentTitle,
|
||||||
|
onCorrect,
|
||||||
|
}: CorrectDialogProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState(currentTitle);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [results, setResults] = useState<TMDBResult[]>([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [correcting, setCorrecting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSearchQuery(currentTitle);
|
||||||
|
setResults([]);
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [isOpen, currentTitle]);
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setError('请输入搜索关键词');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearching(true);
|
||||||
|
setError('');
|
||||||
|
setResults([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/tmdb/search?query=${encodeURIComponent(searchQuery)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('搜索失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.results) {
|
||||||
|
setResults(data.results);
|
||||||
|
if (data.results.length === 0) {
|
||||||
|
setError('未找到匹配的结果');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError('搜索失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('搜索失败:', err);
|
||||||
|
setError('搜索失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCorrect = async (result: TMDBResult) => {
|
||||||
|
setCorrecting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/openlist/correct', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
folder,
|
||||||
|
tmdbId: result.id,
|
||||||
|
title: result.title || result.name,
|
||||||
|
posterPath: result.poster_path,
|
||||||
|
releaseDate: result.release_date || result.first_air_date,
|
||||||
|
overview: result.overview,
|
||||||
|
voteAverage: result.vote_average,
|
||||||
|
mediaType: result.media_type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('纠错失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
onCorrect();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('纠错失败:', err);
|
||||||
|
setError('纠错失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setCorrecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className='fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm'>
|
||||||
|
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col m-4'>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
|
||||||
|
<h2 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
|
纠错:{currentTitle}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className='text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索框 */}
|
||||||
|
<div className='p-4 border-b border-gray-200 dark:border-gray-700'>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder='输入搜索关键词'
|
||||||
|
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={searching}
|
||||||
|
className='px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2'
|
||||||
|
>
|
||||||
|
<Search size={20} />
|
||||||
|
<span className='hidden sm:inline'>{searching ? '搜索中...' : '搜索'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className='mt-2 text-sm text-red-600 dark:text-red-400'>{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 结果列表 */}
|
||||||
|
<div className='flex-1 overflow-y-auto p-4'>
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
|
||||||
|
{searching ? '搜索中...' : '请输入关键词搜索'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{results.map((result) => (
|
||||||
|
<div
|
||||||
|
key={result.id}
|
||||||
|
className='flex gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors'
|
||||||
|
>
|
||||||
|
{/* 海报 */}
|
||||||
|
<div className='flex-shrink-0 w-16 h-24 relative rounded overflow-hidden bg-gray-200 dark:bg-gray-700'>
|
||||||
|
{result.poster_path ? (
|
||||||
|
<Image
|
||||||
|
src={processImageUrl(getTMDBImageUrl(result.poster_path))}
|
||||||
|
alt={result.title || result.name || ''}
|
||||||
|
fill
|
||||||
|
className='object-cover'
|
||||||
|
referrerPolicy='no-referrer'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='w-full h-full flex items-center justify-center text-gray-400 text-xs'>
|
||||||
|
无海报
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 信息 */}
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<h3 className='font-semibold text-gray-900 dark:text-gray-100 truncate'>
|
||||||
|
{result.title || result.name}
|
||||||
|
</h3>
|
||||||
|
<p className='text-sm text-gray-600 dark:text-gray-400 mt-1'>
|
||||||
|
{result.media_type === 'movie' ? '电影' : '电视剧'} •{' '}
|
||||||
|
{result.release_date?.split('-')[0] ||
|
||||||
|
result.first_air_date?.split('-')[0] ||
|
||||||
|
'未知'}{' '}
|
||||||
|
• 评分: {result.vote_average.toFixed(1)}
|
||||||
|
</p>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1 line-clamp-2'>
|
||||||
|
{result.overview || '暂无简介'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 选择按钮 */}
|
||||||
|
<div className='flex-shrink-0 flex items-center'>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCorrect(result)}
|
||||||
|
disabled={correcting}
|
||||||
|
className='px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed'
|
||||||
|
>
|
||||||
|
{correcting ? '处理中...' : '选择'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -266,7 +266,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
if (
|
if (
|
||||||
!optimizationEnabled || // 若关闭测速则直接退出
|
!optimizationEnabled || // 若关闭测速则直接退出
|
||||||
activeTab !== 'sources' ||
|
activeTab !== 'sources' ||
|
||||||
availableSources.length === 0
|
availableSources.length === 0 ||
|
||||||
|
currentSource === 'openlist' // 私人影库不进行测速
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -293,7 +294,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
|
|
||||||
fetchVideoInfosInBatches();
|
fetchVideoInfosInBatches();
|
||||||
// 依赖项保持与之前一致
|
// 依赖项保持与之前一致
|
||||||
}, [activeTab, availableSources, getVideoInfo, optimizationEnabled, initialTestingCompleted]);
|
}, [activeTab, availableSources, getVideoInfo, optimizationEnabled, initialTestingCompleted, currentSource]);
|
||||||
|
|
||||||
// 升序分页标签
|
// 升序分页标签
|
||||||
const categoriesAsc = useMemo(() => {
|
const categoriesAsc = useMemo(() => {
|
||||||
@@ -635,8 +636,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
if (!title) {
|
if (!title) {
|
||||||
return episodeNumber;
|
return episodeNumber;
|
||||||
}
|
}
|
||||||
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字
|
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字(支持小数)
|
||||||
const match = title.match(/(?:第)?(\d+)(?:集|话)/);
|
const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/);
|
||||||
if (match) {
|
if (match) {
|
||||||
return match[1];
|
return match[1];
|
||||||
}
|
}
|
||||||
@@ -848,6 +849,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{/* 重新测试按钮 */}
|
{/* 重新测试按钮 */}
|
||||||
{(() => {
|
{(() => {
|
||||||
|
// 私人影库不显示重新测试按钮
|
||||||
|
if (source.source === 'openlist') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const sourceKey = `${source.source}-${source.id}`;
|
const sourceKey = `${source.source}-${source.id}`;
|
||||||
const isTesting = retestingSources.has(sourceKey);
|
const isTesting = retestingSources.has(sourceKey);
|
||||||
const videoInfo = videoInfoMap.get(sourceKey);
|
const videoInfo = videoInfoMap.get(sourceKey);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Cat, Clover, Film, Home, Radio, Star, Tv, Users } from 'lucide-react';
|
import { Cat, Clover, Film, FolderOpen, Home, Radio, Star, Tv, Users } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@@ -90,6 +90,15 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 如果配置了 OpenList,添加私人影库入口
|
||||||
|
if (runtimeConfig?.OPENLIST_ENABLED) {
|
||||||
|
items.push({
|
||||||
|
icon: FolderOpen,
|
||||||
|
label: '私人影库',
|
||||||
|
href: '/private-library',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 如果启用观影室,添加观影室入口
|
// 如果启用观影室,添加观影室入口
|
||||||
if (watchRoomContext?.isEnabled) {
|
if (watchRoomContext?.isEnabled) {
|
||||||
items.push({
|
items.push({
|
||||||
|
|||||||
314
src/components/PansouSearch.tsx
Normal file
314
src/components/PansouSearch.tsx
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AlertCircle, Copy, ExternalLink, Loader2 } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { PansouLink, PansouSearchResult } from '@/lib/pansou.client';
|
||||||
|
|
||||||
|
interface PansouSearchProps {
|
||||||
|
keyword: string;
|
||||||
|
triggerSearch?: boolean; // 触发搜索的标志
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网盘类型映射
|
||||||
|
const CLOUD_TYPE_NAMES: Record<string, string> = {
|
||||||
|
baidu: '百度网盘',
|
||||||
|
aliyun: '阿里云盘',
|
||||||
|
quark: '夸克网盘',
|
||||||
|
tianyi: '天翼云盘',
|
||||||
|
uc: 'UC网盘',
|
||||||
|
mobile: '移动云盘',
|
||||||
|
'115': '115网盘',
|
||||||
|
pikpak: 'PikPak',
|
||||||
|
xunlei: '迅雷网盘',
|
||||||
|
'123': '123网盘',
|
||||||
|
magnet: '磁力链接',
|
||||||
|
ed2k: '电驴链接',
|
||||||
|
others: '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 网盘类型颜色
|
||||||
|
const CLOUD_TYPE_COLORS: Record<string, string> = {
|
||||||
|
baidu: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200',
|
||||||
|
aliyun: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-200',
|
||||||
|
quark: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200',
|
||||||
|
tianyi: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-200',
|
||||||
|
uc: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200',
|
||||||
|
mobile: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-200',
|
||||||
|
'115': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-200',
|
||||||
|
pikpak: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-200',
|
||||||
|
xunlei: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-200',
|
||||||
|
'123': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-200',
|
||||||
|
magnet: 'bg-gray-100 text-gray-800 dark:bg-gray-700/40 dark:text-gray-200',
|
||||||
|
ed2k: 'bg-gray-100 text-gray-800 dark:bg-gray-700/40 dark:text-gray-200',
|
||||||
|
others: 'bg-gray-100 text-gray-800 dark:bg-gray-700/40 dark:text-gray-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PansouSearch({
|
||||||
|
keyword,
|
||||||
|
triggerSearch,
|
||||||
|
onError,
|
||||||
|
}: PansouSearchProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [results, setResults] = useState<PansouSearchResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||||
|
const [selectedType, setSelectedType] = useState<string>('all'); // 'all' 表示显示全部
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 只在 triggerSearch 变化时执行搜索,不响应 keyword 变化
|
||||||
|
if (!triggerSearch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentKeyword = keyword.trim();
|
||||||
|
if (!currentKeyword) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchPansou = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResults(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/pansou/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
keyword: currentKeyword,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || '搜索失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: PansouSearchResult = await response.json();
|
||||||
|
setResults(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = err.message || '搜索失败,请检查配置';
|
||||||
|
setError(errorMsg);
|
||||||
|
onError?.(errorMsg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
searchPansou();
|
||||||
|
}, [triggerSearch, onError]); // 移除 keyword 依赖,只依赖 triggerSearch
|
||||||
|
|
||||||
|
const handleCopy = async (text: string, url: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopiedUrl(url);
|
||||||
|
setTimeout(() => setCopiedUrl(null), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('复制失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenLink = (url: string) => {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className='flex items-center justify-center py-12'>
|
||||||
|
<div className='text-center'>
|
||||||
|
<Loader2 className='mx-auto h-8 w-8 animate-spin text-green-600 dark:text-green-400' />
|
||||||
|
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
|
||||||
|
正在搜索网盘资源...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className='flex items-center justify-center py-12'>
|
||||||
|
<div className='text-center'>
|
||||||
|
<AlertCircle className='mx-auto h-12 w-12 text-red-500 dark:text-red-400' />
|
||||||
|
<p className='mt-4 text-sm text-red-600 dark:text-red-400'>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!results || results.total === 0 || !results.merged_by_type) {
|
||||||
|
return (
|
||||||
|
<div className='flex items-center justify-center py-12'>
|
||||||
|
<div className='text-center'>
|
||||||
|
<AlertCircle className='mx-auto h-12 w-12 text-gray-400 dark:text-gray-600' />
|
||||||
|
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
|
||||||
|
未找到相关资源
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudTypes = Object.keys(results.merged_by_type || {});
|
||||||
|
|
||||||
|
// 过滤显示的网盘类型
|
||||||
|
const filteredCloudTypes = selectedType === 'all'
|
||||||
|
? cloudTypes
|
||||||
|
: cloudTypes.filter(type => type === selectedType);
|
||||||
|
|
||||||
|
// 计算每种网盘类型的数量
|
||||||
|
const typeStats = cloudTypes.map(type => ({
|
||||||
|
type,
|
||||||
|
count: results.merged_by_type?.[type]?.length || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* 搜索结果统计 */}
|
||||||
|
<div className='text-sm text-gray-600 dark:text-gray-400'>
|
||||||
|
找到 <span className='font-semibold text-green-600 dark:text-green-400'>{results.total}</span> 个资源
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 网盘类型过滤器 */}
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedType('all')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
selectedType === 'all'
|
||||||
|
? 'bg-green-600 text-white dark:bg-green-600'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
全部 ({results.total})
|
||||||
|
</button>
|
||||||
|
{typeStats.map(({ type, count }) => {
|
||||||
|
const typeName = CLOUD_TYPE_NAMES[type] || type;
|
||||||
|
const typeColor = CLOUD_TYPE_COLORS[type] || CLOUD_TYPE_COLORS.others;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => setSelectedType(type)}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
selectedType === type
|
||||||
|
? 'bg-green-600 text-white dark:bg-green-600'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{typeName} ({count})
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 按网盘类型分类显示 */}
|
||||||
|
{filteredCloudTypes.map((cloudType) => {
|
||||||
|
const links = results.merged_by_type?.[cloudType];
|
||||||
|
if (!links || links.length === 0) return null;
|
||||||
|
|
||||||
|
const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType;
|
||||||
|
const typeColor = CLOUD_TYPE_COLORS[cloudType] || CLOUD_TYPE_COLORS.others;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={cloudType} className='space-y-3'>
|
||||||
|
{/* 网盘类型标题 */}
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${typeColor}`}>
|
||||||
|
{typeName}
|
||||||
|
</span>
|
||||||
|
<span className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
{links.length} 个链接
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 链接列表 */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
{links.map((link: PansouLink, index: number) => (
|
||||||
|
<div
|
||||||
|
key={`${cloudType}-${index}`}
|
||||||
|
className='p-4 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 transition-colors'
|
||||||
|
>
|
||||||
|
{/* 资源标题 */}
|
||||||
|
{link.note && (
|
||||||
|
<div className='mb-2 text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||||
|
{link.note}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 链接和密码 */}
|
||||||
|
<div className='flex items-center gap-2 mb-2'>
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<div className='text-xs text-gray-600 dark:text-gray-400 truncate'>
|
||||||
|
{link.url}
|
||||||
|
</div>
|
||||||
|
{link.password && (
|
||||||
|
<div className='text-xs text-gray-600 dark:text-gray-400 mt-1'>
|
||||||
|
提取码: <span className='font-mono font-semibold'>{link.password}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className='flex items-center gap-1 flex-shrink-0'>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(
|
||||||
|
link.password ? `${link.url}\n提取码: ${link.password}` : link.url,
|
||||||
|
link.url
|
||||||
|
)}
|
||||||
|
className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
|
||||||
|
title='复制链接'
|
||||||
|
>
|
||||||
|
{copiedUrl === link.url ? (
|
||||||
|
<span className='text-xs text-green-600 dark:text-green-400'>已复制</span>
|
||||||
|
) : (
|
||||||
|
<Copy className='h-4 w-4 text-gray-600 dark:text-gray-400' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenLink(link.url)}
|
||||||
|
className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
|
||||||
|
title='打开链接'
|
||||||
|
>
|
||||||
|
<ExternalLink className='h-4 w-4 text-gray-600 dark:text-gray-400' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 来源和时间 */}
|
||||||
|
<div className='flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
{link.source && (
|
||||||
|
<span>来源: {link.source}</span>
|
||||||
|
)}
|
||||||
|
{link.datetime && (
|
||||||
|
<span>{new Date(link.datetime).toLocaleDateString()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图片预览 */}
|
||||||
|
{link.images && link.images.length > 0 && (
|
||||||
|
<div className='mt-3 flex gap-2 overflow-x-auto'>
|
||||||
|
{link.images.map((img, imgIndex) => (
|
||||||
|
<img
|
||||||
|
key={imgIndex}
|
||||||
|
src={img}
|
||||||
|
alt=''
|
||||||
|
className='h-20 w-auto rounded object-cover'
|
||||||
|
loading='lazy'
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Cat, Clover, Film, Home, Menu, Radio, Search, Star, Tv, Users } from 'lucide-react';
|
import { Cat, Clover, Film, FolderOpen, Home, Menu, Radio, Search, Star, Tv, Users } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -179,6 +179,15 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 如果配置了 OpenList,添加私人影库入口
|
||||||
|
if (runtimeConfig?.OPENLIST_ENABLED) {
|
||||||
|
items.push({
|
||||||
|
icon: FolderOpen,
|
||||||
|
label: '私人影库',
|
||||||
|
href: '/private-library',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 如果启用观影室,添加观影室入口
|
// 如果启用观影室,添加观影室入口
|
||||||
if (watchRoomContext?.isEnabled) {
|
if (watchRoomContext?.isEnabled) {
|
||||||
items.push({
|
items.push({
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
const [enableOptimization, setEnableOptimization] = useState(true);
|
const [enableOptimization, setEnableOptimization] = useState(true);
|
||||||
const [fluidSearch, setFluidSearch] = useState(true);
|
const [fluidSearch, setFluidSearch] = useState(true);
|
||||||
const [liveDirectConnect, setLiveDirectConnect] = useState(false);
|
const [liveDirectConnect, setLiveDirectConnect] = useState(false);
|
||||||
|
const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(false);
|
||||||
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
|
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
|
||||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
|
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
|
||||||
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
||||||
@@ -300,6 +301,11 @@ export const UserMenu: React.FC = () => {
|
|||||||
if (savedLiveDirectConnect !== null) {
|
if (savedLiveDirectConnect !== null) {
|
||||||
setLiveDirectConnect(JSON.parse(savedLiveDirectConnect));
|
setLiveDirectConnect(JSON.parse(savedLiveDirectConnect));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedDanmakuHeatmapDisabled = localStorage.getItem('danmaku_heatmap_disabled');
|
||||||
|
if (savedDanmakuHeatmapDisabled !== null) {
|
||||||
|
setDanmakuHeatmapDisabled(savedDanmakuHeatmapDisabled === 'true');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -497,6 +503,13 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDanmakuHeatmapDisabledToggle = (value: boolean) => {
|
||||||
|
setDanmakuHeatmapDisabled(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('danmaku_heatmap_disabled', String(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDoubanDataSourceChange = (value: string) => {
|
const handleDoubanDataSourceChange = (value: string) => {
|
||||||
setDoubanDataSource(value);
|
setDoubanDataSource(value);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -553,6 +566,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
setEnableOptimization(true);
|
setEnableOptimization(true);
|
||||||
setFluidSearch(defaultFluidSearch);
|
setFluidSearch(defaultFluidSearch);
|
||||||
setLiveDirectConnect(false);
|
setLiveDirectConnect(false);
|
||||||
|
setDanmakuHeatmapDisabled(false);
|
||||||
setDoubanProxyUrl(defaultDoubanProxy);
|
setDoubanProxyUrl(defaultDoubanProxy);
|
||||||
setDoubanDataSource(defaultDoubanProxyType);
|
setDoubanDataSource(defaultDoubanProxyType);
|
||||||
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
||||||
@@ -563,6 +577,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
localStorage.setItem('enableOptimization', JSON.stringify(true));
|
localStorage.setItem('enableOptimization', JSON.stringify(true));
|
||||||
localStorage.setItem('fluidSearch', JSON.stringify(defaultFluidSearch));
|
localStorage.setItem('fluidSearch', JSON.stringify(defaultFluidSearch));
|
||||||
localStorage.setItem('liveDirectConnect', JSON.stringify(false));
|
localStorage.setItem('liveDirectConnect', JSON.stringify(false));
|
||||||
|
localStorage.setItem('danmaku_heatmap_disabled', 'false');
|
||||||
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
||||||
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
||||||
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
||||||
@@ -1150,6 +1165,30 @@ export const UserMenu: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 禁用弹幕热力 */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
禁用弹幕热力图
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
完全关闭弹幕热力图功能以提升性能(需手动刷新页面生效)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className='flex items-center cursor-pointer'>
|
||||||
|
<div className='relative'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
className='sr-only peer'
|
||||||
|
checked={danmakuHeatmapDisabled}
|
||||||
|
onChange={(e) => handleDanmakuHeatmapDisabledToggle(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||||
|
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 分割线 */}
|
{/* 分割线 */}
|
||||||
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
||||||
|
|
||||||
|
|||||||
@@ -659,7 +659,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className='bg-black/70 backdrop-blur-sm text-white px-4 py-2 rounded-lg text-sm font-medium shadow-lg'
|
className='bg-black/70 backdrop-blur-sm text-white px-4 py-2 rounded-lg text-xs md:text-sm font-medium shadow-lg'
|
||||||
style={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export interface AdminConfig {
|
|||||||
DanmakuApiToken: string;
|
DanmakuApiToken: string;
|
||||||
// TMDB配置
|
// TMDB配置
|
||||||
TMDBApiKey?: string;
|
TMDBApiKey?: string;
|
||||||
|
TMDBProxy?: string;
|
||||||
|
// Pansou配置
|
||||||
|
PansouApiUrl?: string;
|
||||||
|
PansouUsername?: string;
|
||||||
|
PansouPassword?: string;
|
||||||
// 评论功能开关
|
// 评论功能开关
|
||||||
EnableComments: boolean;
|
EnableComments: boolean;
|
||||||
// 自定义去广告代码
|
// 自定义去广告代码
|
||||||
@@ -90,6 +95,16 @@ export interface AdminConfig {
|
|||||||
cacheMinutes: number; // 缓存时间(分钟)
|
cacheMinutes: number; // 缓存时间(分钟)
|
||||||
cacheVersion: number; // CSS版本号(用于缓存控制)
|
cacheVersion: number; // CSS版本号(用于缓存控制)
|
||||||
};
|
};
|
||||||
|
OpenListConfig?: {
|
||||||
|
Enabled: boolean; // 是否启用私人影库功能
|
||||||
|
URL: string; // OpenList 服务器地址
|
||||||
|
Username: string; // 账号(用于登录获取Token)
|
||||||
|
Password: string; // 密码(用于登录获取Token)
|
||||||
|
RootPath: string; // 根目录路径,默认 "/"
|
||||||
|
LastRefreshTime?: number; // 上次刷新时间戳
|
||||||
|
ResourceCount?: number; // 资源数量
|
||||||
|
ScanInterval?: number; // 定时扫描间隔(分钟),0表示关闭,最低60分钟
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminConfigResult {
|
export interface AdminConfigResult {
|
||||||
|
|||||||
@@ -11,6 +11,25 @@ export interface ChangelogEntry {
|
|||||||
|
|
||||||
export const changelog: ChangelogEntry[] = [
|
export const changelog: ChangelogEntry[] = [
|
||||||
{
|
{
|
||||||
|
version: '204.0.0',
|
||||||
|
date: '2025-12-20',
|
||||||
|
added: [
|
||||||
|
"新增私人影视库功能(实验性)",
|
||||||
|
"增加弹幕热力图",
|
||||||
|
"增加盘搜搜索资源"
|
||||||
|
],
|
||||||
|
changed: [
|
||||||
|
"完全重构用户数据存储结构",
|
||||||
|
"提高所有弹幕接口的超时时间",
|
||||||
|
"优化完结标识判断",
|
||||||
|
"即将上映移动端字体大小调整",
|
||||||
|
"tmdb增加代理支持",
|
||||||
|
"剧集更新检测改为服务器后台定时执行"
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
version: '203.2.2',
|
version: '203.2.2',
|
||||||
date: '2025-12-20',
|
date: '2025-12-20',
|
||||||
added: [
|
added: [
|
||||||
@@ -21,7 +40,8 @@ export const changelog: ChangelogEntry[] = [
|
|||||||
"修复IOS端换集报错播放器初始化失败",
|
"修复IOS端换集报错播放器初始化失败",
|
||||||
"修复超分切换时重复渲染"
|
"修复超分切换时重复渲染"
|
||||||
]
|
]
|
||||||
},{
|
},
|
||||||
|
{
|
||||||
version: '203.2.0',
|
version: '203.2.0',
|
||||||
date: '2025-12-19',
|
date: '2025-12-19',
|
||||||
added: [
|
added: [
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const API_CONFIG = {
|
|||||||
|
|
||||||
// 在模块加载时根据环境决定配置来源
|
// 在模块加载时根据环境决定配置来源
|
||||||
let cachedConfig: AdminConfig;
|
let cachedConfig: AdminConfig;
|
||||||
|
let configInitPromise: Promise<AdminConfig> | null = null;
|
||||||
|
|
||||||
|
|
||||||
// 从配置文件补充管理员配置
|
// 从配置文件补充管理员配置
|
||||||
@@ -223,6 +224,7 @@ async function getInitConfig(configFile: string, subConfig: {
|
|||||||
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
|
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
|
||||||
// TMDB配置
|
// TMDB配置
|
||||||
TMDBApiKey: '',
|
TMDBApiKey: '',
|
||||||
|
TMDBProxy: '',
|
||||||
// 评论功能开关
|
// 评论功能开关
|
||||||
EnableComments: false,
|
EnableComments: false,
|
||||||
},
|
},
|
||||||
@@ -302,32 +304,69 @@ export async function getConfig(): Promise<AdminConfig> {
|
|||||||
return cachedConfig;
|
return cachedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读 db
|
// 如果正在初始化,等待初始化完成
|
||||||
let adminConfig: AdminConfig | null = null;
|
if (configInitPromise) {
|
||||||
let dbReadFailed = false;
|
return configInitPromise;
|
||||||
try {
|
|
||||||
adminConfig = await db.getAdminConfig();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('获取管理员配置失败:', e);
|
|
||||||
dbReadFailed = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// db 中无配置,执行一次初始化
|
// 创建初始化 Promise
|
||||||
if (!adminConfig) {
|
configInitPromise = (async () => {
|
||||||
if (dbReadFailed) {
|
// 读 db
|
||||||
// 数据库读取失败,使用默认配置但不保存,避免覆盖数据库
|
let adminConfig: AdminConfig | null = null;
|
||||||
console.warn('数据库读取失败,使用临时默认配置(不会保存到数据库)');
|
let dbReadFailed = false;
|
||||||
adminConfig = await getInitConfig("");
|
try {
|
||||||
} else {
|
adminConfig = await db.getAdminConfig();
|
||||||
// 数据库中确实没有配置,首次初始化并保存
|
} catch (e) {
|
||||||
console.log('首次初始化配置');
|
console.error('获取管理员配置失败:', e);
|
||||||
adminConfig = await getInitConfig("");
|
dbReadFailed = true;
|
||||||
await db.saveAdminConfig(adminConfig);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
adminConfig = configSelfCheck(adminConfig);
|
// db 中无配置,执行一次初始化
|
||||||
cachedConfig = adminConfig;
|
if (!adminConfig) {
|
||||||
return cachedConfig;
|
if (dbReadFailed) {
|
||||||
|
// 数据库读取失败,使用默认配置但不保存,避免覆盖数据库
|
||||||
|
console.warn('数据库读取失败,使用临时默认配置(不会保存到数据库)');
|
||||||
|
adminConfig = await getInitConfig("");
|
||||||
|
} else {
|
||||||
|
// 数据库中确实没有配置,首次初始化并保存
|
||||||
|
console.log('首次初始化配置');
|
||||||
|
adminConfig = await getInitConfig("");
|
||||||
|
await db.saveAdminConfig(adminConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adminConfig = configSelfCheck(adminConfig);
|
||||||
|
cachedConfig = adminConfig;
|
||||||
|
|
||||||
|
// 自动迁移用户(如果配置中有用户且V2存储支持)
|
||||||
|
// 过滤掉站长后检查是否有需要迁移的用户
|
||||||
|
const nonOwnerUsers = adminConfig.UserConfig.Users.filter(
|
||||||
|
(u) => u.username !== process.env.USERNAME
|
||||||
|
);
|
||||||
|
if (!dbReadFailed && nonOwnerUsers.length > 0) {
|
||||||
|
try {
|
||||||
|
// 检查是否支持V2存储
|
||||||
|
const storage = (db as any).storage;
|
||||||
|
if (storage && typeof storage.createUserV2 === 'function') {
|
||||||
|
console.log('检测到配置中有用户,开始自动迁移...');
|
||||||
|
await db.migrateUsersFromConfig(adminConfig);
|
||||||
|
// 迁移完成后,清空配置中的用户列表并保存
|
||||||
|
adminConfig.UserConfig.Users = [];
|
||||||
|
await db.saveAdminConfig(adminConfig);
|
||||||
|
cachedConfig = adminConfig;
|
||||||
|
console.log('用户自动迁移完成');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('自动迁移用户失败:', error);
|
||||||
|
// 不影响主流程,继续执行
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除初始化 Promise
|
||||||
|
configInitPromise = null;
|
||||||
|
return cachedConfig;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return configInitPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||||
@@ -469,14 +508,15 @@ export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
|
|||||||
return allApiSites;
|
return allApiSites;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userConfig = config.UserConfig.Users.find((u) => u.username === user);
|
// 从V2存储中获取用户信息
|
||||||
if (!userConfig) {
|
const userInfoV2 = await db.getUserInfoV2(user);
|
||||||
|
if (!userInfoV2) {
|
||||||
return allApiSites;
|
return allApiSites;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先根据用户自己的 enabledApis 配置查找
|
// 优先根据用户自己的 enabledApis 配置查找
|
||||||
if (userConfig.enabledApis && userConfig.enabledApis.length > 0) {
|
if (userInfoV2.enabledApis && userInfoV2.enabledApis.length > 0) {
|
||||||
const userApiSitesSet = new Set(userConfig.enabledApis);
|
const userApiSitesSet = new Set(userInfoV2.enabledApis);
|
||||||
return allApiSites.filter((s) => userApiSitesSet.has(s.key)).map((s) => ({
|
return allApiSites.filter((s) => userApiSitesSet.has(s.key)).map((s) => ({
|
||||||
key: s.key,
|
key: s.key,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
@@ -486,11 +526,11 @@ export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有 enabledApis 配置,则根据 tags 查找
|
// 如果没有 enabledApis 配置,则根据 tags 查找
|
||||||
if (userConfig.tags && userConfig.tags.length > 0 && config.UserConfig.Tags) {
|
if (userInfoV2.tags && userInfoV2.tags.length > 0 && config.UserConfig.Tags) {
|
||||||
const enabledApisFromTags = new Set<string>();
|
const enabledApisFromTags = new Set<string>();
|
||||||
|
|
||||||
// 遍历用户的所有 tags,收集对应的 enabledApis
|
// 遍历用户的所有 tags,收集对应的 enabledApis
|
||||||
userConfig.tags.forEach(tagName => {
|
userInfoV2.tags.forEach(tagName => {
|
||||||
const tagConfig = config.UserConfig.Tags?.find(t => t.name === tagName);
|
const tagConfig = config.UserConfig.Tags?.find(t => t.name === tagName);
|
||||||
if (tagConfig && tagConfig.enabledApis) {
|
if (tagConfig && tagConfig.enabledApis) {
|
||||||
tagConfig.enabledApis.forEach(apiKey => enabledApisFromTags.add(apiKey));
|
tagConfig.enabledApis.forEach(apiKey => enabledApisFromTags.add(apiKey));
|
||||||
|
|||||||
195
src/lib/db.ts
195
src/lib/db.ts
@@ -132,7 +132,7 @@ export class DbManager {
|
|||||||
return favorite !== null;
|
return favorite !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 用户相关 ----------
|
// ---------- 用户相关(旧版本,保持兼容) ----------
|
||||||
async registerUser(userName: string, password: string): Promise<void> {
|
async registerUser(userName: string, password: string): Promise<void> {
|
||||||
await this.storage.registerUser(userName, password);
|
await this.storage.registerUser(userName, password);
|
||||||
}
|
}
|
||||||
@@ -154,6 +154,199 @@ export class DbManager {
|
|||||||
await this.storage.deleteUser(userName);
|
await this.storage.deleteUser(userName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 用户相关(新版本) ----------
|
||||||
|
async createUserV2(
|
||||||
|
userName: string,
|
||||||
|
password: string,
|
||||||
|
role: 'owner' | 'admin' | 'user' = 'user',
|
||||||
|
tags?: string[],
|
||||||
|
oidcSub?: string,
|
||||||
|
enabledApis?: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (typeof (this.storage as any).createUserV2 === 'function') {
|
||||||
|
await (this.storage as any).createUserV2(userName, password, role, tags, oidcSub, enabledApis);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyUserV2(userName: string, password: string): Promise<boolean> {
|
||||||
|
if (typeof (this.storage as any).verifyUserV2 === 'function') {
|
||||||
|
return (this.storage as any).verifyUserV2(userName, password);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserInfoV2(userName: string): Promise<{
|
||||||
|
role: 'owner' | 'admin' | 'user';
|
||||||
|
banned: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
oidcSub?: string;
|
||||||
|
enabledApis?: string[];
|
||||||
|
created_at: number;
|
||||||
|
} | null> {
|
||||||
|
if (typeof (this.storage as any).getUserInfoV2 === 'function') {
|
||||||
|
return (this.storage as any).getUserInfoV2(userName);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserInfoV2(
|
||||||
|
userName: string,
|
||||||
|
updates: {
|
||||||
|
role?: 'owner' | 'admin' | 'user';
|
||||||
|
banned?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
oidcSub?: string;
|
||||||
|
enabledApis?: string[];
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
if (typeof (this.storage as any).updateUserInfoV2 === 'function') {
|
||||||
|
await (this.storage as any).updateUserInfoV2(userName, updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePasswordV2(userName: string, newPassword: string): Promise<void> {
|
||||||
|
if (typeof (this.storage as any).changePasswordV2 === 'function') {
|
||||||
|
await (this.storage as any).changePasswordV2(userName, newPassword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkUserExistV2(userName: string): Promise<boolean> {
|
||||||
|
if (typeof (this.storage as any).checkUserExistV2 === 'function') {
|
||||||
|
return (this.storage as any).checkUserExistV2(userName);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserByOidcSub(oidcSub: string): Promise<string | null> {
|
||||||
|
if (typeof (this.storage as any).getUserByOidcSub === 'function') {
|
||||||
|
return (this.storage as any).getUserByOidcSub(oidcSub);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserListV2(
|
||||||
|
offset: number = 0,
|
||||||
|
limit: number = 20,
|
||||||
|
ownerUsername?: string
|
||||||
|
): Promise<{
|
||||||
|
users: Array<{
|
||||||
|
username: string;
|
||||||
|
role: 'owner' | 'admin' | 'user';
|
||||||
|
banned: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
oidcSub?: string;
|
||||||
|
enabledApis?: string[];
|
||||||
|
created_at: number;
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
if (typeof (this.storage as any).getUserListV2 === 'function') {
|
||||||
|
return (this.storage as any).getUserListV2(offset, limit, ownerUsername);
|
||||||
|
}
|
||||||
|
return { users: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUserV2(userName: string): Promise<void> {
|
||||||
|
if (typeof (this.storage as any).deleteUserV2 === 'function') {
|
||||||
|
await (this.storage as any).deleteUserV2(userName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsersByTag(tagName: string): Promise<string[]> {
|
||||||
|
if (typeof (this.storage as any).getUsersByTag === 'function') {
|
||||||
|
return (this.storage as any).getUsersByTag(tagName);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 数据迁移 ----------
|
||||||
|
async migrateUsersFromConfig(adminConfig: AdminConfig): Promise<void> {
|
||||||
|
if (typeof (this.storage as any).createUserV2 !== 'function') {
|
||||||
|
throw new Error('当前存储类型不支持新版用户存储');
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = adminConfig.UserConfig.Users;
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`开始迁移 ${users.length} 个用户...`);
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
// 跳过环境变量中的站长(站长使用环境变量认证,不需要迁移)
|
||||||
|
if (user.username === process.env.USERNAME) {
|
||||||
|
console.log(`跳过站长 ${user.username} 的迁移`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否已经迁移
|
||||||
|
const exists = await this.checkUserExistV2(user.username);
|
||||||
|
if (exists) {
|
||||||
|
console.log(`用户 ${user.username} 已存在,跳过迁移`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取密码
|
||||||
|
let password = '';
|
||||||
|
|
||||||
|
// 如果是OIDC用户,生成随机密码(OIDC用户不需要密码登录)
|
||||||
|
if ((user as any).oidcSub) {
|
||||||
|
password = crypto.randomUUID();
|
||||||
|
console.log(`用户 ${user.username} (OIDC用户) 使用随机密码迁移`);
|
||||||
|
}
|
||||||
|
// 尝试从旧的存储中获取密码
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
if ((this.storage as any).client) {
|
||||||
|
const storedPassword = await (this.storage as any).client.get(`u:${user.username}:pwd`);
|
||||||
|
if (storedPassword) {
|
||||||
|
password = storedPassword;
|
||||||
|
console.log(`用户 ${user.username} 使用旧密码迁移`);
|
||||||
|
} else {
|
||||||
|
// 没有旧密码,使用默认密码
|
||||||
|
password = 'defaultPassword123';
|
||||||
|
console.log(`用户 ${user.username} 没有旧密码,使用默认密码`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
password = 'defaultPassword123';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`获取用户 ${user.username} 的密码失败,使用默认密码`, err);
|
||||||
|
password = 'defaultPassword123';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将站长角色转换为普通角色
|
||||||
|
const migratedRole = user.role === 'owner' ? 'user' : user.role;
|
||||||
|
if (user.role === 'owner') {
|
||||||
|
console.log(`用户 ${user.username} 的角色从 owner 转换为 user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新用户
|
||||||
|
await this.createUserV2(
|
||||||
|
user.username,
|
||||||
|
password,
|
||||||
|
migratedRole,
|
||||||
|
user.tags,
|
||||||
|
(user as any).oidcSub,
|
||||||
|
user.enabledApis
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果用户被封禁,更新状态
|
||||||
|
if (user.banned) {
|
||||||
|
await this.updateUserInfoV2(user.username, { banned: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`用户 ${user.username} 迁移成功`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`迁移用户 ${user.username} 失败:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('用户迁移完成');
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 搜索历史 ----------
|
// ---------- 搜索历史 ----------
|
||||||
async getSearchHistory(userName: string): Promise<string[]> {
|
async getSearchHistory(userName: string): Promise<string[]> {
|
||||||
return this.storage.getSearchHistory(userName);
|
return this.storage.getSearchHistory(userName);
|
||||||
|
|||||||
96
src/lib/lock.ts
Normal file
96
src/lib/lock.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
// 简单的内存锁管理器
|
||||||
|
class LockManager {
|
||||||
|
private locks: Map<string, { locked: boolean; queue: Array<() => void> }> = new Map();
|
||||||
|
private readonly LOCK_TIMEOUT = 10000; // 10秒超时
|
||||||
|
|
||||||
|
async acquire(key: string): Promise<() => void> {
|
||||||
|
// 获取或创建锁对象
|
||||||
|
if (!this.locks.has(key)) {
|
||||||
|
this.locks.set(key, { locked: false, queue: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lock = this.locks.get(key)!;
|
||||||
|
|
||||||
|
// 如果锁未被占用,立即获取
|
||||||
|
if (!lock.locked) {
|
||||||
|
lock.locked = true;
|
||||||
|
|
||||||
|
// 设置超时自动释放
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
this.release(key);
|
||||||
|
}, this.LOCK_TIMEOUT);
|
||||||
|
|
||||||
|
// 返回释放函数
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.release(key);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果锁已被占用,等待
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
// 超时,从队列中移除
|
||||||
|
const index = lock.queue.indexOf(callback);
|
||||||
|
if (index > -1) {
|
||||||
|
lock.queue.splice(index, 1);
|
||||||
|
}
|
||||||
|
reject(new Error('获取锁超时'));
|
||||||
|
}, this.LOCK_TIMEOUT);
|
||||||
|
|
||||||
|
const callback = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
lock.locked = true;
|
||||||
|
|
||||||
|
// 设置超时自动释放
|
||||||
|
const lockTimeoutId = setTimeout(() => {
|
||||||
|
this.release(key);
|
||||||
|
}, this.LOCK_TIMEOUT);
|
||||||
|
|
||||||
|
resolve(() => {
|
||||||
|
clearTimeout(lockTimeoutId);
|
||||||
|
this.release(key);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
lock.queue.push(callback);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private release(key: string): void {
|
||||||
|
const lock = this.locks.get(key);
|
||||||
|
if (!lock) return;
|
||||||
|
|
||||||
|
// 如果队列中有等待者,唤醒下一个
|
||||||
|
if (lock.queue.length > 0) {
|
||||||
|
const next = lock.queue.shift();
|
||||||
|
if (next) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没有等待者,释放锁
|
||||||
|
lock.locked = false;
|
||||||
|
// 清理空的锁对象
|
||||||
|
this.locks.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理所有锁(用于测试或重置)
|
||||||
|
clear(): void {
|
||||||
|
this.locks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局单例
|
||||||
|
const globalKey = Symbol.for('__MOONTV_LOCK_MANAGER__');
|
||||||
|
let _lockManager: LockManager | undefined = (global as any)[globalKey];
|
||||||
|
|
||||||
|
if (!_lockManager) {
|
||||||
|
_lockManager = new LockManager();
|
||||||
|
(global as any)[globalKey] = _lockManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeScript doesn't recognize that lockManager is always defined after the if block
|
||||||
|
export const lockManager = _lockManager as LockManager;
|
||||||
97
src/lib/openlist-cache.ts
Normal file
97
src/lib/openlist-cache.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// metainfo.json 缓存 (7天)
|
||||||
|
interface MetaInfoCacheEntry {
|
||||||
|
expiresAt: number;
|
||||||
|
data: MetaInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// videoinfo.json 缓存 (1天)
|
||||||
|
interface VideoInfoCacheEntry {
|
||||||
|
expiresAt: number;
|
||||||
|
data: VideoInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METAINFO_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7天
|
||||||
|
const VIDEOINFO_CACHE_TTL_MS = (parseInt(process.env.VIDEOINFO_CACHE_MINUTES || '1440', 10)) * 60 * 1000; // 默认1天
|
||||||
|
|
||||||
|
const METAINFO_CACHE: Map<string, MetaInfoCacheEntry> = new Map();
|
||||||
|
const VIDEOINFO_CACHE: Map<string, VideoInfoCacheEntry> = new Map();
|
||||||
|
|
||||||
|
export interface MetaInfo {
|
||||||
|
folders: {
|
||||||
|
[folderName: string]: {
|
||||||
|
tmdb_id: number;
|
||||||
|
title: string;
|
||||||
|
poster_path: string | null;
|
||||||
|
release_date: string;
|
||||||
|
overview: string;
|
||||||
|
vote_average: number;
|
||||||
|
media_type: 'movie' | 'tv';
|
||||||
|
last_updated: number;
|
||||||
|
failed?: boolean; // 标记是否搜索失败
|
||||||
|
};
|
||||||
|
};
|
||||||
|
last_refresh: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoInfo {
|
||||||
|
episodes: {
|
||||||
|
[fileName: string]: {
|
||||||
|
episode: number;
|
||||||
|
season?: number;
|
||||||
|
title?: string;
|
||||||
|
parsed_from: 'videoinfo' | 'filename';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
last_updated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaInfo 缓存操作
|
||||||
|
export function getCachedMetaInfo(rootPath: string): MetaInfo | null {
|
||||||
|
const entry = METAINFO_CACHE.get(rootPath);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
METAINFO_CACHE.delete(rootPath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedMetaInfo(rootPath: string, data: MetaInfo): void {
|
||||||
|
METAINFO_CACHE.set(rootPath, {
|
||||||
|
expiresAt: Date.now() + METAINFO_CACHE_TTL_MS,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateMetaInfoCache(rootPath: string): void {
|
||||||
|
METAINFO_CACHE.delete(rootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// VideoInfo 缓存操作
|
||||||
|
export function getCachedVideoInfo(folderPath: string): VideoInfo | null {
|
||||||
|
const entry = VIDEOINFO_CACHE.get(folderPath);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
VIDEOINFO_CACHE.delete(folderPath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedVideoInfo(
|
||||||
|
folderPath: string,
|
||||||
|
data: VideoInfo
|
||||||
|
): void {
|
||||||
|
VIDEOINFO_CACHE.set(folderPath, {
|
||||||
|
expiresAt: Date.now() + VIDEOINFO_CACHE_TTL_MS,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateVideoInfoCache(folderPath: string): void {
|
||||||
|
VIDEOINFO_CACHE.delete(folderPath);
|
||||||
|
}
|
||||||
318
src/lib/openlist.client.ts
Normal file
318
src/lib/openlist.client.ts
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
// Token 内存缓存
|
||||||
|
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
export interface OpenListFile {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
is_dir: boolean;
|
||||||
|
modified: string;
|
||||||
|
sign?: string; // 临时下载签名
|
||||||
|
raw_url?: string; // 完整下载链接
|
||||||
|
thumb?: string;
|
||||||
|
type: number;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenListListResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
content: OpenListFile[];
|
||||||
|
total: number;
|
||||||
|
readme: string;
|
||||||
|
write: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenListGetResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: OpenListFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenListClient {
|
||||||
|
private token: string = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private baseURL: string,
|
||||||
|
private username: string,
|
||||||
|
private password: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用账号密码登录获取Token
|
||||||
|
*/
|
||||||
|
static async login(
|
||||||
|
baseURL: string,
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await fetch(`${baseURL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OpenList 登录失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.code !== 200 || !data.data?.token) {
|
||||||
|
throw new Error('OpenList 登录失败: 未获取到Token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的 Token 或重新登录
|
||||||
|
*/
|
||||||
|
private async getToken(): Promise<string> {
|
||||||
|
const cacheKey = `${this.baseURL}:${this.username}`;
|
||||||
|
const cached = tokenCache.get(cacheKey);
|
||||||
|
|
||||||
|
// 如果有缓存且未过期,直接返回
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
this.token = cached.token;
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则重新登录
|
||||||
|
console.log('[OpenListClient] Token 不存在或已过期,重新登录');
|
||||||
|
this.token = await OpenListClient.login(
|
||||||
|
this.baseURL,
|
||||||
|
this.username,
|
||||||
|
this.password
|
||||||
|
);
|
||||||
|
|
||||||
|
// 缓存 Token,设置 1 小时过期
|
||||||
|
tokenCache.set(cacheKey, {
|
||||||
|
token: this.token,
|
||||||
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[OpenListClient] 登录成功,Token 已缓存');
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除 Token 缓存(当 Token 失效时调用)
|
||||||
|
*/
|
||||||
|
private clearTokenCache(): void {
|
||||||
|
const cacheKey = `${this.baseURL}:${this.username}`;
|
||||||
|
tokenCache.delete(cacheKey);
|
||||||
|
console.log('[OpenListClient] Token 缓存已清除');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行请求,如果401则清除缓存并重新登录后重试
|
||||||
|
*/
|
||||||
|
private async fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit,
|
||||||
|
retried = false
|
||||||
|
): Promise<Response> {
|
||||||
|
// 获取 Token
|
||||||
|
const token = await this.getToken();
|
||||||
|
|
||||||
|
// 更新请求头中的 Token
|
||||||
|
const requestOptions = {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
Authorization: token,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, requestOptions);
|
||||||
|
|
||||||
|
// 检查 HTTP status 401
|
||||||
|
if (response.status === 401 && !retried) {
|
||||||
|
console.log('[OpenListClient] 收到 HTTP 401,清除 Token 缓存并重试');
|
||||||
|
this.clearTokenCache();
|
||||||
|
return this.fetchWithRetry(url, options, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查响应体中的 code 字段(OpenList 的 Token 过期时 HTTP status 是 200,但 code 是 401)
|
||||||
|
if (response.ok && !retried) {
|
||||||
|
try {
|
||||||
|
// 克隆响应以便读取 JSON
|
||||||
|
const clonedResponse = response.clone();
|
||||||
|
const data = await clonedResponse.json();
|
||||||
|
|
||||||
|
if (data.code === 401) {
|
||||||
|
console.log('[OpenListClient] 响应体 code 为 401,Token 已过期,清除缓存并重试');
|
||||||
|
this.clearTokenCache();
|
||||||
|
return this.fetchWithRetry(url, options, true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 如果解析 JSON 失败,忽略错误,返回原始响应
|
||||||
|
console.warn('[OpenListClient] 解析响应 JSON 失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHeaders() {
|
||||||
|
const token = await this.getToken();
|
||||||
|
return {
|
||||||
|
Authorization: token, // 不带 bearer
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列出目录
|
||||||
|
async listDirectory(
|
||||||
|
path: string,
|
||||||
|
page = 1,
|
||||||
|
perPage = 100,
|
||||||
|
refresh = false
|
||||||
|
): Promise<OpenListListResponse> {
|
||||||
|
const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/list`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await this.getHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
path,
|
||||||
|
password: '',
|
||||||
|
refresh,
|
||||||
|
page,
|
||||||
|
per_page: perPage,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OpenList API 错误: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件信息
|
||||||
|
async getFile(path: string): Promise<OpenListGetResponse> {
|
||||||
|
const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/get`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await this.getHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
path,
|
||||||
|
password: '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OpenList API 错误: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传文件
|
||||||
|
async uploadFile(path: string, content: string): Promise<void> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/put`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: token,
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'File-Path': encodeURIComponent(path),
|
||||||
|
'As-Task': 'false',
|
||||||
|
},
|
||||||
|
body: content,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`OpenList 上传失败: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传成功后刷新目录缓存
|
||||||
|
const dir = path.substring(0, path.lastIndexOf('/')) || '/';
|
||||||
|
await this.refreshDirectory(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新目录缓存
|
||||||
|
async refreshDirectory(path: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/list`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await this.getHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
path,
|
||||||
|
password: '',
|
||||||
|
refresh: true,
|
||||||
|
page: 1,
|
||||||
|
per_page: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`刷新目录缓存失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('刷新目录缓存失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除文件
|
||||||
|
async deleteFile(path: string): Promise<void> {
|
||||||
|
const dir = path.substring(0, path.lastIndexOf('/')) || '/';
|
||||||
|
const fileName = path.substring(path.lastIndexOf('/') + 1);
|
||||||
|
|
||||||
|
const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/remove`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await this.getHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
names: [fileName],
|
||||||
|
dir: dir,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OpenList 删除失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查连通性
|
||||||
|
async checkConnectivity(): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithRetry(`${this.baseURL}/api/me`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: await this.getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `HTTP 状态码错误: ${response.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.code !== 200) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `响应码错误: ${data.code}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '连接成功',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '连接失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
src/lib/pansou.client.ts
Normal file
218
src/lib/pansou.client.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pansou 网盘搜索 API 客户端
|
||||||
|
* 文档: https://github.com/fish2018/pansou
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Token 缓存
|
||||||
|
let cachedToken: string | null = null;
|
||||||
|
let tokenExpiry: number | null = null;
|
||||||
|
|
||||||
|
export interface PansouLink {
|
||||||
|
url: string;
|
||||||
|
password: string;
|
||||||
|
note: string;
|
||||||
|
datetime: string;
|
||||||
|
source: string;
|
||||||
|
images?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PansouSearchResult {
|
||||||
|
total: number;
|
||||||
|
merged_by_type?: {
|
||||||
|
[key: string]: PansouLink[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PansouLoginResponse {
|
||||||
|
token: string;
|
||||||
|
expires_at: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录 Pansou 获取 Token
|
||||||
|
*/
|
||||||
|
export async function loginPansou(
|
||||||
|
apiUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || '登录失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: PansouLoginResponse = await response.json();
|
||||||
|
|
||||||
|
// 缓存 Token
|
||||||
|
cachedToken = data.token;
|
||||||
|
tokenExpiry = data.expires_at;
|
||||||
|
|
||||||
|
return data.token;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Pansou 登录失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取有效的 Token(自动处理登录和缓存)
|
||||||
|
*/
|
||||||
|
async function getValidToken(
|
||||||
|
apiUrl: string,
|
||||||
|
username?: string,
|
||||||
|
password?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
// 如果没有配置账号密码,返回 null(不需要认证)
|
||||||
|
if (!username || !password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查缓存的 Token 是否有效
|
||||||
|
if (cachedToken && tokenExpiry) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
// 提前 5 分钟刷新 Token
|
||||||
|
if (tokenExpiry - now > 300) {
|
||||||
|
return cachedToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token 过期或不存在,重新登录
|
||||||
|
try {
|
||||||
|
return await loginPansou(apiUrl, username, password);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 Pansou Token 失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索网盘资源
|
||||||
|
*/
|
||||||
|
export async function searchPansou(
|
||||||
|
apiUrl: string,
|
||||||
|
keyword: string,
|
||||||
|
options?: {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
refresh?: boolean;
|
||||||
|
cloudTypes?: string[];
|
||||||
|
}
|
||||||
|
): Promise<PansouSearchResult> {
|
||||||
|
try {
|
||||||
|
// 获取 Token(如果需要认证)
|
||||||
|
const token = await getValidToken(
|
||||||
|
apiUrl,
|
||||||
|
options?.username,
|
||||||
|
options?.password
|
||||||
|
);
|
||||||
|
|
||||||
|
// 构建请求头
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建请求体
|
||||||
|
const body: any = {
|
||||||
|
kw: keyword,
|
||||||
|
res: 'merge', // 只返回按网盘类型分类的结果
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.refresh) {
|
||||||
|
body.refresh = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.cloudTypes && options.cloudTypes.length > 0) {
|
||||||
|
body.cloud_types = options.cloudTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${apiUrl}/api/search`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || error.message || '搜索失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
|
||||||
|
// Pansou API 返回的数据结构是 { code, message, data }
|
||||||
|
// 实际数据在 data 字段中
|
||||||
|
let data: PansouSearchResult;
|
||||||
|
|
||||||
|
if (responseData.data) {
|
||||||
|
// 如果有 data 字段,使用 data 中的内容
|
||||||
|
data = responseData.data;
|
||||||
|
} else {
|
||||||
|
// 否则直接使用返回的数据
|
||||||
|
data = responseData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证返回的数据结构
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
throw new Error('返回数据格式错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 merged_by_type 存在
|
||||||
|
if (!data.merged_by_type) {
|
||||||
|
data.merged_by_type = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 total 存在
|
||||||
|
if (typeof data.total !== 'number') {
|
||||||
|
data.total = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Pansou 搜索失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除缓存的 Token
|
||||||
|
*/
|
||||||
|
export function clearPansouToken(): void {
|
||||||
|
cachedToken = null;
|
||||||
|
tokenExpiry = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 Pansou 服务是否可用
|
||||||
|
*/
|
||||||
|
export async function checkPansouHealth(apiUrl: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.status === 'ok';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Pansou 健康检查失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -242,7 +242,7 @@ export abstract class BaseRedisStorage implements IStorage {
|
|||||||
await this.withRetry(() => this.client.del(this.favKey(userName, key)));
|
await this.withRetry(() => this.client.del(this.favKey(userName, key)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 用户注册 / 登录 ----------
|
// ---------- 用户注册 / 登录(旧版本,保持兼容) ----------
|
||||||
private userPwdKey(user: string) {
|
private userPwdKey(user: string) {
|
||||||
return `u:${user}:pwd`;
|
return `u:${user}:pwd`;
|
||||||
}
|
}
|
||||||
@@ -314,6 +314,322 @@ export abstract class BaseRedisStorage implements IStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 新版用户存储(使用Hash和Sorted Set) ----------
|
||||||
|
private userInfoKey(userName: string) {
|
||||||
|
return `user:${userName}:info`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private userListKey() {
|
||||||
|
return 'user:list';
|
||||||
|
}
|
||||||
|
|
||||||
|
private oidcSubKey(oidcSub: string) {
|
||||||
|
return `oidc:sub:${oidcSub}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHA256加密密码
|
||||||
|
private async hashPassword(password: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(password);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新用户(新版本)
|
||||||
|
async createUserV2(
|
||||||
|
userName: string,
|
||||||
|
password: string,
|
||||||
|
role: 'owner' | 'admin' | 'user' = 'user',
|
||||||
|
tags?: string[],
|
||||||
|
oidcSub?: string,
|
||||||
|
enabledApis?: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
const hashedPassword = await this.hashPassword(password);
|
||||||
|
const createdAt = Date.now();
|
||||||
|
|
||||||
|
// 存储用户信息到Hash
|
||||||
|
const userInfo: Record<string, string> = {
|
||||||
|
role,
|
||||||
|
banned: 'false',
|
||||||
|
password: hashedPassword,
|
||||||
|
created_at: createdAt.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tags && tags.length > 0) {
|
||||||
|
userInfo.tags = JSON.stringify(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabledApis && enabledApis.length > 0) {
|
||||||
|
userInfo.enabledApis = JSON.stringify(enabledApis);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oidcSub) {
|
||||||
|
userInfo.oidcSub = oidcSub;
|
||||||
|
// 创建OIDC映射
|
||||||
|
await this.withRetry(() => this.client.set(this.oidcSubKey(oidcSub), userName));
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.withRetry(() => this.client.hSet(this.userInfoKey(userName), userInfo));
|
||||||
|
|
||||||
|
// 添加到用户列表(Sorted Set,按注册时间排序)
|
||||||
|
await this.withRetry(() => this.client.zAdd(this.userListKey(), {
|
||||||
|
score: createdAt,
|
||||||
|
value: userName,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 如果创建的是站长用户,清除站长存在状态缓存
|
||||||
|
if (userName === process.env.USERNAME) {
|
||||||
|
const { ownerExistenceCache } = await import('./user-cache');
|
||||||
|
ownerExistenceCache.delete(userName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户密码(新版本)
|
||||||
|
async verifyUserV2(userName: string, password: string): Promise<boolean> {
|
||||||
|
const userInfo = await this.withRetry(() =>
|
||||||
|
this.client.hGetAll(this.userInfoKey(userName))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!userInfo || !userInfo.password) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await this.hashPassword(password);
|
||||||
|
return userInfo.password === hashedPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息(新版本)
|
||||||
|
async getUserInfoV2(userName: string): Promise<{
|
||||||
|
role: 'owner' | 'admin' | 'user';
|
||||||
|
banned: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
oidcSub?: string;
|
||||||
|
enabledApis?: string[];
|
||||||
|
created_at: number;
|
||||||
|
} | null> {
|
||||||
|
const userInfo = await this.withRetry(() =>
|
||||||
|
this.client.hGetAll(this.userInfoKey(userName))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!userInfo || Object.keys(userInfo).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: (userInfo.role as 'owner' | 'admin' | 'user') || 'user',
|
||||||
|
banned: userInfo.banned === 'true',
|
||||||
|
tags: userInfo.tags ? JSON.parse(userInfo.tags) : undefined,
|
||||||
|
oidcSub: userInfo.oidcSub,
|
||||||
|
enabledApis: userInfo.enabledApis ? JSON.parse(userInfo.enabledApis) : undefined,
|
||||||
|
created_at: parseInt(userInfo.created_at || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新用户信息(新版本)
|
||||||
|
async updateUserInfoV2(
|
||||||
|
userName: string,
|
||||||
|
updates: {
|
||||||
|
role?: 'owner' | 'admin' | 'user';
|
||||||
|
banned?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
oidcSub?: string;
|
||||||
|
enabledApis?: string[];
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const userInfo: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (updates.role !== undefined) {
|
||||||
|
userInfo.role = updates.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.banned !== undefined) {
|
||||||
|
userInfo.banned = updates.banned ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.tags !== undefined) {
|
||||||
|
if (updates.tags.length > 0) {
|
||||||
|
userInfo.tags = JSON.stringify(updates.tags);
|
||||||
|
} else {
|
||||||
|
// 删除tags字段
|
||||||
|
await this.withRetry(() => this.client.hDel(this.userInfoKey(userName), 'tags'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.enabledApis !== undefined) {
|
||||||
|
if (updates.enabledApis.length > 0) {
|
||||||
|
userInfo.enabledApis = JSON.stringify(updates.enabledApis);
|
||||||
|
} else {
|
||||||
|
// 删除enabledApis字段
|
||||||
|
await this.withRetry(() => this.client.hDel(this.userInfoKey(userName), 'enabledApis'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.oidcSub !== undefined) {
|
||||||
|
const oldInfo = await this.getUserInfoV2(userName);
|
||||||
|
if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) {
|
||||||
|
// 删除旧的OIDC映射
|
||||||
|
await this.withRetry(() => this.client.del(this.oidcSubKey(oldInfo.oidcSub!)));
|
||||||
|
}
|
||||||
|
userInfo.oidcSub = updates.oidcSub;
|
||||||
|
// 创建新的OIDC映射
|
||||||
|
await this.withRetry(() => this.client.set(this.oidcSubKey(updates.oidcSub!), userName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(userInfo).length > 0) {
|
||||||
|
await this.withRetry(() => this.client.hSet(this.userInfoKey(userName), userInfo));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改用户密码(新版本)
|
||||||
|
async changePasswordV2(userName: string, newPassword: string): Promise<void> {
|
||||||
|
const hashedPassword = await this.hashPassword(newPassword);
|
||||||
|
await this.withRetry(() =>
|
||||||
|
this.client.hSet(this.userInfoKey(userName), 'password', hashedPassword)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否存在(新版本)
|
||||||
|
async checkUserExistV2(userName: string): Promise<boolean> {
|
||||||
|
const exists = await this.withRetry(() =>
|
||||||
|
this.client.exists(this.userInfoKey(userName))
|
||||||
|
);
|
||||||
|
return exists === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过OIDC Sub查找用户名
|
||||||
|
async getUserByOidcSub(oidcSub: string): Promise<string | null> {
|
||||||
|
const userName = await this.withRetry(() =>
|
||||||
|
this.client.get(this.oidcSubKey(oidcSub))
|
||||||
|
);
|
||||||
|
return userName ? ensureString(userName) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户列表(分页,新版本)
|
||||||
|
async getUserListV2(
|
||||||
|
offset: number = 0,
|
||||||
|
limit: number = 20,
|
||||||
|
ownerUsername?: string
|
||||||
|
): Promise<{
|
||||||
|
users: Array<{
|
||||||
|
username: string;
|
||||||
|
role: 'owner' | 'admin' | 'user';
|
||||||
|
banned: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
oidcSub?: string;
|
||||||
|
enabledApis?: string[];
|
||||||
|
created_at: number;
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
// 获取总数
|
||||||
|
let total = await this.withRetry(() => this.client.zCard(this.userListKey()));
|
||||||
|
|
||||||
|
// 检查站长是否在数据库中(使用缓存)
|
||||||
|
let ownerInfo = null;
|
||||||
|
let ownerInDatabase = false;
|
||||||
|
if (ownerUsername) {
|
||||||
|
// 先检查缓存
|
||||||
|
const { ownerExistenceCache } = await import('./user-cache');
|
||||||
|
const cachedExists = ownerExistenceCache.get(ownerUsername);
|
||||||
|
|
||||||
|
if (cachedExists !== null) {
|
||||||
|
// 使用缓存的结果
|
||||||
|
ownerInDatabase = cachedExists;
|
||||||
|
if (ownerInDatabase) {
|
||||||
|
// 如果站长在数据库中,获取详细信息
|
||||||
|
ownerInfo = await this.getUserInfoV2(ownerUsername);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 缓存未命中,查询数据库
|
||||||
|
ownerInfo = await this.getUserInfoV2(ownerUsername);
|
||||||
|
ownerInDatabase = !!ownerInfo;
|
||||||
|
// 更新缓存
|
||||||
|
ownerExistenceCache.set(ownerUsername, ownerInDatabase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果站长不在数据库中,总数+1(无论在哪一页都要加)
|
||||||
|
if (!ownerInDatabase) {
|
||||||
|
total += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果站长不在数据库中且在第一页,需要调整获取的用户数量和偏移量
|
||||||
|
let actualOffset = offset;
|
||||||
|
let actualLimit = limit;
|
||||||
|
|
||||||
|
if (ownerUsername && !ownerInDatabase) {
|
||||||
|
if (offset === 0) {
|
||||||
|
// 第一页:只获取 limit-1 个用户,为站长留出位置
|
||||||
|
actualLimit = limit - 1;
|
||||||
|
} else {
|
||||||
|
// 其他页:偏移量需要减1,因为站长占据了第一页的一个位置
|
||||||
|
actualOffset = offset - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户列表(按注册时间升序)
|
||||||
|
const usernames = await this.withRetry(() =>
|
||||||
|
this.client.zRange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
const users = [];
|
||||||
|
|
||||||
|
// 如果有站长且在第一页,确保站长始终在第一位
|
||||||
|
if (ownerUsername && offset === 0) {
|
||||||
|
// 即使站长不在数据库中,也要添加站长(站长使用环境变量认证)
|
||||||
|
users.push({
|
||||||
|
username: ownerUsername,
|
||||||
|
role: 'owner' as const,
|
||||||
|
banned: ownerInfo?.banned || false,
|
||||||
|
tags: ownerInfo?.tags,
|
||||||
|
created_at: ownerInfo?.created_at || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取其他用户信息
|
||||||
|
for (const username of usernames) {
|
||||||
|
const usernameStr = ensureString(username);
|
||||||
|
// 跳过站长(已经添加)
|
||||||
|
if (ownerUsername && usernameStr === ownerUsername) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = await this.getUserInfoV2(usernameStr);
|
||||||
|
if (userInfo) {
|
||||||
|
users.push({
|
||||||
|
username: usernameStr,
|
||||||
|
role: userInfo.role,
|
||||||
|
banned: userInfo.banned,
|
||||||
|
tags: userInfo.tags,
|
||||||
|
created_at: userInfo.created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { users, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户(新版本)
|
||||||
|
async deleteUserV2(userName: string): Promise<void> {
|
||||||
|
// 获取用户信息
|
||||||
|
const userInfo = await this.getUserInfoV2(userName);
|
||||||
|
|
||||||
|
// 删除OIDC映射
|
||||||
|
if (userInfo?.oidcSub) {
|
||||||
|
await this.withRetry(() => this.client.del(this.oidcSubKey(userInfo.oidcSub!)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户信息Hash
|
||||||
|
await this.withRetry(() => this.client.del(this.userInfoKey(userName)));
|
||||||
|
|
||||||
|
// 从用<E4BB8E><E794A8><EFBFBD>列表中移除
|
||||||
|
await this.withRetry(() => this.client.zRem(this.userListKey(), userName));
|
||||||
|
|
||||||
|
// 删除用户的其他数据(播放记录、收藏等)
|
||||||
|
await this.deleteUser(userName);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 搜索历史 ----------
|
// ---------- 搜索历史 ----------
|
||||||
private shKey(user: string) {
|
private shKey(user: string) {
|
||||||
return `u:${user}:sh`; // u:username:sh
|
return `u:${user}:sh`; // u:username:sh
|
||||||
@@ -348,13 +664,20 @@ export abstract class BaseRedisStorage implements IStorage {
|
|||||||
|
|
||||||
// ---------- 获取全部用户 ----------
|
// ---------- 获取全部用户 ----------
|
||||||
async getAllUsers(): Promise<string[]> {
|
async getAllUsers(): Promise<string[]> {
|
||||||
const keys = await this.withRetry(() => this.client.keys('u:*:pwd'));
|
// 从新版用户列表获取
|
||||||
return keys
|
const userListKey = this.userListKey();
|
||||||
.map((k) => {
|
const users = await this.withRetry(() =>
|
||||||
const match = k.match(/^u:(.+?):pwd$/);
|
this.client.zRange(userListKey, 0, -1)
|
||||||
return match ? ensureString(match[1]) : undefined;
|
);
|
||||||
})
|
const userList = users.map(u => ensureString(u));
|
||||||
.filter((u): u is string => typeof u === 'string');
|
|
||||||
|
// 确保站长在列表中(站长可能不在数据库中,使用环境变量认证)
|
||||||
|
const ownerUsername = process.env.USERNAME;
|
||||||
|
if (ownerUsername && !userList.includes(ownerUsername)) {
|
||||||
|
userList.unshift(ownerUsername);
|
||||||
|
}
|
||||||
|
|
||||||
|
return userList;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 管理员配置 ----------
|
// ---------- 管理员配置 ----------
|
||||||
|
|||||||
131
src/lib/scan-task.ts
Normal file
131
src/lib/scan-task.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台扫描任务管理
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScanTask {
|
||||||
|
id: string;
|
||||||
|
status: 'running' | 'completed' | 'failed';
|
||||||
|
progress: {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
currentFolder?: string;
|
||||||
|
};
|
||||||
|
result?: {
|
||||||
|
total: number;
|
||||||
|
new: number;
|
||||||
|
existing: number;
|
||||||
|
errors: number;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = new Map<string, ScanTask>();
|
||||||
|
|
||||||
|
export function createScanTask(): string {
|
||||||
|
const id = `scan_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||||
|
const task: ScanTask = {
|
||||||
|
id,
|
||||||
|
status: 'running',
|
||||||
|
progress: {
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
startTime: Date.now(),
|
||||||
|
};
|
||||||
|
tasks.set(id, task);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScanTask(id: string): ScanTask | null {
|
||||||
|
return tasks.get(id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateScanTaskProgress(
|
||||||
|
id: string,
|
||||||
|
current: number,
|
||||||
|
total: number,
|
||||||
|
currentFolder?: string
|
||||||
|
): void {
|
||||||
|
let task = tasks.get(id);
|
||||||
|
if (!task) {
|
||||||
|
// 如果任务不存在(可能因为模块重新加载),重新创建任务
|
||||||
|
console.warn(`[ScanTask] 任务 ${id} 不存在,重新创建`);
|
||||||
|
task = {
|
||||||
|
id,
|
||||||
|
status: 'running',
|
||||||
|
progress: {
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
startTime: Date.now(),
|
||||||
|
};
|
||||||
|
tasks.set(id, task);
|
||||||
|
}
|
||||||
|
task.progress = { current, total, currentFolder };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeScanTask(
|
||||||
|
id: string,
|
||||||
|
result: ScanTask['result']
|
||||||
|
): void {
|
||||||
|
let task = tasks.get(id);
|
||||||
|
if (!task) {
|
||||||
|
// 如果任务不存在(可能因为模块重新加载),重新创建任务
|
||||||
|
console.warn(`[ScanTask] 任务 ${id} 不存在,重新创建并标记为完成`);
|
||||||
|
task = {
|
||||||
|
id,
|
||||||
|
status: 'completed',
|
||||||
|
progress: {
|
||||||
|
current: result?.total || 0,
|
||||||
|
total: result?.total || 0,
|
||||||
|
},
|
||||||
|
startTime: Date.now() - 60000, // 假设任务运行了1分钟
|
||||||
|
endTime: Date.now(),
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
tasks.set(id, task);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
task.status = 'completed';
|
||||||
|
task.result = result;
|
||||||
|
task.endTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function failScanTask(id: string, error: string): void {
|
||||||
|
let task = tasks.get(id);
|
||||||
|
if (!task) {
|
||||||
|
// 如果任务不存在(可能因为模块重新加载),重新创建任务
|
||||||
|
console.warn(`[ScanTask] 任务 ${id} 不存在,重新创建并标记为失败`);
|
||||||
|
task = {
|
||||||
|
id,
|
||||||
|
status: 'failed',
|
||||||
|
progress: {
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
startTime: Date.now() - 60000, // 假设任务运行了1分钟
|
||||||
|
endTime: Date.now(),
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
tasks.set(id, task);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
task.status = 'failed';
|
||||||
|
task.error = error;
|
||||||
|
task.endTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupOldTasks(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const maxAge = 60 * 60 * 1000; // 1小时
|
||||||
|
|
||||||
|
for (const [id, task] of Array.from(tasks.entries())) {
|
||||||
|
if (task.endTime && now - task.endTime > maxAge) {
|
||||||
|
tasks.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||||
|
|
||||||
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
|
import nodeFetch from 'node-fetch';
|
||||||
|
|
||||||
export interface TMDBMovie {
|
export interface TMDBMovie {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -48,28 +51,42 @@ interface TMDBTVAiringTodayResponse {
|
|||||||
* @param apiKey - TMDB API Key
|
* @param apiKey - TMDB API Key
|
||||||
* @param page - 页码
|
* @param page - 页码
|
||||||
* @param region - 地区代码,默认 CN (中国)
|
* @param region - 地区代码,默认 CN (中国)
|
||||||
|
* @param proxy - 代理服务器地址
|
||||||
* @returns 即将上映的电影列表
|
* @returns 即将上映的电影列表
|
||||||
*/
|
*/
|
||||||
export async function getTMDBUpcomingMovies(
|
export async function getTMDBUpcomingMovies(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
region: string = 'CN'
|
region: string = 'CN',
|
||||||
|
proxy?: string
|
||||||
): Promise<{ code: number; list: TMDBMovie[] }> {
|
): Promise<{ code: number; list: TMDBMovie[] }> {
|
||||||
try {
|
try {
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return { code: 400, list: [] };
|
return { code: 400, list: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
const url = `https://api.themoviedb.org/3/movie/upcoming?api_key=${apiKey}&language=zh-CN&page=${page}®ion=${region}`;
|
||||||
`https://api.themoviedb.org/3/movie/upcoming?api_key=${apiKey}&language=zh-CN&page=${page}®ion=${region}`
|
const fetchOptions: any = proxy
|
||||||
);
|
? {
|
||||||
|
agent: new HttpsProxyAgent(proxy, {
|
||||||
|
timeout: 30000,
|
||||||
|
keepAlive: false,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 node-fetch 而不是原生 fetch
|
||||||
|
const response = await nodeFetch(url, fetchOptions);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('TMDB API 请求失败:', response.status, response.statusText);
|
console.error('TMDB API 请求失败:', response.status, response.statusText);
|
||||||
return { code: response.status, list: [] };
|
return { code: response.status, list: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: TMDBUpcomingResponse = await response.json();
|
const data: TMDBUpcomingResponse = await response.json() as TMDBUpcomingResponse;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
@@ -85,11 +102,13 @@ export async function getTMDBUpcomingMovies(
|
|||||||
* 获取正在播出的电视剧
|
* 获取正在播出的电视剧
|
||||||
* @param apiKey - TMDB API Key
|
* @param apiKey - TMDB API Key
|
||||||
* @param page - 页码
|
* @param page - 页码
|
||||||
|
* @param proxy - 代理服务器地址
|
||||||
* @returns 正在播出的电视剧列表
|
* @returns 正在播出的电视剧列表
|
||||||
*/
|
*/
|
||||||
export async function getTMDBUpcomingTVShows(
|
export async function getTMDBUpcomingTVShows(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
page: number = 1
|
page: number = 1,
|
||||||
|
proxy?: string
|
||||||
): Promise<{ code: number; list: TMDBTVShow[] }> {
|
): Promise<{ code: number; list: TMDBTVShow[] }> {
|
||||||
try {
|
try {
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -97,16 +116,28 @@ export async function getTMDBUpcomingTVShows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用 on_the_air 接口获取正在播出的电视剧
|
// 使用 on_the_air 接口获取正在播出的电视剧
|
||||||
const response = await fetch(
|
const url = `https://api.themoviedb.org/3/tv/on_the_air?api_key=${apiKey}&language=zh-CN&page=${page}`;
|
||||||
`https://api.themoviedb.org/3/tv/on_the_air?api_key=${apiKey}&language=zh-CN&page=${page}`
|
const fetchOptions: any = proxy
|
||||||
);
|
? {
|
||||||
|
agent: new HttpsProxyAgent(proxy, {
|
||||||
|
timeout: 30000,
|
||||||
|
keepAlive: false,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 node-fetch 而不是原生 fetch
|
||||||
|
const response = await nodeFetch(url, fetchOptions);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('TMDB TV API 请求失败:', response.status, response.statusText);
|
console.error('TMDB TV API 请求失败:', response.status, response.statusText);
|
||||||
return { code: response.status, list: [] };
|
return { code: response.status, list: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: TMDBTVAiringTodayResponse = await response.json();
|
const data: TMDBTVAiringTodayResponse = await response.json() as TMDBTVAiringTodayResponse;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
@@ -121,10 +152,12 @@ export async function getTMDBUpcomingTVShows(
|
|||||||
/**
|
/**
|
||||||
* 获取即将上映/播出的内容(电影+电视剧)
|
* 获取即将上映/播出的内容(电影+电视剧)
|
||||||
* @param apiKey - TMDB API Key
|
* @param apiKey - TMDB API Key
|
||||||
|
* @param proxy - 代理服务器地址
|
||||||
* @returns 统一格式的即将上映/播出列表
|
* @returns 统一格式的即将上映/播出列表
|
||||||
*/
|
*/
|
||||||
export async function getTMDBUpcomingContent(
|
export async function getTMDBUpcomingContent(
|
||||||
apiKey: string
|
apiKey: string,
|
||||||
|
proxy?: string
|
||||||
): Promise<{ code: number; list: TMDBItem[] }> {
|
): Promise<{ code: number; list: TMDBItem[] }> {
|
||||||
try {
|
try {
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -133,8 +166,8 @@ export async function getTMDBUpcomingContent(
|
|||||||
|
|
||||||
// 并行获取电影和电视剧数据
|
// 并行获取电影和电视剧数据
|
||||||
const [moviesResult, tvShowsResult] = await Promise.all([
|
const [moviesResult, tvShowsResult] = await Promise.all([
|
||||||
getTMDBUpcomingMovies(apiKey),
|
getTMDBUpcomingMovies(apiKey, 1, 'CN', proxy),
|
||||||
getTMDBUpcomingTVShows(apiKey),
|
getTMDBUpcomingTVShows(apiKey, 1, proxy),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 检查是否有错误
|
// 检查是否有错误
|
||||||
|
|||||||
91
src/lib/tmdb.search.ts
Normal file
91
src/lib/tmdb.search.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
|
import nodeFetch from 'node-fetch';
|
||||||
|
|
||||||
|
export interface TMDBSearchResult {
|
||||||
|
id: number;
|
||||||
|
title?: string; // 电影
|
||||||
|
name?: string; // 电视剧
|
||||||
|
poster_path: string | null;
|
||||||
|
release_date?: string;
|
||||||
|
first_air_date?: string;
|
||||||
|
overview: string;
|
||||||
|
vote_average: number;
|
||||||
|
media_type: 'movie' | 'tv';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TMDBSearchResponse {
|
||||||
|
results: TMDBSearchResult[];
|
||||||
|
page: number;
|
||||||
|
total_pages: number;
|
||||||
|
total_results: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索 TMDB (电影+电视剧)
|
||||||
|
*/
|
||||||
|
export async function searchTMDB(
|
||||||
|
apiKey: string,
|
||||||
|
query: string,
|
||||||
|
proxy?: string
|
||||||
|
): Promise<{ code: number; result: TMDBSearchResult | null }> {
|
||||||
|
try {
|
||||||
|
if (!apiKey) {
|
||||||
|
return { code: 400, result: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 multi search 同时搜索电影和电视剧
|
||||||
|
const url = `https://api.themoviedb.org/3/search/multi?api_key=${apiKey}&language=zh-CN&query=${encodeURIComponent(query)}&page=1`;
|
||||||
|
|
||||||
|
const fetchOptions: any = proxy
|
||||||
|
? {
|
||||||
|
agent: new HttpsProxyAgent(proxy, {
|
||||||
|
timeout: 30000,
|
||||||
|
keepAlive: false,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 node-fetch 而不是原生 fetch,因为原生 fetch 不支持 agent 选项
|
||||||
|
const response = await nodeFetch(url, fetchOptions);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('TMDB 搜索失败:', response.status, response.statusText);
|
||||||
|
return { code: response.status, result: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: TMDBSearchResponse = await response.json() as TMDBSearchResponse;
|
||||||
|
|
||||||
|
// 过滤出电影和电视剧,取第一个结果
|
||||||
|
const validResults = data.results.filter(
|
||||||
|
(item) => item.media_type === 'movie' || item.media_type === 'tv'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validResults.length === 0) {
|
||||||
|
return { code: 404, result: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
result: validResults[0],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TMDB 搜索异常:', error);
|
||||||
|
return { code: 500, result: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 TMDB 图片完整 URL
|
||||||
|
*/
|
||||||
|
export function getTMDBImageUrl(
|
||||||
|
path: string | null,
|
||||||
|
size: string = 'w500'
|
||||||
|
): string {
|
||||||
|
if (!path) return '';
|
||||||
|
return `https://image.tmdb.org/t/p/${size}${path}`;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Redis } from '@upstash/redis';
|
|||||||
|
|
||||||
import { AdminConfig } from './admin.types';
|
import { AdminConfig } from './admin.types';
|
||||||
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||||
|
import { userInfoCache } from './user-cache';
|
||||||
|
|
||||||
// 搜索历史最大条数
|
// 搜索历史最大条数
|
||||||
const SEARCH_HISTORY_LIMIT = 20;
|
const SEARCH_HISTORY_LIMIT = 20;
|
||||||
@@ -220,6 +221,424 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 新版用户存储(使用Hash和Sorted Set) ----------
|
||||||
|
private userInfoKey(userName: string) {
|
||||||
|
return `user:${userName}:info`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private userListKey() {
|
||||||
|
return 'user:list';
|
||||||
|
}
|
||||||
|
|
||||||
|
private oidcSubKey(oidcSub: string) {
|
||||||
|
return `oidc:sub:${oidcSub}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHA256加密密码
|
||||||
|
private async hashPassword(password: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(password);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新用户(新版本)
|
||||||
|
async createUserV2(
|
||||||
|
userName: string,
|
||||||
|
password: string,
|
||||||
|
role: 'owner' | 'admin' | 'user' = 'user',
|
||||||
|
tags?: string[],
|
||||||
|
oidcSub?: string,
|
||||||
|
enabledApis?: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
// 先检查用户是否已存在(原子性检查)
|
||||||
|
const exists = await withRetry(() =>
|
||||||
|
this.client.exists(this.userInfoKey(userName))
|
||||||
|
);
|
||||||
|
if (exists === 1) {
|
||||||
|
throw new Error('用户已存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await this.hashPassword(password);
|
||||||
|
const createdAt = Date.now();
|
||||||
|
|
||||||
|
// 存储用户信息到Hash
|
||||||
|
const userInfo: Record<string, any> = {
|
||||||
|
role,
|
||||||
|
banned: false, // 直接使用布尔值
|
||||||
|
password: hashedPassword,
|
||||||
|
created_at: createdAt.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tags && tags.length > 0) {
|
||||||
|
userInfo.tags = JSON.stringify(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oidcSub) {
|
||||||
|
userInfo.oidcSub = oidcSub;
|
||||||
|
// 创建OIDC映射
|
||||||
|
await withRetry(() => this.client.set(this.oidcSubKey(oidcSub), userName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabledApis && enabledApis.length > 0) {
|
||||||
|
userInfo.enabledApis = JSON.stringify(enabledApis);
|
||||||
|
}
|
||||||
|
|
||||||
|
await withRetry(() => this.client.hset(this.userInfoKey(userName), userInfo));
|
||||||
|
|
||||||
|
// 添加到用户列表(Sorted Set,按注册时间排序)
|
||||||
|
await withRetry(() => this.client.zadd(this.userListKey(), {
|
||||||
|
score: createdAt,
|
||||||
|
member: userName,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 如果创建的是站长用户,清除站长存在状态缓存
|
||||||
|
if (userName === process.env.USERNAME) {
|
||||||
|
const { ownerExistenceCache } = await import('./user-cache');
|
||||||
|
ownerExistenceCache.delete(userName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户密码(新版本)
|
||||||
|
async verifyUserV2(userName: string, password: string): Promise<boolean> {
|
||||||
|
const userInfo = await withRetry(() =>
|
||||||
|
this.client.hgetall(this.userInfoKey(userName))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!userInfo || !userInfo.password) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await this.hashPassword(password);
|
||||||
|
return userInfo.password === hashedPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息(新版本)
|
||||||
|
async getUserInfoV2(userName: string): Promise<{
|
||||||
|
role: 'owner' | 'admin' | 'user';
|
||||||
|
banned: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
oidcSub?: string;
|
||||||
|
enabledApis?: string[];
|
||||||
|
created_at: number;
|
||||||
|
} | null> {
|
||||||
|
// 先从缓存获取
|
||||||
|
const cached = userInfoCache?.get(userName);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = await withRetry(() =>
|
||||||
|
this.client.hgetall(this.userInfoKey(userName))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!userInfo || Object.keys(userInfo).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 banned 字段:可能是字符串 'true'/'false' 或布尔值 true/false
|
||||||
|
let banned = false;
|
||||||
|
if (typeof userInfo.banned === 'boolean') {
|
||||||
|
banned = userInfo.banned;
|
||||||
|
} else if (typeof userInfo.banned === 'string') {
|
||||||
|
banned = userInfo.banned === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全解析 tags 字段
|
||||||
|
let tags: string[] | undefined = undefined;
|
||||||
|
if (userInfo.tags) {
|
||||||
|
if (Array.isArray(userInfo.tags)) {
|
||||||
|
tags = userInfo.tags;
|
||||||
|
} else if (typeof userInfo.tags === 'string') {
|
||||||
|
try {
|
||||||
|
tags = JSON.parse(userInfo.tags);
|
||||||
|
} catch {
|
||||||
|
// 如果解析失败,可能是单个字符串,转换为数组
|
||||||
|
tags = [userInfo.tags];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全解析 enabledApis 字段
|
||||||
|
let enabledApis: string[] | undefined = undefined;
|
||||||
|
if (userInfo.enabledApis) {
|
||||||
|
if (Array.isArray(userInfo.enabledApis)) {
|
||||||
|
enabledApis = userInfo.enabledApis;
|
||||||
|
} else if (typeof userInfo.enabledApis === 'string') {
|
||||||
|
try {
|
||||||
|
enabledApis = JSON.parse(userInfo.enabledApis);
|
||||||
|
} catch {
|
||||||
|
// 如果解析失败,可能是单个字符串,转换为数组
|
||||||
|
enabledApis = [userInfo.enabledApis];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
role: (userInfo.role as 'owner' | 'admin' | 'user') || 'user',
|
||||||
|
banned,
|
||||||
|
tags,
|
||||||
|
oidcSub: userInfo.oidcSub as string | undefined,
|
||||||
|
enabledApis,
|
||||||
|
created_at: parseInt((userInfo.created_at as string) || '0', 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 存入缓存
|
||||||
|
userInfoCache?.set(userName, result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新用户信息(新版本)
|
||||||
|
async updateUserInfoV2(
|
||||||
|
userName: string,
|
||||||
|
updates: {
|
||||||
|
role?: 'owner' | 'admin' | 'user';
|
||||||
|
banned?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
oidcSub?: string;
|
||||||
|
enabledApis?: string[];
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const userInfo: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (updates.role !== undefined) {
|
||||||
|
userInfo.role = updates.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.banned !== undefined) {
|
||||||
|
// 直接存储布尔值,让 Upstash 自动处理序列化
|
||||||
|
userInfo.banned = updates.banned;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.tags !== undefined) {
|
||||||
|
if (updates.tags.length > 0) {
|
||||||
|
userInfo.tags = JSON.stringify(updates.tags);
|
||||||
|
} else {
|
||||||
|
// 删除tags字段
|
||||||
|
await withRetry(() => this.client.hdel(this.userInfoKey(userName), 'tags'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.enabledApis !== undefined) {
|
||||||
|
if (updates.enabledApis.length > 0) {
|
||||||
|
userInfo.enabledApis = JSON.stringify(updates.enabledApis);
|
||||||
|
} else {
|
||||||
|
// 删除enabledApis字段
|
||||||
|
await withRetry(() => this.client.hdel(this.userInfoKey(userName), 'enabledApis'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.oidcSub !== undefined) {
|
||||||
|
const oldInfo = await this.getUserInfoV2(userName);
|
||||||
|
if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) {
|
||||||
|
// 删除旧的OIDC映射
|
||||||
|
await withRetry(() => this.client.del(this.oidcSubKey(oldInfo.oidcSub!)));
|
||||||
|
}
|
||||||
|
userInfo.oidcSub = updates.oidcSub;
|
||||||
|
// 创建新的OIDC映射
|
||||||
|
await withRetry(() => this.client.set(this.oidcSubKey(updates.oidcSub!), userName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(userInfo).length > 0) {
|
||||||
|
await withRetry(() => this.client.hset(this.userInfoKey(userName), userInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除缓存
|
||||||
|
userInfoCache?.delete(userName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改用户密码(新版本)
|
||||||
|
async changePasswordV2(userName: string, newPassword: string): Promise<void> {
|
||||||
|
const hashedPassword = await this.hashPassword(newPassword);
|
||||||
|
await withRetry(() =>
|
||||||
|
this.client.hset(this.userInfoKey(userName), { password: hashedPassword })
|
||||||
|
);
|
||||||
|
|
||||||
|
// 清除缓存
|
||||||
|
userInfoCache?.delete(userName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否存在(新版本)
|
||||||
|
async checkUserExistV2(userName: string): Promise<boolean> {
|
||||||
|
const exists = await withRetry(() =>
|
||||||
|
this.client.exists(this.userInfoKey(userName))
|
||||||
|
);
|
||||||
|
return exists === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过OIDC Sub查找用户名
|
||||||
|
async getUserByOidcSub(oidcSub: string): Promise<string | null> {
|
||||||
|
const userName = await withRetry(() =>
|
||||||
|
this.client.get(this.oidcSubKey(oidcSub))
|
||||||
|
);
|
||||||
|
return userName ? ensureString(userName) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取使用特定用户组的用户列表
|
||||||
|
async getUsersByTag(tagName: string): Promise<string[]> {
|
||||||
|
const affectedUsers: string[] = [];
|
||||||
|
|
||||||
|
// 使用 SCAN 遍历所有用户信息的 key
|
||||||
|
let cursor: number | string = 0;
|
||||||
|
do {
|
||||||
|
const result = await withRetry(() =>
|
||||||
|
this.client.scan(cursor as number, { match: 'user:*:info', count: 100 })
|
||||||
|
);
|
||||||
|
|
||||||
|
cursor = result[0];
|
||||||
|
const keys = result[1];
|
||||||
|
|
||||||
|
// 检查每个用户的 tags
|
||||||
|
for (const key of keys) {
|
||||||
|
const userInfo = await withRetry(() => this.client.hgetall(key));
|
||||||
|
if (userInfo && userInfo.tags) {
|
||||||
|
const tags = JSON.parse(userInfo.tags as string);
|
||||||
|
if (tags.includes(tagName)) {
|
||||||
|
// 从 key 中提取用户名: user:username:info -> username
|
||||||
|
const username = key.replace('user:', '').replace(':info', '');
|
||||||
|
affectedUsers.push(username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (typeof cursor === 'number' ? cursor !== 0 : cursor !== '0');
|
||||||
|
|
||||||
|
return affectedUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户列表(分页,新版本)
|
||||||
|
async getUserListV2(
|
||||||
|
offset: number = 0,
|
||||||
|
limit: number = 20,
|
||||||
|
ownerUsername?: string
|
||||||
|
): Promise<{
|
||||||
|
users: Array<{
|
||||||
|
username: string;
|
||||||
|
role: 'owner' | 'admin' | 'user';
|
||||||
|
banned: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
oidcSub?: string;
|
||||||
|
enabledApis?: string[];
|
||||||
|
created_at: number;
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
// 获取总数
|
||||||
|
let total = await withRetry(() => this.client.zcard(this.userListKey()));
|
||||||
|
|
||||||
|
// 检查站长是否在数据库中(使用缓存)
|
||||||
|
let ownerInfo = null;
|
||||||
|
let ownerInDatabase = false;
|
||||||
|
if (ownerUsername) {
|
||||||
|
// 先检查缓存
|
||||||
|
const { ownerExistenceCache } = await import('./user-cache');
|
||||||
|
const cachedExists = ownerExistenceCache.get(ownerUsername);
|
||||||
|
|
||||||
|
if (cachedExists !== null) {
|
||||||
|
// 使用缓存的结果
|
||||||
|
ownerInDatabase = cachedExists;
|
||||||
|
if (ownerInDatabase) {
|
||||||
|
// 如果站长在数据库中,获取详细信息
|
||||||
|
ownerInfo = await this.getUserInfoV2(ownerUsername);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 缓存未命中,查询数据库
|
||||||
|
ownerInfo = await this.getUserInfoV2(ownerUsername);
|
||||||
|
ownerInDatabase = !!ownerInfo;
|
||||||
|
// 更<><E69BB4><EFBFBD>缓存
|
||||||
|
ownerExistenceCache.set(ownerUsername, ownerInDatabase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果站长不在数据库中,总数+1(无论在哪一页都要加)
|
||||||
|
if (!ownerInDatabase) {
|
||||||
|
total += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果站长不在数据库中且在第一页,需要调整获取的用户数量和偏移量
|
||||||
|
let actualOffset = offset;
|
||||||
|
let actualLimit = limit;
|
||||||
|
|
||||||
|
if (ownerUsername && !ownerInDatabase) {
|
||||||
|
if (offset === 0) {
|
||||||
|
// 第一页:只获取 limit-1 个用户,为站长留出位置
|
||||||
|
actualLimit = limit - 1;
|
||||||
|
} else {
|
||||||
|
// 其他页:偏移量需要减1,因为站长占据了第一页的一个位置
|
||||||
|
actualOffset = offset - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户列表(按注册时间升序)
|
||||||
|
const usernames = await withRetry(() =>
|
||||||
|
this.client.zrange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
const users = [];
|
||||||
|
|
||||||
|
// 如果有站长且在第一页,确保站长始终在第一位
|
||||||
|
if (ownerUsername && offset === 0) {
|
||||||
|
// 即使站长不在数据库中,也要添加站长(站长使用环境变量认证)
|
||||||
|
users.push({
|
||||||
|
username: ownerUsername,
|
||||||
|
role: 'owner' as const,
|
||||||
|
banned: ownerInfo?.banned || false,
|
||||||
|
tags: ownerInfo?.tags,
|
||||||
|
oidcSub: ownerInfo?.oidcSub,
|
||||||
|
enabledApis: ownerInfo?.enabledApis,
|
||||||
|
created_at: ownerInfo?.created_at || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取其他用户信息
|
||||||
|
for (const username of usernames) {
|
||||||
|
const usernameStr = ensureString(username);
|
||||||
|
// 跳过站长(已经添加)
|
||||||
|
if (ownerUsername && usernameStr === ownerUsername) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = await this.getUserInfoV2(usernameStr);
|
||||||
|
if (userInfo) {
|
||||||
|
users.push({
|
||||||
|
username: usernameStr,
|
||||||
|
role: userInfo.role,
|
||||||
|
banned: userInfo.banned,
|
||||||
|
tags: userInfo.tags,
|
||||||
|
oidcSub: userInfo.oidcSub,
|
||||||
|
enabledApis: userInfo.enabledApis,
|
||||||
|
created_at: userInfo.created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { users, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户(新版本)
|
||||||
|
async deleteUserV2(userName: string): Promise<void> {
|
||||||
|
// 获取用户信息
|
||||||
|
const userInfo = await this.getUserInfoV2(userName);
|
||||||
|
|
||||||
|
// 删除OIDC映射
|
||||||
|
if (userInfo?.oidcSub) {
|
||||||
|
await withRetry(() => this.client.del(this.oidcSubKey(userInfo.oidcSub!)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户信息Hash
|
||||||
|
await withRetry(() => this.client.del(this.userInfoKey(userName)));
|
||||||
|
|
||||||
|
// 从用户列表中移除
|
||||||
|
await withRetry(() => this.client.zrem(this.userListKey(), userName));
|
||||||
|
|
||||||
|
// 删除用户的其他数据(播放记录、收藏等)
|
||||||
|
await this.deleteUser(userName);
|
||||||
|
|
||||||
|
// 清除缓存
|
||||||
|
userInfoCache?.delete(userName);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 搜索历史 ----------
|
// ---------- 搜索历史 ----------
|
||||||
private shKey(user: string) {
|
private shKey(user: string) {
|
||||||
return `u:${user}:sh`; // u:username:sh
|
return `u:${user}:sh`; // u:username:sh
|
||||||
@@ -254,13 +673,20 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
|
|
||||||
// ---------- 获取全部用户 ----------
|
// ---------- 获取全部用户 ----------
|
||||||
async getAllUsers(): Promise<string[]> {
|
async getAllUsers(): Promise<string[]> {
|
||||||
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
|
// 从新版用户列表获取
|
||||||
return keys
|
const userListKey = this.userListKey();
|
||||||
.map((k) => {
|
const users = await withRetry(() =>
|
||||||
const match = k.match(/^u:(.+?):pwd$/);
|
this.client.zrange(userListKey, 0, -1)
|
||||||
return match ? ensureString(match[1]) : undefined;
|
);
|
||||||
})
|
const userList = users.map(u => ensureString(u));
|
||||||
.filter((u): u is string => typeof u === 'string');
|
|
||||||
|
// 确保站长在列表中(站长可能不在数据库中,使用环境变量认证)
|
||||||
|
const ownerUsername = process.env.USERNAME;
|
||||||
|
if (ownerUsername && !userList.includes(ownerUsername)) {
|
||||||
|
userList.unshift(ownerUsername);
|
||||||
|
}
|
||||||
|
|
||||||
|
return userList;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 管理员配置 ----------
|
// ---------- 管理员配置 ----------
|
||||||
|
|||||||
132
src/lib/user-cache.ts
Normal file
132
src/lib/user-cache.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
// 用户信息缓存
|
||||||
|
interface CachedUserInfo {
|
||||||
|
role: 'owner' | 'admin' | 'user';
|
||||||
|
banned: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
oidcSub?: string;
|
||||||
|
enabledApis?: string[];
|
||||||
|
created_at: number;
|
||||||
|
cachedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserInfoCache {
|
||||||
|
private cache: Map<string, CachedUserInfo> = new Map();
|
||||||
|
private readonly TTL = 6 * 60 * 60 * 1000; // 6小时过期
|
||||||
|
|
||||||
|
get(username: string): CachedUserInfo | null {
|
||||||
|
const cached = this.cache.get(username);
|
||||||
|
if (!cached) return null;
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
if (Date.now() - cached.cachedAt > this.TTL) {
|
||||||
|
this.cache.delete(username);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(username: string, userInfo: Omit<CachedUserInfo, 'cachedAt'>): void {
|
||||||
|
this.cache.set(username, {
|
||||||
|
...userInfo,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(username: string): void {
|
||||||
|
this.cache.delete(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理过期的缓存
|
||||||
|
cleanup(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const entries = Array.from(this.cache.entries());
|
||||||
|
for (const [username, cached] of entries) {
|
||||||
|
if (now - cached.cachedAt > this.TTL) {
|
||||||
|
this.cache.delete(username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 站长存在状态缓存
|
||||||
|
class OwnerExistenceCache {
|
||||||
|
private cache: Map<string, { exists: boolean; cachedAt: number }> = new Map();
|
||||||
|
private readonly TTL = 10 * 60 * 1000; // 10分钟过期
|
||||||
|
|
||||||
|
get(ownerUsername: string): boolean | null {
|
||||||
|
const cached = this.cache.get(ownerUsername);
|
||||||
|
if (!cached) return null;
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
if (Date.now() - cached.cachedAt > this.TTL) {
|
||||||
|
this.cache.delete(ownerUsername);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached.exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(ownerUsername: string, exists: boolean): void {
|
||||||
|
this.cache.set(ownerUsername, {
|
||||||
|
exists,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(ownerUsername: string): void {
|
||||||
|
this.cache.delete(ownerUsername);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理过期的缓存
|
||||||
|
cleanup(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const entries = Array.from(this.cache.entries());
|
||||||
|
for (const [username, cached] of entries) {
|
||||||
|
if (now - cached.cachedAt > this.TTL) {
|
||||||
|
this.cache.delete(username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局单例
|
||||||
|
const globalKey = Symbol.for('__MOONTV_USER_INFO_CACHE__');
|
||||||
|
let _userInfoCache: UserInfoCache | undefined = (global as any)[globalKey];
|
||||||
|
|
||||||
|
if (!_userInfoCache) {
|
||||||
|
_userInfoCache = new UserInfoCache();
|
||||||
|
(global as any)[globalKey] = _userInfoCache;
|
||||||
|
|
||||||
|
// 每分钟清理一次过期缓存
|
||||||
|
setInterval(() => {
|
||||||
|
_userInfoCache?.cleanup();
|
||||||
|
}, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userInfoCache = _userInfoCache as UserInfoCache;
|
||||||
|
|
||||||
|
const ownerExistenceGlobalKey = Symbol.for('__MOONTV_OWNER_EXISTENCE_CACHE__');
|
||||||
|
let _ownerExistenceCache: OwnerExistenceCache | undefined = (global as any)[ownerExistenceGlobalKey];
|
||||||
|
|
||||||
|
if (!_ownerExistenceCache) {
|
||||||
|
_ownerExistenceCache = new OwnerExistenceCache();
|
||||||
|
(global as any)[ownerExistenceGlobalKey] = _ownerExistenceCache;
|
||||||
|
|
||||||
|
// 每分钟清理一次过期缓存
|
||||||
|
setInterval(() => {
|
||||||
|
_ownerExistenceCache?.cleanup();
|
||||||
|
}, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ownerExistenceCache = _ownerExistenceCache as OwnerExistenceCache;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
const CURRENT_VERSION = '203.2.2';
|
const CURRENT_VERSION = '204.0.0';
|
||||||
|
|
||||||
// 导出当前版本号供其他地方使用
|
// 导出当前版本号供其他地方使用
|
||||||
export { CURRENT_VERSION };
|
export { CURRENT_VERSION };
|
||||||
|
|||||||
57
src/lib/video-parser.ts
Normal file
57
src/lib/video-parser.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import parseTorrentName from 'parse-torrent-name';
|
||||||
|
|
||||||
|
export interface ParsedVideoInfo {
|
||||||
|
episode?: number;
|
||||||
|
season?: number;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析视频文件名
|
||||||
|
*/
|
||||||
|
export function parseVideoFileName(fileName: string): ParsedVideoInfo {
|
||||||
|
try {
|
||||||
|
const parsed = parseTorrentName(fileName);
|
||||||
|
|
||||||
|
// 如果 parse-torrent-name 成功解析出集数,直接返回
|
||||||
|
if (parsed.episode) {
|
||||||
|
return {
|
||||||
|
episode: parsed.episode,
|
||||||
|
season: parsed.season,
|
||||||
|
title: parsed.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('parse-torrent-name 解析失败:', fileName, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级方案:使用多种正则模式提取集数
|
||||||
|
// 按优先级排序:更具体的模式优先
|
||||||
|
const patterns = [
|
||||||
|
// S01E01, s01e01, S01E01.5 (支持小数) - 最具体
|
||||||
|
/[Ss]\d+[Ee](\d+(?:\.\d+)?)/,
|
||||||
|
// [01], (01), [01.5], (01.5) (支持小数) - 很具体
|
||||||
|
/[\[\(](\d+(?:\.\d+)?)[\]\)]/,
|
||||||
|
// E01, E1, e01, e1, E01.5 (支持小数)
|
||||||
|
/[Ee](\d+(?:\.\d+)?)/,
|
||||||
|
// 第01集, 第1集, 第01话, 第1话, 第1.5集 (支持小数)
|
||||||
|
/第(\d+(?:\.\d+)?)[集话]/,
|
||||||
|
// _01_, -01-, _01.5_, -01.5- (支持小数)
|
||||||
|
/[_\-](\d+(?:\.\d+)?)[_\-]/,
|
||||||
|
// 01.mp4, 001.mp4, 01.5.mp4 (纯数字开头,支持小数) - 最不具体
|
||||||
|
/^(\d+(?:\.\d+)?)[^\d.]/,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = fileName.match(pattern);
|
||||||
|
if (match && match[1]) {
|
||||||
|
const episode = parseFloat(match[1]);
|
||||||
|
if (episode > 0 && episode < 10000) { // 合理的集数范围
|
||||||
|
return { episode };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果所有模式都失败,返回空对象(调用方会处理)
|
||||||
|
return {};
|
||||||
|
}
|
||||||
30
src/types/parse-torrent-name.d.ts
vendored
Normal file
30
src/types/parse-torrent-name.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
declare module 'parse-torrent-name' {
|
||||||
|
interface ParsedTorrent {
|
||||||
|
title?: string;
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
|
year?: number;
|
||||||
|
resolution?: string;
|
||||||
|
codec?: string;
|
||||||
|
audio?: string;
|
||||||
|
group?: string;
|
||||||
|
region?: string;
|
||||||
|
extended?: boolean;
|
||||||
|
hardcoded?: boolean;
|
||||||
|
proper?: boolean;
|
||||||
|
repack?: boolean;
|
||||||
|
container?: string;
|
||||||
|
widescreen?: boolean;
|
||||||
|
website?: string;
|
||||||
|
language?: string;
|
||||||
|
sbs?: string;
|
||||||
|
unrated?: boolean;
|
||||||
|
size?: string;
|
||||||
|
bitDepth?: string;
|
||||||
|
hdr?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTorrentName(name: string): ParsedTorrent;
|
||||||
|
export default parseTorrentName;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user