Merge branch 'dev'

This commit is contained in:
mtvpls
2025-12-25 20:24:41 +08:00
75 changed files with 7164 additions and 526 deletions

View File

@@ -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

View File

@@ -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 | 私人影库视频信息在内存中的缓存时长(分钟) | 正整数 | 14401天 |
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释: NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:

View File

@@ -1 +1 @@
203.2.2 204.0.0

View File

@@ -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
View File

@@ -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

View File

@@ -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 }
);
}
} }
} }

View File

@@ -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;
}
}

View File

@@ -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 : '未知版本'
}); });

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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,

View File

@@ -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 });
} }

View File

@@ -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;
}
} }
} }

View 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 }
);
}
}

View File

@@ -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);

View File

@@ -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: '注册成功' });

View File

@@ -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) {

View File

@@ -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,
});
}

View File

@@ -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);
}
}

View File

@@ -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 });
} }
} }

View File

@@ -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, {

View File

@@ -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, {

View File

@@ -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, {

View File

@@ -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 }
);
}
}

View File

@@ -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 });
} }

View File

@@ -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 });
} }
} }

View File

@@ -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 });

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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;
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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 });
} }
} }

View File

@@ -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);

View File

@@ -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 || '';

View File

@@ -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({

View File

@@ -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 });
} }
} }

View File

@@ -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 });
} }
} }

View 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 }
);
}
}

View File

@@ -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(

View File

@@ -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 (

View File

@@ -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;

View File

@@ -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>
); );
} }

View 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>
);
}

View File

@@ -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'
> >

View 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
);
}

View File

@@ -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);

View File

@@ -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({

View 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>
);
}

View File

@@ -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({

View File

@@ -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>

View File

@@ -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',

View File

@@ -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 {

View File

@@ -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: [

View File

@@ -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));

View File

@@ -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
View 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
View 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
View 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 为 401Token 已过期,清除缓存并重试');
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
View 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;
}
}

View File

@@ -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
View 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);
}
}
}

View File

@@ -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}&region=${region}`;
`https://api.themoviedb.org/3/movie/upcoming?api_key=${apiKey}&language=zh-CN&page=${page}&region=${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
View 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}`;
}

View File

@@ -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
View 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;

View File

@@ -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
View 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
View 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;
}