Merge branch 'dev'
This commit is contained in:
16
CHANGELOG
16
CHANGELOG
@@ -1,3 +1,19 @@
|
||||
## [204.0.0] - 2025-12-25
|
||||
|
||||
### Added
|
||||
- ⚠️⚠️⚠️更新此版本前前务必进行备份!!!⚠️⚠️⚠️
|
||||
- 新增私人影视库功能(实验性)
|
||||
- 增加弹幕热力图
|
||||
- 增加盘搜搜索资源
|
||||
|
||||
### Changed
|
||||
- 完全重构用户数据存储结构
|
||||
- 提高所有弹幕接口的超时时间
|
||||
- 优化完结标识判断
|
||||
- 即将上映移动端字体大小调整
|
||||
- tmdb增加代理支持
|
||||
- 剧集更新检测改为服务器后台定时执行
|
||||
|
||||
## [203.2.2] - 2025-12-20
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -255,6 +255,7 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
||||
| NEXT_PUBLIC_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback |
|
||||
| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false |
|
||||
| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data |
|
||||
| VIDEOINFO_CACHE_MINUTES | 私人影库视频信息在内存中的缓存时长(分钟) | 正整数 | 1440(1天) |
|
||||
|
||||
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
203.2.2
|
||||
204.0.0
|
||||
@@ -39,12 +39,15 @@
|
||||
"framer-motion": "^12.18.1",
|
||||
"he": "^1.2.0",
|
||||
"hls.js": "^1.6.10",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"lucide-react": "^0.438.0",
|
||||
"media-icons": "^1.1.5",
|
||||
"mux.js": "^6.3.0",
|
||||
"next": "^14.2.33",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-fetch": "^2.7.0",
|
||||
"parse-torrent-name": "^0.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.4.0",
|
||||
@@ -66,6 +69,7 @@
|
||||
"@types/bs58": "^5.0.0",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/node": "24.0.3",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/testing-library__jest-dom": "^5.14.9",
|
||||
|
||||
85
pnpm-lock.yaml
generated
85
pnpm-lock.yaml
generated
@@ -65,6 +65,9 @@ importers:
|
||||
hls.js:
|
||||
specifier: ^1.6.10
|
||||
version: 1.6.10
|
||||
https-proxy-agent:
|
||||
specifier: ^7.0.6
|
||||
version: 7.0.6
|
||||
lucide-react:
|
||||
specifier: ^0.438.0
|
||||
version: 0.438.0(react@18.3.1)
|
||||
@@ -83,6 +86,12 @@ importers:
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
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:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
@@ -141,6 +150,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: 24.0.3
|
||||
version: 24.0.3
|
||||
'@types/node-fetch':
|
||||
specifier: ^2.6.13
|
||||
version: 2.6.13
|
||||
'@types/react':
|
||||
specifier: ^18.3.18
|
||||
version: 18.3.23
|
||||
@@ -1559,6 +1571,9 @@ packages:
|
||||
'@types/minimist@1.2.5':
|
||||
resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==}
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
|
||||
|
||||
'@types/node@24.0.3':
|
||||
resolution: {integrity: sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==}
|
||||
|
||||
@@ -1889,6 +1904,10 @@ packages:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2997,6 +3016,10 @@ packages:
|
||||
resolution: {integrity: sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
fraction.js@4.3.7:
|
||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
||||
|
||||
@@ -3211,6 +3234,10 @@ packages:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
human-signals@2.1.0:
|
||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||
engines: {node: '>=10.17.0'}
|
||||
@@ -4068,6 +4095,15 @@ packages:
|
||||
no-case@3.0.4:
|
||||
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:
|
||||
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
||||
|
||||
@@ -4207,6 +4243,9 @@ packages:
|
||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
parse-torrent-name@0.5.4:
|
||||
resolution: {integrity: sha512-digWcT7Zp/oZX8I7iTQSfWd3z3C/0zszo/xYQsmogO2a6XDU0sTlQXYffHRhuwXNivBvMB8mS+EAwciyyVBlGQ==}
|
||||
|
||||
parse5-htmlparser2-tree-adapter@7.1.0:
|
||||
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
|
||||
|
||||
@@ -5040,6 +5079,9 @@ packages:
|
||||
resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
tr46@1.0.1:
|
||||
resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
|
||||
|
||||
@@ -5265,6 +5307,9 @@ packages:
|
||||
resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
webidl-conversions@4.0.2:
|
||||
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
|
||||
|
||||
@@ -5317,6 +5362,9 @@ packages:
|
||||
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
whatwg-url@7.1.0:
|
||||
resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==}
|
||||
|
||||
@@ -7211,6 +7259,11 @@ snapshots:
|
||||
|
||||
'@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':
|
||||
dependencies:
|
||||
undici-types: 7.8.0
|
||||
@@ -7539,6 +7592,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
aggregate-error@3.1.0:
|
||||
dependencies:
|
||||
clean-stack: 2.2.0
|
||||
@@ -8855,6 +8910,14 @@ snapshots:
|
||||
es-set-tostringtag: 2.1.0
|
||||
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: {}
|
||||
|
||||
framer-motion@12.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
@@ -9088,6 +9151,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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: {}
|
||||
|
||||
husky@7.0.4: {}
|
||||
@@ -10223,6 +10293,10 @@ snapshots:
|
||||
lower-case: 2.0.2
|
||||
tslib: 2.8.1
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
node-int64@0.4.0: {}
|
||||
|
||||
node-releases@2.0.19: {}
|
||||
@@ -10381,6 +10455,8 @@ snapshots:
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
lines-and-columns: 1.2.4
|
||||
|
||||
parse-torrent-name@0.5.4: {}
|
||||
|
||||
parse5-htmlparser2-tree-adapter@7.1.0:
|
||||
dependencies:
|
||||
domhandler: 5.0.3
|
||||
@@ -11229,6 +11305,8 @@ snapshots:
|
||||
universalify: 0.2.0
|
||||
url-parse: 1.5.10
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
tr46@1.0.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
@@ -11473,6 +11551,8 @@ snapshots:
|
||||
glob-to-regexp: 0.4.1
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@4.0.2: {}
|
||||
|
||||
webidl-conversions@5.0.0: {}
|
||||
@@ -11543,6 +11623,11 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
lodash.sortby: 4.7.0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,14 +34,31 @@ export async function GET(request: NextRequest) {
|
||||
if (username === process.env.USERNAME) {
|
||||
result.Role = 'owner';
|
||||
} else {
|
||||
const user = config.UserConfig.Users.find((u) => u.username === username);
|
||||
if (user && user.role === 'admin' && !user.banned) {
|
||||
result.Role = 'admin';
|
||||
// 优先从新版本获取用户信息
|
||||
const { db } = await import('@/lib/db');
|
||||
const userInfoV2 = await db.getUserInfoV2(username);
|
||||
|
||||
if (userInfoV2) {
|
||||
// 使用新版本用户信息
|
||||
if (userInfoV2.role === 'admin' && !userInfoV2.banned) {
|
||||
result.Role = 'admin';
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: '你是管理员吗你就访问?' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: '你是管理员吗你就访问?' },
|
||||
{ status: 401 }
|
||||
);
|
||||
// 回退到配置中查找
|
||||
const user = config.UserConfig.Users.find((u) => u.username === username);
|
||||
if (user && user.role === 'admin' && !user.banned) {
|
||||
result.Role = 'admin';
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: '你是管理员吗你就访问?' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,14 +54,26 @@ export async function POST(req: NextRequest) {
|
||||
// 管理员配置
|
||||
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();
|
||||
// 添加站长用户
|
||||
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));
|
||||
|
||||
// 为每个用户收集数据
|
||||
@@ -76,7 +88,9 @@ export async function POST(req: NextRequest) {
|
||||
// 跳过片头片尾配置
|
||||
skipConfigs: await db.getAllSkipConfigs(username),
|
||||
// 用户密码(通过验证空密码来检查用户是否存在,然后获取密码)
|
||||
password: await getUserPassword(username)
|
||||
password: await getUserPassword(username),
|
||||
// V2用户的加密密码
|
||||
passwordV2: await getUserPasswordV2(username)
|
||||
};
|
||||
|
||||
exportData.data.userData[username] = userData;
|
||||
@@ -134,3 +148,19 @@ async function getUserPassword(username: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:获取V2用户的加密密码
|
||||
async function getUserPasswordV2(username: string): Promise<string | null> {
|
||||
try {
|
||||
const storage = (db as any).storage;
|
||||
if (storage && typeof storage.client?.hget === 'function') {
|
||||
const userInfoKey = `user:${username}:info`;
|
||||
const password = await storage.client.hget(userInfoKey, 'password');
|
||||
return password;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`获取用户 ${username} V2密码失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,13 +94,80 @@ export async function POST(req: NextRequest) {
|
||||
// 不影响主流程,继续执行
|
||||
}
|
||||
|
||||
// 导入V2用户信息
|
||||
if (importData.data.usersV2 && Array.isArray(importData.data.usersV2)) {
|
||||
for (const userV2 of importData.data.usersV2) {
|
||||
try {
|
||||
// 跳过环境变量中的站长(站长使用环境变量认证)
|
||||
if (userV2.username === process.env.USERNAME) {
|
||||
console.log(`跳过站长 ${userV2.username} 的导入`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取用户的加密密码
|
||||
const userData = importData.data.userData[userV2.username];
|
||||
const passwordV2 = userData?.passwordV2;
|
||||
|
||||
if (passwordV2) {
|
||||
// 将站长角色转换为普通角色
|
||||
const importedRole = userV2.role === 'owner' ? 'user' : userV2.role;
|
||||
if (userV2.role === 'owner') {
|
||||
console.log(`用户 ${userV2.username} 的角色从 owner 转换为 user`);
|
||||
}
|
||||
|
||||
// 直接使用加密后的密码创建用户
|
||||
const storage = (db as any).storage;
|
||||
if (storage && typeof storage.client?.hset === 'function') {
|
||||
const userInfoKey = `user:${userV2.username}:info`;
|
||||
const createdAt = userV2.created_at || Date.now();
|
||||
|
||||
const userInfo: any = {
|
||||
role: importedRole,
|
||||
banned: userV2.banned,
|
||||
password: passwordV2,
|
||||
created_at: createdAt.toString(),
|
||||
};
|
||||
|
||||
if (userV2.tags && userV2.tags.length > 0) {
|
||||
userInfo.tags = JSON.stringify(userV2.tags);
|
||||
}
|
||||
|
||||
if (userV2.oidcSub) {
|
||||
userInfo.oidcSub = userV2.oidcSub;
|
||||
// 创建OIDC映射
|
||||
const oidcSubKey = `oidc:sub:${userV2.oidcSub}`;
|
||||
await storage.client.set(oidcSubKey, userV2.username);
|
||||
}
|
||||
|
||||
if (userV2.enabledApis && userV2.enabledApis.length > 0) {
|
||||
userInfo.enabledApis = JSON.stringify(userV2.enabledApis);
|
||||
}
|
||||
|
||||
await storage.client.hset(userInfoKey, userInfo);
|
||||
|
||||
// 添加到用户列表(Sorted Set)
|
||||
const userListKey = 'user:list';
|
||||
await storage.client.zadd(userListKey, {
|
||||
score: createdAt,
|
||||
member: userV2.username,
|
||||
});
|
||||
|
||||
console.log(`V2用户 ${userV2.username} 导入成功`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`导入V2用户 ${userV2.username} 失败:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导入用户数据
|
||||
const userData = importData.data.userData;
|
||||
for (const username in userData) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -139,6 +206,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({
|
||||
message: '数据导入成功',
|
||||
importedUsers: Object.keys(userData).length,
|
||||
importedUsersV2: importData.data.usersV2?.length || 0,
|
||||
timestamp: importData.timestamp,
|
||||
serverVersion: typeof importData.serverVersion === 'string' ? importData.serverVersion : '未知版本'
|
||||
});
|
||||
|
||||
76
src/app/api/admin/migrate-users/route.ts
Normal file
76
src/app/api/admin/migrate-users/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行数据迁移',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 只有站长可以执行迁移
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 获取配置
|
||||
const adminConfig = await getConfig();
|
||||
|
||||
// 检查是否有需要迁移的用户(排除站长)
|
||||
const usersToMigrate = adminConfig.UserConfig.Users.filter(
|
||||
u => u.role !== 'owner'
|
||||
);
|
||||
|
||||
if (!usersToMigrate || usersToMigrate.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: '没有需要迁移的用户' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 执行迁移
|
||||
await db.migrateUsersFromConfig(adminConfig);
|
||||
|
||||
// 迁移完成后,清空配置中的用户列表
|
||||
adminConfig.UserConfig.Users = [];
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
|
||||
// 更新配置缓存
|
||||
const { setCachedConfig } = await import('@/lib/config');
|
||||
await setCachedConfig(adminConfig);
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true, message: '用户数据迁移成功' },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('用户数据迁移失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '用户数据迁移失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
129
src/app/api/admin/openlist/route.ts
Normal file
129
src/app/api/admin/openlist/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { OpenListClient } from '@/lib/openlist.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* POST /api/admin/openlist
|
||||
* 保存 OpenList 配置
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, Enabled, URL, Username, Password, RootPath, ScanInterval } = body;
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
// 获取配置
|
||||
const adminConfig = await getConfig();
|
||||
|
||||
// 权限检查
|
||||
if (username !== process.env.USERNAME) {
|
||||
const userEntry = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'save') {
|
||||
// 如果功能未启用,允许保存空配置
|
||||
if (!Enabled) {
|
||||
adminConfig.OpenListConfig = {
|
||||
Enabled: false,
|
||||
URL: URL || '',
|
||||
Username: Username || '',
|
||||
Password: Password || '',
|
||||
RootPath: RootPath || '/',
|
||||
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
||||
ScanInterval: 0,
|
||||
};
|
||||
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '保存成功',
|
||||
});
|
||||
}
|
||||
|
||||
// 功能启用时,验证必填字段
|
||||
if (!URL || !Username || !Password) {
|
||||
return NextResponse.json(
|
||||
{ error: '请提供 URL、账号和密码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证扫描间隔
|
||||
let scanInterval = parseInt(ScanInterval) || 0;
|
||||
if (scanInterval > 0 && scanInterval < 60) {
|
||||
return NextResponse.json(
|
||||
{ error: '定时扫描间隔最低为 60 分钟' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证账号密码是否正确
|
||||
try {
|
||||
console.log('[OpenList Config] 验证账号密码');
|
||||
await OpenListClient.login(URL, Username, Password);
|
||||
console.log('[OpenList Config] 账号密码验证成功');
|
||||
} catch (error) {
|
||||
console.error('[OpenList Config] 账号密码验证失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '账号密码验证失败: ' + (error as Error).message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
adminConfig.OpenListConfig = {
|
||||
Enabled: true,
|
||||
URL,
|
||||
Username,
|
||||
Password,
|
||||
RootPath: RootPath || '/',
|
||||
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
||||
ScanInterval: scanInterval,
|
||||
};
|
||||
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '保存成功',
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error('OpenList 配置操作失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '操作失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,10 @@ export async function POST(request: NextRequest) {
|
||||
DanmakuApiBase,
|
||||
DanmakuApiToken,
|
||||
TMDBApiKey,
|
||||
TMDBProxy,
|
||||
PansouApiUrl,
|
||||
PansouUsername,
|
||||
PansouPassword,
|
||||
EnableComments,
|
||||
CustomAdFilterCode,
|
||||
CustomAdFilterVersion,
|
||||
@@ -74,6 +78,10 @@ export async function POST(request: NextRequest) {
|
||||
DanmakuApiBase: string;
|
||||
DanmakuApiToken: string;
|
||||
TMDBApiKey?: string;
|
||||
TMDBProxy?: string;
|
||||
PansouApiUrl?: string;
|
||||
PansouUsername?: string;
|
||||
PansouPassword?: string;
|
||||
EnableComments: boolean;
|
||||
CustomAdFilterCode?: string;
|
||||
CustomAdFilterVersion?: number;
|
||||
@@ -109,6 +117,7 @@ export async function POST(request: NextRequest) {
|
||||
typeof DanmakuApiBase !== 'string' ||
|
||||
typeof DanmakuApiToken !== 'string' ||
|
||||
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
|
||||
(TMDBProxy !== undefined && typeof TMDBProxy !== 'string') ||
|
||||
typeof EnableComments !== 'boolean' ||
|
||||
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
|
||||
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
|
||||
@@ -159,6 +168,10 @@ export async function POST(request: NextRequest) {
|
||||
DanmakuApiBase,
|
||||
DanmakuApiToken,
|
||||
TMDBApiKey,
|
||||
TMDBProxy,
|
||||
PansouApiUrl,
|
||||
PansouUsername,
|
||||
PansouPassword,
|
||||
EnableComments,
|
||||
CustomAdFilterCode,
|
||||
CustomAdFilterVersion,
|
||||
|
||||
@@ -66,6 +66,13 @@ export async function POST(request: NextRequest) {
|
||||
if (!key || !name || !api) {
|
||||
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)) {
|
||||
return NextResponse.json({ error: '该源已存在' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -85,24 +85,50 @@ export async function POST(request: NextRequest) {
|
||||
if (username === process.env.USERNAME) {
|
||||
operatorRole = 'owner';
|
||||
} else {
|
||||
const userEntry = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
// 优先从新版本获取用户信息
|
||||
const operatorInfo = await db.getUserInfoV2(username);
|
||||
if (operatorInfo) {
|
||||
if (operatorInfo.role !== 'admin' || operatorInfo.banned) {
|
||||
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 isTargetAdmin = false;
|
||||
let targetUserV2: any = null;
|
||||
|
||||
if (!['userGroup', 'batchUpdateUserGroups'].includes(action) && targetUsername) {
|
||||
// 先从配置中查找
|
||||
targetEntry = adminConfig.UserConfig.Users.find(
|
||||
(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 (
|
||||
targetEntry &&
|
||||
targetEntry.role === 'owner' &&
|
||||
@@ -120,33 +146,35 @@ export async function POST(request: NextRequest) {
|
||||
if (targetEntry) {
|
||||
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
|
||||
}
|
||||
// 检查新版本中是否已存在
|
||||
const existsV2 = await db.checkUserExistV2(targetUsername!);
|
||||
if (existsV2) {
|
||||
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
|
||||
}
|
||||
if (!targetPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少目标用户密码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
await db.registerUser(targetUsername!, targetPassword);
|
||||
|
||||
// 获取用户组信息
|
||||
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!,
|
||||
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;
|
||||
}
|
||||
case 'ban': {
|
||||
@@ -165,7 +193,9 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
targetEntry.banned = true;
|
||||
|
||||
// 只更新V2存储
|
||||
await db.updateUserInfoV2(targetUsername!, { banned: true });
|
||||
break;
|
||||
}
|
||||
case 'unban': {
|
||||
@@ -183,7 +213,9 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
targetEntry.banned = false;
|
||||
|
||||
// 只更新V2存储
|
||||
await db.updateUserInfoV2(targetUsername!, { banned: false });
|
||||
break;
|
||||
}
|
||||
case 'setAdmin': {
|
||||
@@ -205,7 +237,9 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
targetEntry.role = 'admin';
|
||||
|
||||
// 只更新V2存储
|
||||
await db.updateUserInfoV2(targetUsername!, { role: 'admin' });
|
||||
break;
|
||||
}
|
||||
case 'cancelAdmin': {
|
||||
@@ -227,7 +261,9 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
targetEntry.role = 'user';
|
||||
|
||||
// 只更新V2存储
|
||||
await db.updateUserInfoV2(targetUsername!, { role: 'user' });
|
||||
break;
|
||||
}
|
||||
case 'changePassword': {
|
||||
@@ -260,6 +296,9 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 使用新版本修改密码(SHA256加密)
|
||||
await db.changePasswordV2(targetUsername!, targetPassword);
|
||||
// 同时更新旧版本(保持兼容性)
|
||||
await db.changePassword(targetUsername!, targetPassword);
|
||||
break;
|
||||
}
|
||||
@@ -286,16 +325,11 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 只删除V2存储中的用户
|
||||
await db.deleteUserV2(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;
|
||||
}
|
||||
case 'updateUserApis': {
|
||||
@@ -320,13 +354,10 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 更新用户的采集源权限
|
||||
if (enabledApis && enabledApis.length > 0) {
|
||||
targetEntry.enabledApis = enabledApis;
|
||||
} else {
|
||||
// 如果为空数组或未提供,则删除该字段,表示无限制
|
||||
delete targetEntry.enabledApis;
|
||||
}
|
||||
// 更新V2存储中的采集源权限
|
||||
await db.updateUserInfoV2(targetUsername!, {
|
||||
enabledApis: enabledApis && enabledApis.length > 0 ? enabledApis : []
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -368,19 +399,17 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '用户组不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 查找使用该用户组的所有用户
|
||||
const affectedUsers: string[] = [];
|
||||
adminConfig.UserConfig.Users.forEach(user => {
|
||||
if (user.tags && user.tags.includes(groupName)) {
|
||||
affectedUsers.push(user.username);
|
||||
// 从用户的tags中移除该用户组
|
||||
user.tags = user.tags.filter(tag => tag !== groupName);
|
||||
// 如果用户没有其他标签了,删除tags字段
|
||||
if (user.tags.length === 0) {
|
||||
delete user.tags;
|
||||
}
|
||||
// 查找使用该用户组的所有用户(从V2存储中查找)
|
||||
const affectedUsers = await db.getUsersByTag(groupName);
|
||||
|
||||
// 从用户的tags中移除该用户组
|
||||
for (const username of affectedUsers) {
|
||||
const userInfo = await db.getUserInfoV2(username);
|
||||
if (userInfo && userInfo.tags) {
|
||||
const newTags = userInfo.tags.filter(tag => tag !== groupName);
|
||||
await db.updateUserInfoV2(username, { tags: newTags });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 删除用户组
|
||||
adminConfig.UserConfig.Tags.splice(groupIndex, 1);
|
||||
@@ -413,10 +442,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 更新用户的用户组
|
||||
if (userGroups && userGroups.length > 0) {
|
||||
targetEntry.tags = userGroups;
|
||||
// 只更新V2存储
|
||||
await db.updateUserInfoV2(targetUsername!, { tags: userGroups });
|
||||
} else {
|
||||
// 如果为空数组或未提供,则删除该字段,表示无用户组
|
||||
delete targetEntry.tags;
|
||||
await db.updateUserInfoV2(targetUsername!, { tags: [] });
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -431,7 +461,20 @@ export async function POST(request: NextRequest) {
|
||||
// 权限检查:站长可批量配置所有人的用户组,管理员只能批量配置普通用户
|
||||
if (operatorRole !== 'owner') {
|
||||
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) {
|
||||
return NextResponse.json({ error: `管理员无法操作其他管理员 ${targetUsername}` }, { status: 400 });
|
||||
}
|
||||
@@ -440,14 +483,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 批量更新用户组
|
||||
for (const targetUsername of usernames) {
|
||||
const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
|
||||
if (targetUser) {
|
||||
if (userGroups && userGroups.length > 0) {
|
||||
targetUser.tags = userGroups;
|
||||
} else {
|
||||
// 如果为空数组或未提供,则删除该字段,表示无用户组
|
||||
delete targetUser.tags;
|
||||
}
|
||||
// 只更新V2存储
|
||||
if (userGroups && userGroups.length > 0) {
|
||||
await db.updateUserInfoV2(targetUsername, { tags: userGroups });
|
||||
} else {
|
||||
await db.updateUserInfoV2(targetUsername, { tags: [] });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
129
src/app/api/admin/users/route.ts
Normal file
129
src/app/api/admin/users/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行用户列表查询',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 获取配置
|
||||
const adminConfig = await getConfig();
|
||||
|
||||
// 判定操作者角色
|
||||
let operatorRole: 'owner' | 'admin' | 'user' = 'user';
|
||||
if (authInfo.username === process.env.USERNAME) {
|
||||
operatorRole = 'owner';
|
||||
} else {
|
||||
// 优先从新版本获取用户信息
|
||||
const operatorInfo = await db.getUserInfoV2(authInfo.username);
|
||||
if (operatorInfo) {
|
||||
operatorRole = operatorInfo.role;
|
||||
} else {
|
||||
// 回退到配置中查找
|
||||
const userEntry = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (userEntry) {
|
||||
operatorRole = userEntry.role;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只有站长和管理员可以查看用户列表
|
||||
if (operatorRole !== 'owner' && operatorRole !== 'admin') {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 获取分页参数
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const limit = parseInt(searchParams.get('limit') || '10', 10);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// 获取用户列表(优先使用新版本)
|
||||
const result = await db.getUserListV2(offset, limit, process.env.USERNAME);
|
||||
|
||||
if (result.users.length > 0) {
|
||||
// 使用新版本数据
|
||||
return NextResponse.json(
|
||||
{
|
||||
users: result.users,
|
||||
total: result.total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(result.total / limit),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 回退到配置中的用户列表
|
||||
const configUsers = adminConfig.UserConfig.Users || [];
|
||||
const total = configUsers.length;
|
||||
|
||||
// 排序:站长始终在第一位,其他用户按用户名排序
|
||||
const sortedUsers = [...configUsers].sort((a, b) => {
|
||||
if (a.username === process.env.USERNAME) return -1;
|
||||
if (b.username === process.env.USERNAME) return 1;
|
||||
return a.username.localeCompare(b.username);
|
||||
});
|
||||
|
||||
// 分页
|
||||
const paginatedUsers = sortedUsers.slice(offset, offset + limit);
|
||||
|
||||
// 转换为统一格式
|
||||
const users = paginatedUsers.map((u) => ({
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
banned: u.banned || false,
|
||||
tags: u.tags,
|
||||
created_at: 0, // 配置中没有创建时间
|
||||
}));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
users,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '获取用户列表失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -148,15 +148,41 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// 检查用户是否已存在(通过OIDC sub查找)
|
||||
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 cookieValue = await generateAuthCookie(
|
||||
existingUser.username,
|
||||
existingUser.role || 'user'
|
||||
);
|
||||
const cookieValue = await generateAuthCookie(username, userRole);
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7);
|
||||
|
||||
|
||||
@@ -110,7 +110,20 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
// 检查用户名是否已存在(优先使用新版本)
|
||||
let userExists = await db.checkUserExistV2(username);
|
||||
if (!userExists) {
|
||||
// 回退到旧版本检查
|
||||
userExists = await db.checkUserExist(username);
|
||||
}
|
||||
if (userExists) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户名已存在' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查配置中是否已存在
|
||||
const existingUser = config.UserConfig.Users.find((u) => u.username === username);
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
@@ -119,9 +132,16 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 检查OIDC sub是否已被使用
|
||||
const existingOIDCUser = config.UserConfig.Users.find((u: any) => u.oidcSub === oidcSession.sub);
|
||||
if (existingOIDCUser) {
|
||||
// 检查OIDC sub是否已被使用(优先使用新版本)
|
||||
let existingOIDCUsername = await db.getUserByOidcSub(oidcSession.sub);
|
||||
if (!existingOIDCUsername) {
|
||||
// 回退到配置中查找
|
||||
const existingOIDCUser = config.UserConfig.Users.find((u: any) => u.oidcSub === oidcSession.sub);
|
||||
if (existingOIDCUser) {
|
||||
existingOIDCUsername = existingOIDCUser.username;
|
||||
}
|
||||
}
|
||||
if (existingOIDCUsername) {
|
||||
return NextResponse.json(
|
||||
{ error: '该OIDC账号已被注册' },
|
||||
{ status: 409 }
|
||||
@@ -132,25 +152,14 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 生成随机密码(OIDC用户不需要密码登录)
|
||||
const randomPassword = crypto.randomUUID();
|
||||
await db.registerUser(username, randomPassword);
|
||||
|
||||
// 将用户添加到配置中
|
||||
const newUser: any = {
|
||||
username: username,
|
||||
role: 'user',
|
||||
banned: false,
|
||||
oidcSub: oidcSession.sub, // 保存OIDC标识符
|
||||
};
|
||||
// 获取默认用户组
|
||||
const defaultTags = siteConfig.DefaultUserTags && siteConfig.DefaultUserTags.length > 0
|
||||
? siteConfig.DefaultUserTags
|
||||
: undefined;
|
||||
|
||||
// 如果配置了默认用户组,分配给新用户
|
||||
if (siteConfig.DefaultUserTags && siteConfig.DefaultUserTags.length > 0) {
|
||||
newUser.tags = siteConfig.DefaultUserTags;
|
||||
}
|
||||
|
||||
config.UserConfig.Users.push(newUser);
|
||||
|
||||
// 保存配置
|
||||
await db.saveAdminConfig(config);
|
||||
// 使用新版本创建用户(带SHA256加密和OIDC绑定)
|
||||
await db.createUserV2(username, randomPassword, 'user', defaultTags, oidcSession.sub);
|
||||
|
||||
// 设置认证cookie
|
||||
const response = NextResponse.json({ ok: true, message: '注册成功' });
|
||||
|
||||
@@ -45,8 +45,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
await db.changePassword(username, newPassword);
|
||||
// 修改密码(只更新V2存储)
|
||||
await db.changePasswordV2(username, newPassword);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { 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';
|
||||
|
||||
/**
|
||||
@@ -21,6 +31,11 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 特殊处理 openlist
|
||||
if (apiUrl === 'openlist') {
|
||||
return handleOpenListProxy(request);
|
||||
}
|
||||
|
||||
// 构建完整的 API 请求 URL,包含所有查询参数
|
||||
const targetUrl = new URL(apiUrl);
|
||||
|
||||
@@ -237,3 +252,170 @@ function processUrl(url: string, playFrom: string, proxyOrigin: string, tokenPar
|
||||
// 非 m3u8 链接不处理
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 OpenList 代理请求
|
||||
*/
|
||||
async function handleOpenListProxy(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const wd = searchParams.get('wd'); // 搜索关键词
|
||||
const ids = searchParams.get('ids'); // 详情ID
|
||||
|
||||
const config = await getConfig();
|
||||
const openListConfig = config.OpenListConfig;
|
||||
|
||||
if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) {
|
||||
return NextResponse.json(
|
||||
{ code: 0, msg: 'OpenList 未配置', list: [] },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
const rootPath = openListConfig.RootPath || '/';
|
||||
const client = new OpenListClient(
|
||||
openListConfig.URL,
|
||||
openListConfig.Username,
|
||||
openListConfig.Password
|
||||
);
|
||||
|
||||
// 读取 metainfo (从数据库或缓存)
|
||||
let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath);
|
||||
|
||||
if (!metaInfo) {
|
||||
try {
|
||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||
if (metainfoJson) {
|
||||
metaInfo = JSON.parse(metainfoJson) as MetaInfo;
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ code: 0, msg: 'metainfo 不存在', list: [] },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!metaInfo) {
|
||||
return NextResponse.json(
|
||||
{ code: 0, msg: '无数据', list: [] },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// 搜索模式
|
||||
if (wd) {
|
||||
const results = Object.entries(metaInfo.folders)
|
||||
.filter(
|
||||
([folderName, info]) =>
|
||||
folderName.toLowerCase().includes(wd.toLowerCase()) ||
|
||||
info.title.toLowerCase().includes(wd.toLowerCase())
|
||||
)
|
||||
.map(([folderName, info]) => ({
|
||||
vod_id: folderName,
|
||||
vod_name: info.title,
|
||||
vod_pic: getTMDBImageUrl(info.poster_path),
|
||||
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
||||
vod_year: info.release_date.split('-')[0] || '',
|
||||
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
code: 1,
|
||||
msg: '数据列表',
|
||||
page: 1,
|
||||
pagecount: 1,
|
||||
limit: results.length,
|
||||
total: results.length,
|
||||
list: results,
|
||||
});
|
||||
}
|
||||
|
||||
// 详情模式
|
||||
if (ids) {
|
||||
const folderName = ids;
|
||||
const info = metaInfo.folders[folderName];
|
||||
|
||||
if (!info) {
|
||||
return NextResponse.json(
|
||||
{ code: 0, msg: '视频不存在', list: [] },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取视频详情
|
||||
try {
|
||||
const detailResponse = await fetch(
|
||||
`${request.headers.get('x-forwarded-proto') || 'http'}://${request.headers.get('host')}/api/openlist/detail?folder=${encodeURIComponent(folderName)}`
|
||||
);
|
||||
|
||||
if (!detailResponse.ok) {
|
||||
throw new Error('获取视频详情失败');
|
||||
}
|
||||
|
||||
const detailData = await detailResponse.json();
|
||||
|
||||
if (!detailData.success) {
|
||||
throw new Error('获取视频详情失败');
|
||||
}
|
||||
|
||||
// 构建播放列表
|
||||
const playUrls = detailData.episodes
|
||||
.map((ep: any) => {
|
||||
const title = ep.title || `第${ep.episode}集`;
|
||||
return `${title}$${ep.playUrl}`;
|
||||
})
|
||||
.join('#');
|
||||
|
||||
return NextResponse.json({
|
||||
code: 1,
|
||||
msg: '数据列表',
|
||||
page: 1,
|
||||
pagecount: 1,
|
||||
limit: 1,
|
||||
total: 1,
|
||||
list: [
|
||||
{
|
||||
vod_id: folderName,
|
||||
vod_name: info.title,
|
||||
vod_pic: getTMDBImageUrl(info.poster_path),
|
||||
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
||||
vod_year: info.release_date.split('-')[0] || '',
|
||||
vod_content: info.overview,
|
||||
vod_play_from: 'OpenList',
|
||||
vod_play_url: playUrls,
|
||||
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取 OpenList 视频详情失败:', error);
|
||||
return NextResponse.json(
|
||||
{ code: 0, msg: '获取详情失败', list: [] },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 默认返回所有视频
|
||||
const results = Object.entries(metaInfo.folders).map(
|
||||
([folderName, info]) => ({
|
||||
vod_id: folderName,
|
||||
vod_name: info.title,
|
||||
vod_pic: getTMDBImageUrl(info.poster_path),
|
||||
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
||||
vod_year: info.release_date.split('-')[0] || '',
|
||||
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
code: 1,
|
||||
msg: '数据列表',
|
||||
page: 1,
|
||||
pagecount: 1,
|
||||
limit: results.length,
|
||||
total: results.length,
|
||||
list: results,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig, refineConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { db, getStorage } from '@/lib/db';
|
||||
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
||||
import { refreshLiveChannels } from '@/lib/live';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
@@ -41,6 +40,7 @@ export async function GET(request: NextRequest) {
|
||||
async function cronJob() {
|
||||
await refreshConfig();
|
||||
await refreshAllLiveChannels();
|
||||
await refreshOpenList();
|
||||
await refreshRecordAndFavorites();
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ async function refreshRecordAndFavorites() {
|
||||
|
||||
for (const user of users) {
|
||||
console.log(`开始处理用户: ${user}`);
|
||||
const storage = getStorage();
|
||||
|
||||
// 播放记录
|
||||
try {
|
||||
@@ -215,6 +216,7 @@ async function refreshRecordAndFavorites() {
|
||||
);
|
||||
const totalFavorites = Object.keys(favorites).length;
|
||||
let processedFavorites = 0;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [key, fav] of Object.entries(favorites)) {
|
||||
try {
|
||||
@@ -244,6 +246,26 @@ async function refreshRecordAndFavorites() {
|
||||
console.log(
|
||||
`更新收藏: ${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++;
|
||||
@@ -264,3 +286,66 @@ async function refreshRecordAndFavorites() {
|
||||
console.error('刷新播放记录/收藏任务启动失败', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOpenList() {
|
||||
try {
|
||||
const config = await getConfig();
|
||||
const openListConfig = config.OpenListConfig;
|
||||
|
||||
// 检查功能是否启用
|
||||
if (!openListConfig || !openListConfig.Enabled) {
|
||||
console.log('跳过 OpenList 扫描:功能未启用');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否配置了 OpenList 和定时扫描
|
||||
if (!openListConfig.URL || !openListConfig.Username || !openListConfig.Password) {
|
||||
console.log('跳过 OpenList 扫描:未配置');
|
||||
return;
|
||||
}
|
||||
|
||||
const scanInterval = openListConfig.ScanInterval || 0;
|
||||
if (scanInterval === 0) {
|
||||
console.log('跳过 OpenList 扫描:定时扫描已关闭');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查间隔时间是否满足最低要求(60分钟)
|
||||
if (scanInterval < 60) {
|
||||
console.log(`跳过 OpenList 扫描:间隔时间 ${scanInterval} 分钟小于最低要求 60 分钟`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查上次扫描时间
|
||||
const lastRefreshTime = openListConfig.LastRefreshTime || 0;
|
||||
const now = Date.now();
|
||||
const timeSinceLastRefresh = now - lastRefreshTime;
|
||||
const intervalMs = scanInterval * 60 * 1000;
|
||||
|
||||
if (timeSinceLastRefresh < intervalMs) {
|
||||
const remainingMinutes = Math.ceil((intervalMs - timeSinceLastRefresh) / 60000);
|
||||
console.log(`跳过 OpenList 扫描:距离上次扫描仅 ${Math.floor(timeSinceLastRefresh / 60000)} 分钟,还需等待 ${remainingMinutes} 分钟`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`开始 OpenList 定时扫描(间隔: ${scanInterval} 分钟)`);
|
||||
|
||||
// 调用扫描接口(立即扫描模式,不清空 metainfo)
|
||||
const response = await fetch(`${process.env.SITE_BASE || 'http://localhost:3000'}/api/openlist/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ clearMetaInfo: false }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`扫描请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('OpenList 定时扫描已启动,任务ID:', result.taskId);
|
||||
} catch (err) {
|
||||
console.error('OpenList 定时扫描失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,16 +16,13 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -55,16 +52,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// 添加超时控制和重试机制
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 10秒超时
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 添加超时控制
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 10秒超时
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
|
||||
@@ -36,7 +36,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// 添加超时控制
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 10秒超时
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
|
||||
// 调试信息
|
||||
const debugInfo = {
|
||||
storageType,
|
||||
envVars: {
|
||||
hasRedisUrl: !!process.env.REDIS_URL,
|
||||
hasUpstashUrl: !!process.env.UPSTASH_REDIS_REST_URL,
|
||||
hasUpstashToken: !!process.env.UPSTASH_REDIS_REST_TOKEN,
|
||||
hasKvrocksUrl: !!process.env.KVROCKS_URL,
|
||||
watchRoomEnabled: process.env.WATCH_ROOM_ENABLED,
|
||||
watchRoomServerType: process.env.WATCH_ROOM_SERVER_TYPE,
|
||||
hasWatchRoomExternalUrl: !!process.env.WATCH_ROOM_EXTERNAL_SERVER_URL,
|
||||
hasWatchRoomExternalAuth: !!process.env.WATCH_ROOM_EXTERNAL_SERVER_AUTH,
|
||||
},
|
||||
watchRoomConfig: {
|
||||
enabled: process.env.WATCH_ROOM_ENABLED === 'true',
|
||||
serverType: process.env.WATCH_ROOM_SERVER_TYPE || 'internal',
|
||||
externalServerUrl: process.env.WATCH_ROOM_EXTERNAL_SERVER_URL,
|
||||
externalServerAuth: process.env.WATCH_ROOM_EXTERNAL_SERVER_AUTH ? '***' : undefined,
|
||||
},
|
||||
configReadError: null as string | null,
|
||||
};
|
||||
|
||||
// 尝试读取配置(验证数据库连接)
|
||||
try {
|
||||
await getConfig();
|
||||
} catch (error) {
|
||||
debugInfo.configReadError = (error as Error).message;
|
||||
}
|
||||
|
||||
return NextResponse.json(debugInfo, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Debug API error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to get debug info',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
|
||||
import { getDetailFromApi } from '@/lib/downstream';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
@@ -20,6 +20,149 @@ export async function GET(request: NextRequest) {
|
||||
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)) {
|
||||
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -24,16 +24,13 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -78,16 +75,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -149,16 +143,13 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,44 +217,69 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const config = await getConfig();
|
||||
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 });
|
||||
}
|
||||
|
||||
// 校验用户密码
|
||||
try {
|
||||
const pass = await db.verifyUser(username, password);
|
||||
if (!pass) {
|
||||
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 });
|
||||
if (!pass) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户名或密码错误' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证成功,设置认证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) {
|
||||
console.error('登录接口异常', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
|
||||
61
src/app/api/openlist/check/route.ts
Normal file
61
src/app/api/openlist/check/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { OpenListClient } from '@/lib/openlist.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* POST /api/openlist/check
|
||||
* 检查 OpenList 连通性
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 权限检查
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 获取请求参数
|
||||
const body = await request.json();
|
||||
const { url, username, password } = body;
|
||||
|
||||
if (!url || !username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少必要参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 创建客户端并检查连通性
|
||||
const client = new OpenListClient(url, username, password);
|
||||
const result = await client.checkConnectivity();
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.message,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查 OpenList 连通性失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '检查失败',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
122
src/app/api/openlist/correct/route.ts
Normal file
122
src/app/api/openlist/correct/route.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { OpenListClient } from '@/lib/openlist.client';
|
||||
import {
|
||||
getCachedMetaInfo,
|
||||
invalidateMetaInfoCache,
|
||||
MetaInfo,
|
||||
setCachedMetaInfo,
|
||||
} from '@/lib/openlist-cache';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* POST /api/openlist/correct
|
||||
* 纠正视频的TMDB映射
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { folder, tmdbId, title, posterPath, releaseDate, overview, voteAverage, mediaType } = body;
|
||||
|
||||
if (!folder || !tmdbId) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少必要参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const openListConfig = config.OpenListConfig;
|
||||
|
||||
if (
|
||||
!openListConfig ||
|
||||
!openListConfig.Enabled ||
|
||||
!openListConfig.URL ||
|
||||
!openListConfig.Username ||
|
||||
!openListConfig.Password
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'OpenList 未配置或未启用' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const rootPath = openListConfig.RootPath || '/';
|
||||
const client = new OpenListClient(
|
||||
openListConfig.URL,
|
||||
openListConfig.Username,
|
||||
openListConfig.Password
|
||||
);
|
||||
|
||||
// 读取现有 metainfo (从数据库或缓存)
|
||||
let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath);
|
||||
|
||||
if (!metaInfo) {
|
||||
try {
|
||||
console.log('[OpenList Correct] 尝试从数据库读取 metainfo');
|
||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||
|
||||
if (metainfoJson) {
|
||||
metaInfo = JSON.parse(metainfoJson);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OpenList Correct] 从数据库读取 metainfo 失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'metainfo 读取失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!metaInfo) {
|
||||
return NextResponse.json(
|
||||
{ error: 'metainfo.json 不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 更新视频信息
|
||||
metaInfo.folders[folder] = {
|
||||
tmdb_id: tmdbId,
|
||||
title: title,
|
||||
poster_path: posterPath,
|
||||
release_date: releaseDate || '',
|
||||
overview: overview || '',
|
||||
vote_average: voteAverage || 0,
|
||||
media_type: mediaType,
|
||||
last_updated: Date.now(),
|
||||
failed: false, // 纠错后标记为成功
|
||||
};
|
||||
|
||||
// 保存 metainfo 到数据库
|
||||
const metainfoContent = JSON.stringify(metaInfo);
|
||||
|
||||
await db.setGlobalValue('video.metainfo', metainfoContent);
|
||||
|
||||
// 更新缓存
|
||||
invalidateMetaInfoCache(rootPath);
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '纠错成功',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('视频纠错失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '纠错失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
101
src/app/api/openlist/delete/route.ts
Normal file
101
src/app/api/openlist/delete/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
getCachedMetaInfo,
|
||||
invalidateMetaInfoCache,
|
||||
MetaInfo,
|
||||
setCachedMetaInfo,
|
||||
} from '@/lib/openlist-cache';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* POST /api/openlist/delete
|
||||
* 删除私人影库中的视频记录
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 权限检查
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 获取请求参数
|
||||
const body = await request.json();
|
||||
const { folder } = body;
|
||||
|
||||
if (!folder) {
|
||||
return NextResponse.json({ error: '缺少 folder 参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取配置
|
||||
const config = await getConfig();
|
||||
const openListConfig = config.OpenListConfig;
|
||||
|
||||
if (
|
||||
!openListConfig ||
|
||||
!openListConfig.Enabled ||
|
||||
!openListConfig.URL
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'OpenList 未配置或未启用' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const rootPath = openListConfig.RootPath || '/';
|
||||
|
||||
// 从数据库读取 metainfo
|
||||
const metainfoContent = await db.getGlobalValue('video.metainfo');
|
||||
if (!metainfoContent) {
|
||||
return NextResponse.json(
|
||||
{ error: '未找到视频元数据' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const metaInfo: MetaInfo = JSON.parse(metainfoContent);
|
||||
|
||||
// 检查文件夹是否存在
|
||||
if (!metaInfo.folders[folder]) {
|
||||
return NextResponse.json(
|
||||
{ error: '未找到该视频记录' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 删除文件夹记录
|
||||
delete metaInfo.folders[folder];
|
||||
|
||||
// 保存到数据库
|
||||
const updatedMetainfoContent = JSON.stringify(metaInfo);
|
||||
await db.setGlobalValue('video.metainfo', updatedMetainfoContent);
|
||||
|
||||
// 更新缓存
|
||||
invalidateMetaInfoCache(rootPath);
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
|
||||
// 更新配置中的资源数量
|
||||
if (config.OpenListConfig) {
|
||||
config.OpenListConfig.ResourceCount = Object.keys(metaInfo.folders).length;
|
||||
await db.saveAdminConfig(config);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '删除成功',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除视频记录失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '删除失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
231
src/app/api/openlist/detail/route.ts
Normal file
231
src/app/api/openlist/detail/route.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { OpenListClient } from '@/lib/openlist.client';
|
||||
import {
|
||||
getCachedVideoInfo,
|
||||
setCachedVideoInfo,
|
||||
VideoInfo,
|
||||
} from '@/lib/openlist-cache';
|
||||
import { parseVideoFileName } from '@/lib/video-parser';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/openlist/detail?folder=xxx
|
||||
* 获取视频文件夹的详细信息
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const folderName = searchParams.get('folder');
|
||||
|
||||
if (!folderName) {
|
||||
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const openListConfig = config.OpenListConfig;
|
||||
|
||||
if (
|
||||
!openListConfig ||
|
||||
!openListConfig.Enabled ||
|
||||
!openListConfig.URL ||
|
||||
!openListConfig.Username ||
|
||||
!openListConfig.Password
|
||||
) {
|
||||
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rootPath = openListConfig.RootPath || '/';
|
||||
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`;
|
||||
const client = new OpenListClient(
|
||||
openListConfig.URL,
|
||||
openListConfig.Username,
|
||||
openListConfig.Password
|
||||
);
|
||||
|
||||
// 1. 尝试读取缓存的 videoinfo.json
|
||||
let videoInfo: VideoInfo | null = getCachedVideoInfo(folderPath);
|
||||
|
||||
if (!videoInfo) {
|
||||
// 2. 尝试从 OpenList 读取 videoinfo.json
|
||||
try {
|
||||
const videoinfoPath = `${folderPath}/videoinfo.json`;
|
||||
const fileResponse = await client.getFile(videoinfoPath);
|
||||
|
||||
if (fileResponse.code === 200 && fileResponse.data.raw_url) {
|
||||
const downloadUrl = fileResponse.data.raw_url;
|
||||
const contentResponse = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
},
|
||||
});
|
||||
const content = await contentResponse.text();
|
||||
videoInfo = JSON.parse(content);
|
||||
|
||||
// 缓存
|
||||
if (videoInfo) {
|
||||
setCachedVideoInfo(folderPath, videoInfo);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('videoinfo.json 不存在,将解析文件名');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果没有 videoinfo.json,列出文件夹并解析
|
||||
if (!videoInfo) {
|
||||
const listResponse = await client.listDirectory(folderPath);
|
||||
|
||||
if (listResponse.code !== 200) {
|
||||
return NextResponse.json(
|
||||
{ error: 'OpenList 列表获取失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 过滤视频文件
|
||||
const videoFiles = listResponse.data.content.filter(
|
||||
(item) =>
|
||||
!item.is_dir &&
|
||||
!item.name.endsWith('.json') && // 排除 JSON 文件
|
||||
!item.name.startsWith('.') && // 排除隐藏文件
|
||||
(item.name.endsWith('.mp4') ||
|
||||
item.name.endsWith('.mkv') ||
|
||||
item.name.endsWith('.avi') ||
|
||||
item.name.endsWith('.m3u8') ||
|
||||
item.name.endsWith('.flv') ||
|
||||
item.name.endsWith('.ts'))
|
||||
);
|
||||
|
||||
videoInfo = {
|
||||
episodes: {},
|
||||
last_updated: Date.now(),
|
||||
};
|
||||
|
||||
// 按文件名排序,确保顺序一致
|
||||
videoFiles.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// 解析文件名
|
||||
for (let i = 0; i < videoFiles.length; i++) {
|
||||
const file = videoFiles[i];
|
||||
const parsed = parseVideoFileName(file.name);
|
||||
|
||||
videoInfo.episodes[file.name] = {
|
||||
episode: parsed.episode || (i + 1), // 如果解析失败,使用索引+1作为集数
|
||||
season: parsed.season,
|
||||
title: parsed.title,
|
||||
parsed_from: 'filename',
|
||||
};
|
||||
}
|
||||
|
||||
// 仅缓存到内存,不再持久化到 OpenList
|
||||
setCachedVideoInfo(folderPath, videoInfo);
|
||||
}
|
||||
|
||||
// 4. 获取视频文件列表(不获取播放链接,使用懒加载)
|
||||
const listResponse = await client.listDirectory(folderPath);
|
||||
|
||||
// 定义视频文件扩展名(不区分大小写)
|
||||
const videoExtensions = [
|
||||
'.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts',
|
||||
'.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg',
|
||||
'.mpeg', '.3gp', '.f4v', '.m4v', '.vob'
|
||||
];
|
||||
|
||||
const videoFiles = listResponse.data.content.filter((item) => {
|
||||
// 排除文件夹
|
||||
if (item.is_dir) return false;
|
||||
|
||||
// 排除隐藏文件
|
||||
if (item.name.startsWith('.')) return false;
|
||||
|
||||
// 排除 JSON 文件
|
||||
if (item.name.endsWith('.json')) return false;
|
||||
|
||||
// 检查是否是视频文件(不区分大小写)
|
||||
const lowerName = item.name.toLowerCase();
|
||||
return videoExtensions.some(ext => lowerName.endsWith(ext));
|
||||
});
|
||||
|
||||
// 5. 构建集数信息(不包含播放链接)
|
||||
// 确保所有视频文件都被显示,即使 videoInfo 中没有记录
|
||||
const episodes = videoFiles
|
||||
.map((file, index) => {
|
||||
// 总是重新解析文件名,确保使用最新的解析逻辑
|
||||
const parsed = parseVideoFileName(file.name);
|
||||
|
||||
// 如果解析成功,使用解析结果;否则使用 videoInfo 中的记录或索引
|
||||
let episodeInfo;
|
||||
if (parsed.episode) {
|
||||
episodeInfo = {
|
||||
episode: parsed.episode,
|
||||
season: parsed.season,
|
||||
title: parsed.title,
|
||||
parsed_from: 'filename',
|
||||
};
|
||||
} else {
|
||||
// 如果解析失败,尝试从 videoInfo 获取
|
||||
episodeInfo = videoInfo!.episodes[file.name];
|
||||
if (!episodeInfo) {
|
||||
// 如果 videoInfo 中也没有,使用索引
|
||||
episodeInfo = {
|
||||
episode: index + 1,
|
||||
season: undefined,
|
||||
title: undefined,
|
||||
parsed_from: 'filename',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 优先使用解析出的标题,其次是"第X集"格式,最后才是文件名
|
||||
let displayTitle = episodeInfo.title;
|
||||
if (!displayTitle && episodeInfo.episode) {
|
||||
// 支持小数集数显示
|
||||
displayTitle = `第${episodeInfo.episode}集`;
|
||||
}
|
||||
if (!displayTitle) {
|
||||
displayTitle = file.name;
|
||||
}
|
||||
|
||||
return {
|
||||
fileName: file.name,
|
||||
episode: episodeInfo.episode || 0,
|
||||
season: episodeInfo.season,
|
||||
title: displayTitle,
|
||||
size: file.size,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// 确保排序稳定,即使 episode 相同也按文件名排序
|
||||
if (a.episode !== b.episode) {
|
||||
return a.episode - b.episode;
|
||||
}
|
||||
return a.fileName.localeCompare(b.fileName);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
folder: folderName,
|
||||
episodes,
|
||||
videoInfo,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取视频详情失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
166
src/app/api/openlist/list/route.ts
Normal file
166
src/app/api/openlist/list/route.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { OpenListClient } from '@/lib/openlist.client';
|
||||
import {
|
||||
getCachedMetaInfo,
|
||||
MetaInfo,
|
||||
setCachedMetaInfo,
|
||||
} from '@/lib/openlist-cache';
|
||||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/openlist/list?page=1&pageSize=20&includeFailed=false&noCache=false
|
||||
* 获取私人影库视频列表
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '20');
|
||||
const includeFailed = searchParams.get('includeFailed') === 'true';
|
||||
const noCache = searchParams.get('noCache') === 'true';
|
||||
|
||||
const config = await getConfig();
|
||||
const openListConfig = config.OpenListConfig;
|
||||
|
||||
if (
|
||||
!openListConfig ||
|
||||
!openListConfig.Enabled ||
|
||||
!openListConfig.URL ||
|
||||
!openListConfig.Username ||
|
||||
!openListConfig.Password
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'OpenList 未配置或未启用', list: [], total: 0 },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
const rootPath = openListConfig.RootPath || '/';
|
||||
const client = new OpenListClient(
|
||||
openListConfig.URL,
|
||||
openListConfig.Username,
|
||||
openListConfig.Password
|
||||
);
|
||||
|
||||
// 读取 metainfo (从数据库或缓存)
|
||||
let metaInfo: MetaInfo | null = null;
|
||||
|
||||
// 如果不使用缓存,直接从数据库读取
|
||||
if (noCache) {
|
||||
// noCache 模式:跳过缓存
|
||||
} else {
|
||||
metaInfo = getCachedMetaInfo(rootPath);
|
||||
}
|
||||
|
||||
if (!metaInfo) {
|
||||
try {
|
||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||
|
||||
if (metainfoJson) {
|
||||
try {
|
||||
metaInfo = JSON.parse(metainfoJson);
|
||||
|
||||
// 验证数据结构
|
||||
if (!metaInfo || typeof metaInfo !== 'object') {
|
||||
throw new Error('metaInfo 不是有效对象');
|
||||
}
|
||||
if (!metaInfo.folders || typeof metaInfo.folders !== 'object') {
|
||||
throw new Error('metaInfo.folders 不存在或不是对象');
|
||||
}
|
||||
|
||||
// 只有在不是 noCache 模式时才更新缓存
|
||||
if (!noCache) {
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('[OpenList List] JSON 解析或验证失败:', parseError);
|
||||
throw new Error(`JSON 解析失败: ${(parseError as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error('数据库中没有 metainfo 数据');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OpenList List] 从数据库读取 metainfo 失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'metainfo 读取失败',
|
||||
details: (error as Error).message,
|
||||
list: [],
|
||||
total: 0,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!metaInfo) {
|
||||
return NextResponse.json(
|
||||
{ error: '无数据', list: [], total: 0 },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证 metaInfo 结构
|
||||
if (!metaInfo.folders || typeof metaInfo.folders !== 'object') {
|
||||
return NextResponse.json(
|
||||
{ error: 'metainfo.json 结构无效', list: [], total: 0 },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// 转换为数组并分页
|
||||
const allVideos = Object.entries(metaInfo.folders)
|
||||
.filter(([, info]) => includeFailed || !info.failed) // 根据参数过滤失败的视频
|
||||
.map(
|
||||
([folderName, info]) => ({
|
||||
id: folderName,
|
||||
folder: folderName,
|
||||
tmdbId: info.tmdb_id,
|
||||
title: info.title,
|
||||
poster: getTMDBImageUrl(info.poster_path),
|
||||
releaseDate: info.release_date,
|
||||
overview: info.overview,
|
||||
voteAverage: info.vote_average,
|
||||
mediaType: info.media_type,
|
||||
lastUpdated: info.last_updated,
|
||||
failed: info.failed || false,
|
||||
})
|
||||
);
|
||||
|
||||
// 按更新时间倒序排序
|
||||
allVideos.sort((a, b) => b.lastUpdated - a.lastUpdated);
|
||||
|
||||
const total = allVideos.length;
|
||||
const start = (page - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
const list = allVideos.slice(start, end);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
list,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取视频列表失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取失败', details: (error as Error).message, list: [], total: 0 },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
78
src/app/api/openlist/play/route.ts
Normal file
78
src/app/api/openlist/play/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { OpenListClient } from '@/lib/openlist.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/openlist/play?folder=xxx&fileName=xxx
|
||||
* 获取单个视频文件的播放链接(懒加载)
|
||||
* 返回重定向到真实播放 URL
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const folderName = searchParams.get('folder');
|
||||
const fileName = searchParams.get('fileName');
|
||||
|
||||
if (!folderName || !fileName) {
|
||||
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const openListConfig = config.OpenListConfig;
|
||||
|
||||
if (
|
||||
!openListConfig ||
|
||||
!openListConfig.Enabled ||
|
||||
!openListConfig.URL ||
|
||||
!openListConfig.Username ||
|
||||
!openListConfig.Password
|
||||
) {
|
||||
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rootPath = openListConfig.RootPath || '/';
|
||||
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`;
|
||||
const filePath = `${folderPath}/${fileName}`;
|
||||
|
||||
const client = new OpenListClient(
|
||||
openListConfig.URL,
|
||||
openListConfig.Username,
|
||||
openListConfig.Password
|
||||
);
|
||||
|
||||
// 获取文件的播放链接
|
||||
const fileResponse = await client.getFile(filePath);
|
||||
|
||||
if (fileResponse.code !== 200 || !fileResponse.data.raw_url) {
|
||||
console.error('[OpenList Play] 获取播放URL失败:', {
|
||||
fileName,
|
||||
code: fileResponse.code,
|
||||
message: fileResponse.message,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: '获取播放链接失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 返回重定向到真实播放 URL
|
||||
return NextResponse.redirect(fileResponse.data.raw_url);
|
||||
} catch (error) {
|
||||
console.error('获取播放链接失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
74
src/app/api/openlist/refresh-video/route.ts
Normal file
74
src/app/api/openlist/refresh-video/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { OpenListClient } from '@/lib/openlist.client';
|
||||
import { invalidateVideoInfoCache } from '@/lib/openlist-cache';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* POST /api/openlist/refresh-video
|
||||
* 刷新单个视频的 videoinfo.json
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { folder } = body;
|
||||
|
||||
if (!folder) {
|
||||
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const openListConfig = config.OpenListConfig;
|
||||
|
||||
if (
|
||||
!openListConfig ||
|
||||
!openListConfig.Enabled ||
|
||||
!openListConfig.URL ||
|
||||
!openListConfig.Username ||
|
||||
!openListConfig.Password
|
||||
) {
|
||||
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rootPath = openListConfig.RootPath || '/';
|
||||
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folder}`;
|
||||
const client = new OpenListClient(
|
||||
openListConfig.URL,
|
||||
openListConfig.Username,
|
||||
openListConfig.Password
|
||||
);
|
||||
|
||||
// 删除 videoinfo.json
|
||||
const videoinfoPath = `${folderPath}/videoinfo.json`;
|
||||
|
||||
try {
|
||||
await client.deleteFile(videoinfoPath);
|
||||
} catch (error) {
|
||||
console.log('videoinfo.json 不存在或删除失败');
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
invalidateVideoInfoCache(folderPath);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '刷新成功',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('刷新视频失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '刷新失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
290
src/app/api/openlist/refresh/route.ts
Normal file
290
src/app/api/openlist/refresh/route.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { OpenListClient } from '@/lib/openlist.client';
|
||||
import {
|
||||
getCachedMetaInfo,
|
||||
invalidateMetaInfoCache,
|
||||
MetaInfo,
|
||||
setCachedMetaInfo,
|
||||
} from '@/lib/openlist-cache';
|
||||
import {
|
||||
cleanupOldTasks,
|
||||
completeScanTask,
|
||||
createScanTask,
|
||||
failScanTask,
|
||||
updateScanTaskProgress,
|
||||
} from '@/lib/scan-task';
|
||||
import { searchTMDB } from '@/lib/tmdb.search';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* POST /api/openlist/refresh
|
||||
* 刷新私人影库元数据(后台任务模式)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 权限检查
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 获取请求参数
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const clearMetaInfo = body.clearMetaInfo === true; // 是否清空 metainfo
|
||||
|
||||
// 获取配置
|
||||
const config = await getConfig();
|
||||
const openListConfig = config.OpenListConfig;
|
||||
|
||||
if (
|
||||
!openListConfig ||
|
||||
!openListConfig.Enabled ||
|
||||
!openListConfig.URL ||
|
||||
!openListConfig.Username ||
|
||||
!openListConfig.Password
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'OpenList 未配置或未启用' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
||||
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
||||
|
||||
if (!tmdbApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'TMDB API Key 未配置' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 清理旧任务
|
||||
cleanupOldTasks();
|
||||
|
||||
// 创建后台任务
|
||||
const taskId = createScanTask();
|
||||
|
||||
// 启动后台扫描
|
||||
performScan(
|
||||
taskId,
|
||||
openListConfig.URL,
|
||||
openListConfig.RootPath || '/',
|
||||
tmdbApiKey,
|
||||
tmdbProxy,
|
||||
openListConfig.Username,
|
||||
openListConfig.Password,
|
||||
clearMetaInfo
|
||||
).catch((error) => {
|
||||
console.error('[OpenList Refresh] 后台扫描失败:', error);
|
||||
failScanTask(taskId, (error as Error).message);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
taskId,
|
||||
message: '扫描任务已启动',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('启动刷新任务失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '启动失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行扫描任务
|
||||
*/
|
||||
async function performScan(
|
||||
taskId: string,
|
||||
url: string,
|
||||
rootPath: string,
|
||||
tmdbApiKey: string,
|
||||
tmdbProxy?: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
clearMetaInfo?: boolean
|
||||
): Promise<void> {
|
||||
const client = new OpenListClient(url, username!, password!);
|
||||
|
||||
// 立即更新进度,确保任务可被查询
|
||||
updateScanTaskProgress(taskId, 0, 0);
|
||||
|
||||
try {
|
||||
// 1. 根据参数决定是否读取现有数据
|
||||
let metaInfo: MetaInfo;
|
||||
|
||||
if (clearMetaInfo) {
|
||||
// 重新扫描:清空现有数据
|
||||
metaInfo = {
|
||||
folders: {},
|
||||
last_refresh: Date.now(),
|
||||
};
|
||||
} else {
|
||||
// 立即扫描:保留现有数据,从数据库读取
|
||||
try {
|
||||
const metainfoContent = await db.getGlobalValue('video.metainfo');
|
||||
if (metainfoContent) {
|
||||
metaInfo = JSON.parse(metainfoContent);
|
||||
} else {
|
||||
metaInfo = {
|
||||
folders: {},
|
||||
last_refresh: Date.now(),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OpenList Refresh] 读取现有 metainfo 失败:', error);
|
||||
metaInfo = {
|
||||
folders: {},
|
||||
last_refresh: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 清除缓存,确保后续读取的是新数据
|
||||
invalidateMetaInfoCache(rootPath);
|
||||
|
||||
// 2. 列出根目录下的所有文件夹(强制刷新 OpenList 缓存)
|
||||
// 循环获取所有页的数据
|
||||
const folders: any[] = [];
|
||||
let currentPage = 1;
|
||||
const pageSize = 100;
|
||||
let total = 0;
|
||||
|
||||
while (true) {
|
||||
const listResponse = await client.listDirectory(rootPath, currentPage, pageSize, true);
|
||||
|
||||
if (listResponse.code !== 200) {
|
||||
throw new Error('OpenList 列表获取失败');
|
||||
}
|
||||
|
||||
total = listResponse.data.total;
|
||||
const pageFolders = listResponse.data.content.filter((item) => item.is_dir);
|
||||
folders.push(...pageFolders);
|
||||
|
||||
// 如果已经获取了所有数据,退出循环
|
||||
if (folders.length >= total) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentPage++;
|
||||
}
|
||||
|
||||
// 更新任务进度
|
||||
updateScanTaskProgress(taskId, 0, folders.length);
|
||||
|
||||
// 3. 遍历文件夹,搜索 TMDB
|
||||
let newCount = 0;
|
||||
let existingCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const folder = folders[i];
|
||||
|
||||
// 更新进度
|
||||
updateScanTaskProgress(taskId, i + 1, folders.length, folder.name);
|
||||
|
||||
// 如果是立即扫描(不清空 metainfo),且文件夹已存在,跳过
|
||||
if (!clearMetaInfo && metaInfo.folders[folder.name]) {
|
||||
existingCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 搜索 TMDB
|
||||
const searchResult = await searchTMDB(
|
||||
tmdbApiKey,
|
||||
folder.name,
|
||||
tmdbProxy
|
||||
);
|
||||
|
||||
if (searchResult.code === 200 && searchResult.result) {
|
||||
const result = searchResult.result;
|
||||
|
||||
metaInfo.folders[folder.name] = {
|
||||
tmdb_id: result.id,
|
||||
title: result.title || result.name || folder.name,
|
||||
poster_path: result.poster_path,
|
||||
release_date: result.release_date || result.first_air_date || '',
|
||||
overview: result.overview,
|
||||
vote_average: result.vote_average,
|
||||
media_type: result.media_type,
|
||||
last_updated: Date.now(),
|
||||
failed: false,
|
||||
};
|
||||
|
||||
newCount++;
|
||||
} else {
|
||||
// 记录失败的文件夹
|
||||
metaInfo.folders[folder.name] = {
|
||||
tmdb_id: 0,
|
||||
title: folder.name,
|
||||
poster_path: null,
|
||||
release_date: '',
|
||||
overview: '',
|
||||
vote_average: 0,
|
||||
media_type: 'movie',
|
||||
last_updated: Date.now(),
|
||||
failed: true,
|
||||
};
|
||||
errorCount++;
|
||||
}
|
||||
|
||||
// 避免请求过快
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
} catch (error) {
|
||||
console.error(`[OpenList Refresh] 处理文件夹失败: ${folder.name}`, error);
|
||||
// 记录失败的文件夹
|
||||
metaInfo.folders[folder.name] = {
|
||||
tmdb_id: 0,
|
||||
title: folder.name,
|
||||
poster_path: null,
|
||||
release_date: '',
|
||||
overview: '',
|
||||
vote_average: 0,
|
||||
media_type: 'movie',
|
||||
last_updated: Date.now(),
|
||||
failed: true,
|
||||
};
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 保存 metainfo 到数据库
|
||||
metaInfo.last_refresh = Date.now();
|
||||
|
||||
const metainfoContent = JSON.stringify(metaInfo);
|
||||
await db.setGlobalValue('video.metainfo', metainfoContent);
|
||||
|
||||
// 5. 更新缓存
|
||||
invalidateMetaInfoCache(rootPath);
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
|
||||
// 6. 更新配置
|
||||
const config = await getConfig();
|
||||
config.OpenListConfig!.LastRefreshTime = Date.now();
|
||||
config.OpenListConfig!.ResourceCount = Object.keys(metaInfo.folders).length;
|
||||
await db.saveAdminConfig(config);
|
||||
|
||||
// 完成任务
|
||||
completeScanTask(taskId, {
|
||||
total: folders.length,
|
||||
new: newCount,
|
||||
existing: existingCount,
|
||||
errors: errorCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[OpenList Refresh] 扫描失败:', error);
|
||||
failScanTask(taskId, (error as Error).message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
45
src/app/api/openlist/scan-progress/route.ts
Normal file
45
src/app/api/openlist/scan-progress/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getScanTask } from '@/lib/scan-task';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/openlist/scan-progress?taskId=xxx
|
||||
* 获取扫描任务进度
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const taskId = searchParams.get('taskId');
|
||||
|
||||
if (!taskId) {
|
||||
return NextResponse.json({ error: '缺少 taskId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const task = getScanTask(taskId);
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json({ error: '任务不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取扫描进度失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
61
src/app/api/pansou/search/route.ts
Normal file
61
src/app/api/pansou/search/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { searchPansou } from '@/lib/pansou.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { keyword } = body;
|
||||
|
||||
if (!keyword) {
|
||||
return NextResponse.json(
|
||||
{ error: '关键词不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 从系统配置中获取 Pansou 配置
|
||||
const config = await getConfig();
|
||||
const apiUrl = config.SiteConfig.PansouApiUrl;
|
||||
const username = config.SiteConfig.PansouUsername;
|
||||
const password = config.SiteConfig.PansouPassword;
|
||||
|
||||
console.log('Pansou 搜索请求:', {
|
||||
keyword,
|
||||
apiUrl: apiUrl ? '已配置' : '未配置',
|
||||
hasAuth: !!(username && password),
|
||||
});
|
||||
|
||||
if (!apiUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: '未配置 Pansou API 地址,请在管理面板配置' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 调用 Pansou 搜索
|
||||
const results = await searchPansou(apiUrl, keyword, {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
console.log('Pansou 搜索结果:', {
|
||||
total: results.total,
|
||||
hasData: !!results.merged_by_type,
|
||||
types: results.merged_by_type ? Object.keys(results.merged_by_type) : [],
|
||||
});
|
||||
|
||||
return NextResponse.json(results);
|
||||
} catch (error: any) {
|
||||
console.error('Pansou 搜索失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || '搜索失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,16 +17,13 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -50,16 +47,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -116,16 +110,13 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { lockManager } from '@/lib/lock';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -93,69 +94,78 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 检查用户是否已存在
|
||||
const existingUser = config.UserConfig.Users.find((u) => u.username === username);
|
||||
if (existingUser) {
|
||||
// 获取用户名锁,防止并发注册
|
||||
let releaseLock: (() => void) | null = null;
|
||||
try {
|
||||
releaseLock = await lockManager.acquire(`register:${username}`);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户名已存在' },
|
||||
{ status: 409 }
|
||||
{ error: '服务器繁忙,请稍后重试' },
|
||||
{ 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 {
|
||||
// 1. 在数据库中创建用户密码
|
||||
await db.registerUser(username, password);
|
||||
|
||||
// 2. 将用户添加到管理员配置的用户列表中
|
||||
const newUser: any = {
|
||||
username: username,
|
||||
role: 'user',
|
||||
banned: false,
|
||||
};
|
||||
|
||||
// 3. 如果配置了默认用户组,分配给新用户
|
||||
if (siteConfig.DefaultUserTags && siteConfig.DefaultUserTags.length > 0) {
|
||||
newUser.tags = siteConfig.DefaultUserTags;
|
||||
// 检查用户是否已存在(只检查V2存储)
|
||||
const userExists = await db.checkUserExistV2(username);
|
||||
if (userExists) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户名已存在' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
config.UserConfig.Users.push(newUser);
|
||||
// 如果开启了Turnstile验证
|
||||
if (siteConfig.RegistrationRequireTurnstile) {
|
||||
if (!turnstileToken) {
|
||||
return NextResponse.json(
|
||||
{ error: '请完成人机验证' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 保存更新后的配置
|
||||
await db.saveAdminConfig(config);
|
||||
if (!siteConfig.TurnstileSecretKey) {
|
||||
console.error('Turnstile Secret Key未配置');
|
||||
return NextResponse.json(
|
||||
{ error: '服务器配置错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 注册成功
|
||||
return NextResponse.json({ ok: true, message: '注册成功' });
|
||||
} catch (err) {
|
||||
console.error('创建用户失败', err);
|
||||
return NextResponse.json({ error: '注册失败,请稍后重试' }, { status: 500 });
|
||||
// 验证Turnstile Token
|
||||
const isValid = await verifyTurnstileToken(turnstileToken, siteConfig.TurnstileSecretKey);
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ 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) {
|
||||
console.error('注册接口异常', error);
|
||||
|
||||
@@ -36,6 +36,66 @@ export async function GET(request: NextRequest) {
|
||||
const config = await getConfig();
|
||||
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) =>
|
||||
Promise.race([
|
||||
@@ -54,7 +114,7 @@ export async function GET(request: NextRequest) {
|
||||
const successResults = results
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((result) => (result as PromiseFulfilledResult<any>).value);
|
||||
let flattenedResults = successResults.flat();
|
||||
let flattenedResults = [...openlistResults, ...successResults.flat()];
|
||||
if (!config.SiteConfig.DisableYellowFilter) {
|
||||
flattenedResults = flattenedResults.filter((result) => {
|
||||
const typeName = result.type_name || '';
|
||||
|
||||
@@ -33,6 +33,14 @@ export async function GET(request: NextRequest) {
|
||||
const config = await getConfig();
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
|
||||
// 检查是否配置了 OpenList
|
||||
const hasOpenList = !!(
|
||||
config.OpenListConfig?.Enabled &&
|
||||
config.OpenListConfig?.URL &&
|
||||
config.OpenListConfig?.Username &&
|
||||
config.OpenListConfig?.Password
|
||||
);
|
||||
|
||||
// 共享状态
|
||||
let streamClosed = false;
|
||||
|
||||
@@ -62,7 +70,7 @@ export async function GET(request: NextRequest) {
|
||||
const startEvent = `data: ${JSON.stringify({
|
||||
type: 'start',
|
||||
query,
|
||||
totalSources: apiSites.length,
|
||||
totalSources: apiSites.length + (hasOpenList ? 1 : 0),
|
||||
timestamp: Date.now()
|
||||
})}\n\n`;
|
||||
|
||||
@@ -74,6 +82,111 @@ export async function GET(request: NextRequest) {
|
||||
let completedSources = 0;
|
||||
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
|
||||
const searchPromises = apiSites.map(async (site) => {
|
||||
try {
|
||||
@@ -141,7 +254,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// 检查是否所有源都已完成
|
||||
if (completedSources === apiSites.length) {
|
||||
if (completedSources === apiSites.length + (hasOpenList ? 1 : 0)) {
|
||||
if (!streamClosed) {
|
||||
// 发送最终完成事件
|
||||
const completeEvent = `data: ${JSON.stringify({
|
||||
|
||||
@@ -23,16 +23,13 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -60,16 +57,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -112,16 +106,13 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,16 +16,13 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -59,16 +56,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -112,16 +106,13 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
86
src/app/api/tmdb/search/route.ts
Normal file
86
src/app/api/tmdb/search/route.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import nodeFetch from 'node-fetch';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/tmdb/search?query=xxx
|
||||
* 搜索TMDB,返回多个结果供用户选择
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('query');
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: '缺少查询参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
||||
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
||||
|
||||
if (!tmdbApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'TMDB API Key 未配置' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 使用 multi search 同时搜索电影和电视剧
|
||||
const url = `https://api.themoviedb.org/3/search/multi?api_key=${tmdbApiKey}&language=zh-CN&query=${encodeURIComponent(query)}&page=1`;
|
||||
|
||||
const fetchOptions: any = tmdbProxy
|
||||
? {
|
||||
agent: new HttpsProxyAgent(tmdbProxy, {
|
||||
timeout: 30000,
|
||||
keepAlive: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
: {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
};
|
||||
|
||||
// 使用 node-fetch 而不是原生 fetch
|
||||
const response = await nodeFetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('TMDB 搜索失败:', response.status, response.statusText);
|
||||
return NextResponse.json(
|
||||
{ error: 'TMDB 搜索失败', code: response.status },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data: any = await response.json();
|
||||
|
||||
// 过滤出电影和电视剧
|
||||
const validResults = data.results.filter(
|
||||
(item: any) => item.media_type === 'movie' || item.media_type === 'tv'
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
results: validResults,
|
||||
total: validResults.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('TMDB搜索失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '搜索失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export async function GET(request: NextRequest) {
|
||||
// 缓存不存在或已过期,获取新数据
|
||||
const config = await getConfig();
|
||||
const tmdbApiKey = config.SiteConfig?.TMDBApiKey;
|
||||
const tmdbProxy = config.SiteConfig?.TMDBProxy;
|
||||
|
||||
if (!tmdbApiKey) {
|
||||
return NextResponse.json(
|
||||
@@ -36,7 +37,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// 调用TMDB API获取数据
|
||||
const result = await getTMDBUpcomingContent(tmdbApiKey);
|
||||
const result = await getTMDBUpcomingContent(tmdbApiKey, tmdbProxy);
|
||||
|
||||
if (result.code !== 200) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -62,6 +62,7 @@ export default async function RootLayout({
|
||||
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
|
||||
let enableComments = false;
|
||||
let tmdbApiKey = '';
|
||||
let openListEnabled = false;
|
||||
let customCategories = [] as {
|
||||
name: string;
|
||||
type: 'movie' | 'tv';
|
||||
@@ -87,6 +88,13 @@ export default async function RootLayout({
|
||||
fluidSearch = config.SiteConfig.FluidSearch;
|
||||
enableComments = config.SiteConfig.EnableComments;
|
||||
tmdbApiKey = config.SiteConfig.TMDBApiKey || '';
|
||||
// 检查是否启用了 OpenList 功能
|
||||
openListEnabled = !!(
|
||||
config.OpenListConfig?.Enabled &&
|
||||
config.OpenListConfig?.URL &&
|
||||
config.OpenListConfig?.Username &&
|
||||
config.OpenListConfig?.Password
|
||||
);
|
||||
}
|
||||
|
||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||
@@ -103,6 +111,7 @@ export default async function RootLayout({
|
||||
ENABLE_TVBOX_SUBSCRIBE: process.env.ENABLE_TVBOX_SUBSCRIBE === 'true',
|
||||
ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
||||
VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback',
|
||||
OPENLIST_ENABLED: openListEnabled,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -57,55 +57,6 @@ function HomeClient() {
|
||||
}
|
||||
}, [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 = {
|
||||
id: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { Heart } from 'lucide-react';
|
||||
import { Heart, Search, X, Cloud } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
@@ -48,6 +48,7 @@ import DoubanComments from '@/components/DoubanComments';
|
||||
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
|
||||
import Toast, { ToastProps } from '@/components/Toast';
|
||||
import { useEnableComments } from '@/hooks/useEnableComments';
|
||||
import PansouSearch from '@/components/PansouSearch';
|
||||
|
||||
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
||||
declare global {
|
||||
@@ -96,6 +97,9 @@ function PlayPageClient() {
|
||||
// 收藏状态
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
|
||||
// 网盘搜索弹窗状态
|
||||
const [showPansouDialog, setShowPansouDialog] = useState(false);
|
||||
|
||||
// 跳过片头片尾配置
|
||||
const [skipConfig, setSkipConfig] = useState<{
|
||||
enable: boolean;
|
||||
@@ -289,6 +293,32 @@ function PlayPageClient() {
|
||||
const danmakuPluginRef = useRef<any>(null);
|
||||
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 [showDanmakuSourceSelector, setShowDanmakuSourceSelector] = useState(false);
|
||||
@@ -602,9 +632,9 @@ function PlayPageClient() {
|
||||
// 工具函数(Utils)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// 判断剧集是否已完结
|
||||
const isSeriesCompleted = (detail: SearchResult | null): boolean => {
|
||||
if (!detail) return false;
|
||||
// 判断剧集状态
|
||||
const getSeriesStatus = (detail: SearchResult | null): 'completed' | 'ongoing' | 'unknown' => {
|
||||
if (!detail) return 'unknown';
|
||||
|
||||
// 方法1:通过 vod_remarks 判断
|
||||
if (detail.vod_remarks) {
|
||||
@@ -616,23 +646,27 @@ function PlayPageClient() {
|
||||
|
||||
// 如果包含连载关键词,则为连载中
|
||||
if (ongoingKeywords.some(keyword => remarks.includes(keyword))) {
|
||||
return false;
|
||||
return 'ongoing';
|
||||
}
|
||||
|
||||
// 如果包含完结关键词,则为已完结
|
||||
if (completedKeywords.some(keyword => remarks.includes(keyword))) {
|
||||
return true;
|
||||
return 'completed';
|
||||
}
|
||||
}
|
||||
|
||||
// 方法2:通过 vod_total 和实际集数对比判断
|
||||
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(连载中)
|
||||
return false;
|
||||
// 无法判断,返回 unknown
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
// 播放源优选函数
|
||||
@@ -1772,7 +1806,9 @@ function PlayPageClient() {
|
||||
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
|
||||
: true) &&
|
||||
(searchType
|
||||
? (searchType === 'tv' && result.episodes.length > 1) ||
|
||||
? // openlist 源跳过 episodes 长度检查,因为搜索时不返回详细播放列表
|
||||
result.source === 'openlist' ||
|
||||
(searchType === 'tv' && result.episodes.length > 1) ||
|
||||
(searchType === 'movie' && result.episodes.length === 1)
|
||||
: true)
|
||||
);
|
||||
@@ -1825,6 +1861,15 @@ function PlayPageClient() {
|
||||
);
|
||||
if (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 {
|
||||
setError('未找到匹配结果');
|
||||
setLoading(false);
|
||||
@@ -1845,6 +1890,15 @@ function PlayPageClient() {
|
||||
|
||||
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);
|
||||
setCurrentSource(detailData.source);
|
||||
setCurrentId(detailData.id);
|
||||
@@ -2283,6 +2337,17 @@ function PlayPageClient() {
|
||||
setDanmakuCount(0);
|
||||
} finally {
|
||||
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,
|
||||
save_time: Date.now(),
|
||||
search_title: searchTitle,
|
||||
is_completed: isSeriesCompleted(detailRef.current),
|
||||
is_completed: getSeriesStatus(detailRef.current) === 'completed',
|
||||
vod_remarks: detailRef.current?.vod_remarks,
|
||||
});
|
||||
setFavorited(true);
|
||||
@@ -3108,6 +3173,7 @@ function PlayPageClient() {
|
||||
antiOverlap: true,
|
||||
synchronousPlayback: danmakuSettingsRef.current.synchronousPlayback,
|
||||
emitter: false,
|
||||
heatmap: !danmakuHeatmapDisabledRef.current, // 根据禁用状态决定是否创建热力图
|
||||
// 主题
|
||||
theme: 'dark',
|
||||
filter: (danmu: any) => {
|
||||
@@ -3188,6 +3254,33 @@ function PlayPageClient() {
|
||||
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 ? [
|
||||
{
|
||||
name: 'Anime4K超分',
|
||||
@@ -3872,6 +3965,16 @@ function PlayPageClient() {
|
||||
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();
|
||||
}
|
||||
@@ -4404,17 +4507,22 @@ function PlayPageClient() {
|
||||
)}
|
||||
</span>
|
||||
{/* 完结状态标识 */}
|
||||
{detail && totalEpisodes > 1 && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
isSeriesCompleted(detail)
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
}`}
|
||||
>
|
||||
{isSeriesCompleted(detail) ? '已完结' : '连载中'}
|
||||
</span>
|
||||
)}
|
||||
{detail && totalEpisodes > 1 && (() => {
|
||||
const status = getSeriesStatus(detail);
|
||||
if (status === 'unknown') return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
status === 'completed'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
}`}
|
||||
>
|
||||
{status === 'completed' ? '已完结' : '连载中'}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</h1>
|
||||
</div>
|
||||
{/* 第二行:播放器和选集 */}
|
||||
@@ -4890,6 +4998,17 @@ function PlayPageClient() {
|
||||
>
|
||||
<FavoriteIcon filled={favorited} />
|
||||
</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 && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
148
src/app/private-library/page.tsx
Normal file
148
src/app/private-library/page.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
interface Video {
|
||||
id: string;
|
||||
folder: string;
|
||||
tmdbId: number;
|
||||
title: string;
|
||||
poster: string;
|
||||
releaseDate: string;
|
||||
overview: string;
|
||||
voteAverage: number;
|
||||
mediaType: 'movie' | 'tv';
|
||||
}
|
||||
|
||||
export default function PrivateLibraryPage() {
|
||||
const router = useRouter();
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, [page]);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
`/api/openlist/list?page=${page}&pageSize=${pageSize}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取视频列表失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
setError(data.error);
|
||||
setVideos([]);
|
||||
} else {
|
||||
setVideos(data.list || []);
|
||||
setTotalPages(data.totalPages || 1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取视频列表失败:', err);
|
||||
setError('获取视频列表失败');
|
||||
setVideos([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoClick = (video: Video) => {
|
||||
// 跳转到播放页面
|
||||
router.push(`/play?source=openlist&id=${encodeURIComponent(video.folder)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/private-library'>
|
||||
<div className='container mx-auto px-4 py-6'>
|
||||
<div className='mb-6'>
|
||||
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100'>
|
||||
私人影库
|
||||
</h1>
|
||||
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
|
||||
观看自我收藏的高清视频吧
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6'>
|
||||
<p className='text-red-800 dark:text-red-200'>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
|
||||
{Array.from({ length: pageSize }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='animate-pulse bg-gray-200 dark:bg-gray-700 rounded-lg aspect-[2/3]'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : videos.length === 0 ? (
|
||||
<div className='text-center py-12'>
|
||||
<p className='text-gray-500 dark:text-gray-400'>
|
||||
暂无视频,请在管理面板配置 OpenList 并刷新
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
|
||||
{videos.map((video) => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
id={video.folder}
|
||||
source='openlist'
|
||||
title={video.title}
|
||||
poster={video.poster}
|
||||
year={video.releaseDate.split('-')[0]}
|
||||
from='search'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className='flex justify-center items-center gap-4 mt-8'>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className='px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded-lg transition-colors'
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
|
||||
<span className='text-gray-700 dark:text-gray-300'>
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className='px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded-lg transition-colors'
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +18,17 @@ import PageLayout from '@/components/PageLayout';
|
||||
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
|
||||
import SearchSuggestions from '@/components/SearchSuggestions';
|
||||
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
|
||||
import PansouSearch from '@/components/PansouSearch';
|
||||
|
||||
function SearchPageClient() {
|
||||
// 搜索历史
|
||||
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||
// 返回顶部按钮显示状态
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
// 选项卡状态: 'video' 或 'pansou'
|
||||
const [activeTab, setActiveTab] = useState<'video' | 'pansou'>('video');
|
||||
// Pansou 搜索触发标志
|
||||
const [triggerPansouSearch, setTriggerPansouSearch] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -381,6 +386,14 @@ function SearchPageClient() {
|
||||
});
|
||||
}, [aggregatedResults, filterAgg, searchQuery]);
|
||||
|
||||
// 监听选项卡切换,自动执行搜索
|
||||
useEffect(() => {
|
||||
// 如果切换到网盘搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索
|
||||
if (activeTab === 'pansou' && searchQuery.trim() && showResults) {
|
||||
setTriggerPansouSearch(prev => !prev);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
// 无搜索参数时聚焦搜索框
|
||||
!searchParams.get('q') && document.getElementById('searchInput')?.focus();
|
||||
@@ -693,8 +706,15 @@ function SearchPageClient() {
|
||||
setShowResults(true);
|
||||
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) => {
|
||||
@@ -778,14 +798,41 @@ function SearchPageClient() {
|
||||
/>
|
||||
</div>
|
||||
</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 className='max-w-[95%] mx-auto mt-12 overflow-visible'>
|
||||
{showResults ? (
|
||||
<section className='mb-12'>
|
||||
{/* 标题 */}
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
{activeTab === 'video' ? (
|
||||
<>
|
||||
{/* 影视搜索结果 */}
|
||||
{/* 标题 */}
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
搜索结果
|
||||
{isFromCache ? (
|
||||
@@ -930,6 +977,21 @@ function SearchPageClient() {
|
||||
))}
|
||||
</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>
|
||||
) : searchHistory.length > 0 ? (
|
||||
// 搜索历史
|
||||
@@ -953,9 +1015,18 @@ function SearchPageClient() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery(item);
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(item.trim())}`
|
||||
);
|
||||
setShowResults(true);
|
||||
|
||||
// 根据当前选项卡执行不同的搜索
|
||||
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'
|
||||
>
|
||||
|
||||
236
src/components/CorrectDialog.tsx
Normal file
236
src/components/CorrectDialog.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
'use client';
|
||||
|
||||
import { Search, X } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
interface TMDBResult {
|
||||
id: number;
|
||||
title?: string;
|
||||
name?: string;
|
||||
poster_path: string | null;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
overview: string;
|
||||
vote_average: number;
|
||||
media_type: 'movie' | 'tv';
|
||||
}
|
||||
|
||||
interface CorrectDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
folder: string;
|
||||
currentTitle: string;
|
||||
onCorrect: () => void;
|
||||
}
|
||||
|
||||
export default function CorrectDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
folder,
|
||||
currentTitle,
|
||||
onCorrect,
|
||||
}: CorrectDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState(currentTitle);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [results, setResults] = useState<TMDBResult[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [correcting, setCorrecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchQuery(currentTitle);
|
||||
setResults([]);
|
||||
setError('');
|
||||
}
|
||||
}, [isOpen, currentTitle]);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setError('请输入搜索关键词');
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
setError('');
|
||||
setResults([]);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/tmdb/search?query=${encodeURIComponent(searchQuery)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('搜索失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.results) {
|
||||
setResults(data.results);
|
||||
if (data.results.length === 0) {
|
||||
setError('未找到匹配的结果');
|
||||
}
|
||||
} else {
|
||||
setError('搜索失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('搜索失败:', err);
|
||||
setError('搜索失败,请重试');
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCorrect = async (result: TMDBResult) => {
|
||||
setCorrecting(true);
|
||||
try {
|
||||
const response = await fetch('/api/openlist/correct', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
folder,
|
||||
tmdbId: result.id,
|
||||
title: result.title || result.name,
|
||||
posterPath: result.poster_path,
|
||||
releaseDate: result.release_date || result.first_air_date,
|
||||
overview: result.overview,
|
||||
voteAverage: result.vote_average,
|
||||
mediaType: result.media_type,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('纠错失败');
|
||||
}
|
||||
|
||||
onCorrect();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('纠错失败:', err);
|
||||
setError('纠错失败,请重试');
|
||||
} finally {
|
||||
setCorrecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className='fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm'>
|
||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col m-4'>
|
||||
{/* 头部 */}
|
||||
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
|
||||
<h2 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||
纠错:{currentTitle}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className='p-4 border-b border-gray-200 dark:border-gray-700'>
|
||||
<div className='flex gap-2'>
|
||||
<input
|
||||
type='text'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
placeholder='输入搜索关键词'
|
||||
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent'
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searching}
|
||||
className='px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2'
|
||||
>
|
||||
<Search size={20} />
|
||||
<span className='hidden sm:inline'>{searching ? '搜索中...' : '搜索'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className='mt-2 text-sm text-red-600 dark:text-red-400'>{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div className='flex-1 overflow-y-auto p-4'>
|
||||
{results.length === 0 ? (
|
||||
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
|
||||
{searching ? '搜索中...' : '请输入关键词搜索'}
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{results.map((result) => (
|
||||
<div
|
||||
key={result.id}
|
||||
className='flex gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors'
|
||||
>
|
||||
{/* 海报 */}
|
||||
<div className='flex-shrink-0 w-16 h-24 relative rounded overflow-hidden bg-gray-200 dark:bg-gray-700'>
|
||||
{result.poster_path ? (
|
||||
<Image
|
||||
src={processImageUrl(getTMDBImageUrl(result.poster_path))}
|
||||
alt={result.title || result.name || ''}
|
||||
fill
|
||||
className='object-cover'
|
||||
referrerPolicy='no-referrer'
|
||||
/>
|
||||
) : (
|
||||
<div className='w-full h-full flex items-center justify-center text-gray-400 text-xs'>
|
||||
无海报
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
<h3 className='font-semibold text-gray-900 dark:text-gray-100 truncate'>
|
||||
{result.title || result.name}
|
||||
</h3>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-400 mt-1'>
|
||||
{result.media_type === 'movie' ? '电影' : '电视剧'} •{' '}
|
||||
{result.release_date?.split('-')[0] ||
|
||||
result.first_air_date?.split('-')[0] ||
|
||||
'未知'}{' '}
|
||||
• 评分: {result.vote_average.toFixed(1)}
|
||||
</p>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1 line-clamp-2'>
|
||||
{result.overview || '暂无简介'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 选择按钮 */}
|
||||
<div className='flex-shrink-0 flex items-center'>
|
||||
<button
|
||||
onClick={() => handleCorrect(result)}
|
||||
disabled={correcting}
|
||||
className='px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed'
|
||||
>
|
||||
{correcting ? '处理中...' : '选择'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -266,7 +266,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
if (
|
||||
!optimizationEnabled || // 若关闭测速则直接退出
|
||||
activeTab !== 'sources' ||
|
||||
availableSources.length === 0
|
||||
availableSources.length === 0 ||
|
||||
currentSource === 'openlist' // 私人影库不进行测速
|
||||
)
|
||||
return;
|
||||
|
||||
@@ -293,7 +294,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
|
||||
fetchVideoInfosInBatches();
|
||||
// 依赖项保持与之前一致
|
||||
}, [activeTab, availableSources, getVideoInfo, optimizationEnabled, initialTestingCompleted]);
|
||||
}, [activeTab, availableSources, getVideoInfo, optimizationEnabled, initialTestingCompleted, currentSource]);
|
||||
|
||||
// 升序分页标签
|
||||
const categoriesAsc = useMemo(() => {
|
||||
@@ -635,8 +636,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
if (!title) {
|
||||
return episodeNumber;
|
||||
}
|
||||
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字
|
||||
const match = title.match(/(?:第)?(\d+)(?:集|话)/);
|
||||
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字(支持小数)
|
||||
const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
@@ -848,6 +849,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
</div>
|
||||
{/* 重新测试按钮 */}
|
||||
{(() => {
|
||||
// 私人影库不显示重新测试按钮
|
||||
if (source.source === 'openlist') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceKey = `${source.source}-${source.id}`;
|
||||
const isTesting = retestingSources.has(sourceKey);
|
||||
const videoInfo = videoInfoMap.get(sourceKey);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
'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 { usePathname, useSearchParams } from 'next/navigation';
|
||||
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) {
|
||||
items.push({
|
||||
|
||||
314
src/components/PansouSearch.tsx
Normal file
314
src/components/PansouSearch.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, Copy, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { PansouLink, PansouSearchResult } from '@/lib/pansou.client';
|
||||
|
||||
interface PansouSearchProps {
|
||||
keyword: string;
|
||||
triggerSearch?: boolean; // 触发搜索的标志
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
// 网盘类型映射
|
||||
const CLOUD_TYPE_NAMES: Record<string, string> = {
|
||||
baidu: '百度网盘',
|
||||
aliyun: '阿里云盘',
|
||||
quark: '夸克网盘',
|
||||
tianyi: '天翼云盘',
|
||||
uc: 'UC网盘',
|
||||
mobile: '移动云盘',
|
||||
'115': '115网盘',
|
||||
pikpak: 'PikPak',
|
||||
xunlei: '迅雷网盘',
|
||||
'123': '123网盘',
|
||||
magnet: '磁力链接',
|
||||
ed2k: '电驴链接',
|
||||
others: '其他',
|
||||
};
|
||||
|
||||
// 网盘类型颜色
|
||||
const CLOUD_TYPE_COLORS: Record<string, string> = {
|
||||
baidu: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200',
|
||||
aliyun: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-200',
|
||||
quark: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200',
|
||||
tianyi: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-200',
|
||||
uc: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200',
|
||||
mobile: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-200',
|
||||
'115': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-200',
|
||||
pikpak: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-200',
|
||||
xunlei: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-200',
|
||||
'123': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-200',
|
||||
magnet: 'bg-gray-100 text-gray-800 dark:bg-gray-700/40 dark:text-gray-200',
|
||||
ed2k: 'bg-gray-100 text-gray-800 dark:bg-gray-700/40 dark:text-gray-200',
|
||||
others: 'bg-gray-100 text-gray-800 dark:bg-gray-700/40 dark:text-gray-200',
|
||||
};
|
||||
|
||||
export default function PansouSearch({
|
||||
keyword,
|
||||
triggerSearch,
|
||||
onError,
|
||||
}: PansouSearchProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<PansouSearchResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||
const [selectedType, setSelectedType] = useState<string>('all'); // 'all' 表示显示全部
|
||||
|
||||
useEffect(() => {
|
||||
// 只在 triggerSearch 变化时执行搜索,不响应 keyword 变化
|
||||
if (!triggerSearch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentKeyword = keyword.trim();
|
||||
if (!currentKeyword) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchPansou = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResults(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pansou/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
keyword: currentKeyword,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '搜索失败');
|
||||
}
|
||||
|
||||
const data: PansouSearchResult = await response.json();
|
||||
setResults(data);
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.message || '搜索失败,请检查配置';
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
searchPansou();
|
||||
}, [triggerSearch, onError]); // 移除 keyword 依赖,只依赖 triggerSearch
|
||||
|
||||
const handleCopy = async (text: string, url: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedUrl(url);
|
||||
setTimeout(() => setCopiedUrl(null), 2000);
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenLink = (url: string) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-center'>
|
||||
<Loader2 className='mx-auto h-8 w-8 animate-spin text-green-600 dark:text-green-400' />
|
||||
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
|
||||
正在搜索网盘资源...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-center'>
|
||||
<AlertCircle className='mx-auto h-12 w-12 text-red-500 dark:text-red-400' />
|
||||
<p className='mt-4 text-sm text-red-600 dark:text-red-400'>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!results || results.total === 0 || !results.merged_by_type) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='text-center'>
|
||||
<AlertCircle className='mx-auto h-12 w-12 text-gray-400 dark:text-gray-600' />
|
||||
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
|
||||
未找到相关资源
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cloudTypes = Object.keys(results.merged_by_type || {});
|
||||
|
||||
// 过滤显示的网盘类型
|
||||
const filteredCloudTypes = selectedType === 'all'
|
||||
? cloudTypes
|
||||
: cloudTypes.filter(type => type === selectedType);
|
||||
|
||||
// 计算每种网盘类型的数量
|
||||
const typeStats = cloudTypes.map(type => ({
|
||||
type,
|
||||
count: results.merged_by_type?.[type]?.length || 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* 搜索结果统计 */}
|
||||
<div className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
找到 <span className='font-semibold text-green-600 dark:text-green-400'>{results.total}</span> 个资源
|
||||
</div>
|
||||
|
||||
{/* 网盘类型过滤器 */}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<button
|
||||
onClick={() => setSelectedType('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedType === 'all'
|
||||
? 'bg-green-600 text-white dark:bg-green-600'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
全部 ({results.total})
|
||||
</button>
|
||||
{typeStats.map(({ type, count }) => {
|
||||
const typeName = CLOUD_TYPE_NAMES[type] || type;
|
||||
const typeColor = CLOUD_TYPE_COLORS[type] || CLOUD_TYPE_COLORS.others;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedType === type
|
||||
? 'bg-green-600 text-white dark:bg-green-600'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{typeName} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 按网盘类型分类显示 */}
|
||||
{filteredCloudTypes.map((cloudType) => {
|
||||
const links = results.merged_by_type?.[cloudType];
|
||||
if (!links || links.length === 0) return null;
|
||||
|
||||
const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType;
|
||||
const typeColor = CLOUD_TYPE_COLORS[cloudType] || CLOUD_TYPE_COLORS.others;
|
||||
|
||||
return (
|
||||
<div key={cloudType} className='space-y-3'>
|
||||
{/* 网盘类型标题 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${typeColor}`}>
|
||||
{typeName}
|
||||
</span>
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
{links.length} 个链接
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 链接列表 */}
|
||||
<div className='space-y-2'>
|
||||
{links.map((link: PansouLink, index: number) => (
|
||||
<div
|
||||
key={`${cloudType}-${index}`}
|
||||
className='p-4 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 transition-colors'
|
||||
>
|
||||
{/* 资源标题 */}
|
||||
{link.note && (
|
||||
<div className='mb-2 text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||
{link.note}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 链接和密码 */}
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='text-xs text-gray-600 dark:text-gray-400 truncate'>
|
||||
{link.url}
|
||||
</div>
|
||||
{link.password && (
|
||||
<div className='text-xs text-gray-600 dark:text-gray-400 mt-1'>
|
||||
提取码: <span className='font-mono font-semibold'>{link.password}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex items-center gap-1 flex-shrink-0'>
|
||||
<button
|
||||
onClick={() => handleCopy(
|
||||
link.password ? `${link.url}\n提取码: ${link.password}` : link.url,
|
||||
link.url
|
||||
)}
|
||||
className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
|
||||
title='复制链接'
|
||||
>
|
||||
{copiedUrl === link.url ? (
|
||||
<span className='text-xs text-green-600 dark:text-green-400'>已复制</span>
|
||||
) : (
|
||||
<Copy className='h-4 w-4 text-gray-600 dark:text-gray-400' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenLink(link.url)}
|
||||
className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
|
||||
title='打开链接'
|
||||
>
|
||||
<ExternalLink className='h-4 w-4 text-gray-600 dark:text-gray-400' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 来源和时间 */}
|
||||
<div className='flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400'>
|
||||
{link.source && (
|
||||
<span>来源: {link.source}</span>
|
||||
)}
|
||||
{link.datetime && (
|
||||
<span>{new Date(link.datetime).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 图片预览 */}
|
||||
{link.images && link.images.length > 0 && (
|
||||
<div className='mt-3 flex gap-2 overflow-x-auto'>
|
||||
{link.images.map((img, imgIndex) => (
|
||||
<img
|
||||
key={imgIndex}
|
||||
src={img}
|
||||
alt=''
|
||||
className='h-20 w-auto rounded object-cover'
|
||||
loading='lazy'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
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 { usePathname, useSearchParams } from 'next/navigation';
|
||||
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) {
|
||||
items.push({
|
||||
|
||||
@@ -86,6 +86,7 @@ export const UserMenu: React.FC = () => {
|
||||
const [enableOptimization, setEnableOptimization] = useState(true);
|
||||
const [fluidSearch, setFluidSearch] = useState(true);
|
||||
const [liveDirectConnect, setLiveDirectConnect] = useState(false);
|
||||
const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(false);
|
||||
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
|
||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
|
||||
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
||||
@@ -300,6 +301,11 @@ export const UserMenu: React.FC = () => {
|
||||
if (savedLiveDirectConnect !== null) {
|
||||
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) => {
|
||||
setDoubanDataSource(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -553,6 +566,7 @@ export const UserMenu: React.FC = () => {
|
||||
setEnableOptimization(true);
|
||||
setFluidSearch(defaultFluidSearch);
|
||||
setLiveDirectConnect(false);
|
||||
setDanmakuHeatmapDisabled(false);
|
||||
setDoubanProxyUrl(defaultDoubanProxy);
|
||||
setDoubanDataSource(defaultDoubanProxyType);
|
||||
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
||||
@@ -563,6 +577,7 @@ export const UserMenu: React.FC = () => {
|
||||
localStorage.setItem('enableOptimization', JSON.stringify(true));
|
||||
localStorage.setItem('fluidSearch', JSON.stringify(defaultFluidSearch));
|
||||
localStorage.setItem('liveDirectConnect', JSON.stringify(false));
|
||||
localStorage.setItem('danmaku_heatmap_disabled', 'false');
|
||||
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
||||
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
||||
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
||||
@@ -1150,6 +1165,30 @@ export const UserMenu: React.FC = () => {
|
||||
</label>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -659,7 +659,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
}}
|
||||
>
|
||||
<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={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
||||
@@ -21,6 +21,11 @@ export interface AdminConfig {
|
||||
DanmakuApiToken: string;
|
||||
// TMDB配置
|
||||
TMDBApiKey?: string;
|
||||
TMDBProxy?: string;
|
||||
// Pansou配置
|
||||
PansouApiUrl?: string;
|
||||
PansouUsername?: string;
|
||||
PansouPassword?: string;
|
||||
// 评论功能开关
|
||||
EnableComments: boolean;
|
||||
// 自定义去广告代码
|
||||
@@ -90,6 +95,16 @@ export interface AdminConfig {
|
||||
cacheMinutes: number; // 缓存时间(分钟)
|
||||
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 {
|
||||
|
||||
@@ -11,6 +11,25 @@ export interface ChangelogEntry {
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
version: '204.0.0',
|
||||
date: '2025-12-20',
|
||||
added: [
|
||||
"新增私人影视库功能(实验性)",
|
||||
"增加弹幕热力图",
|
||||
"增加盘搜搜索资源"
|
||||
],
|
||||
changed: [
|
||||
"完全重构用户数据存储结构",
|
||||
"提高所有弹幕接口的超时时间",
|
||||
"优化完结标识判断",
|
||||
"即将上映移动端字体大小调整",
|
||||
"tmdb增加代理支持",
|
||||
"剧集更新检测改为服务器后台定时执行"
|
||||
],
|
||||
fixed: [
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '203.2.2',
|
||||
date: '2025-12-20',
|
||||
added: [
|
||||
@@ -21,7 +40,8 @@ export const changelog: ChangelogEntry[] = [
|
||||
"修复IOS端换集报错播放器初始化失败",
|
||||
"修复超分切换时重复渲染"
|
||||
]
|
||||
},{
|
||||
},
|
||||
{
|
||||
version: '203.2.0',
|
||||
date: '2025-12-19',
|
||||
added: [
|
||||
|
||||
@@ -55,6 +55,7 @@ export const API_CONFIG = {
|
||||
|
||||
// 在模块加载时根据环境决定配置来源
|
||||
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',
|
||||
// TMDB配置
|
||||
TMDBApiKey: '',
|
||||
TMDBProxy: '',
|
||||
// 评论功能开关
|
||||
EnableComments: false,
|
||||
},
|
||||
@@ -302,32 +304,69 @@ export async function getConfig(): Promise<AdminConfig> {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
// 读 db
|
||||
let adminConfig: AdminConfig | null = null;
|
||||
let dbReadFailed = false;
|
||||
try {
|
||||
adminConfig = await db.getAdminConfig();
|
||||
} catch (e) {
|
||||
console.error('获取管理员配置失败:', e);
|
||||
dbReadFailed = true;
|
||||
// 如果正在初始化,等待初始化完成
|
||||
if (configInitPromise) {
|
||||
return configInitPromise;
|
||||
}
|
||||
|
||||
// db 中无配置,执行一次初始化
|
||||
if (!adminConfig) {
|
||||
if (dbReadFailed) {
|
||||
// 数据库读取失败,使用默认配置但不保存,避免覆盖数据库
|
||||
console.warn('数据库读取失败,使用临时默认配置(不会保存到数据库)');
|
||||
adminConfig = await getInitConfig("");
|
||||
} else {
|
||||
// 数据库中确实没有配置,首次初始化并保存
|
||||
console.log('首次初始化配置');
|
||||
adminConfig = await getInitConfig("");
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
// 创建初始化 Promise
|
||||
configInitPromise = (async () => {
|
||||
// 读 db
|
||||
let adminConfig: AdminConfig | null = null;
|
||||
let dbReadFailed = false;
|
||||
try {
|
||||
adminConfig = await db.getAdminConfig();
|
||||
} catch (e) {
|
||||
console.error('获取管理员配置失败:', e);
|
||||
dbReadFailed = true;
|
||||
}
|
||||
}
|
||||
adminConfig = configSelfCheck(adminConfig);
|
||||
cachedConfig = adminConfig;
|
||||
return cachedConfig;
|
||||
|
||||
// db 中无配置,执行一次初始化
|
||||
if (!adminConfig) {
|
||||
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 {
|
||||
@@ -469,14 +508,15 @@ export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
|
||||
return allApiSites;
|
||||
}
|
||||
|
||||
const userConfig = config.UserConfig.Users.find((u) => u.username === user);
|
||||
if (!userConfig) {
|
||||
// 从V2存储中获取用户信息
|
||||
const userInfoV2 = await db.getUserInfoV2(user);
|
||||
if (!userInfoV2) {
|
||||
return allApiSites;
|
||||
}
|
||||
|
||||
// 优先根据用户自己的 enabledApis 配置查找
|
||||
if (userConfig.enabledApis && userConfig.enabledApis.length > 0) {
|
||||
const userApiSitesSet = new Set(userConfig.enabledApis);
|
||||
if (userInfoV2.enabledApis && userInfoV2.enabledApis.length > 0) {
|
||||
const userApiSitesSet = new Set(userInfoV2.enabledApis);
|
||||
return allApiSites.filter((s) => userApiSitesSet.has(s.key)).map((s) => ({
|
||||
key: s.key,
|
||||
name: s.name,
|
||||
@@ -486,11 +526,11 @@ export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
|
||||
}
|
||||
|
||||
// 如果没有 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>();
|
||||
|
||||
// 遍历用户的所有 tags,收集对应的 enabledApis
|
||||
userConfig.tags.forEach(tagName => {
|
||||
userInfoV2.tags.forEach(tagName => {
|
||||
const tagConfig = config.UserConfig.Tags?.find(t => t.name === tagName);
|
||||
if (tagConfig && tagConfig.enabledApis) {
|
||||
tagConfig.enabledApis.forEach(apiKey => enabledApisFromTags.add(apiKey));
|
||||
|
||||
195
src/lib/db.ts
195
src/lib/db.ts
@@ -132,7 +132,7 @@ export class DbManager {
|
||||
return favorite !== null;
|
||||
}
|
||||
|
||||
// ---------- 用户相关 ----------
|
||||
// ---------- 用户相关(旧版本,保持兼容) ----------
|
||||
async registerUser(userName: string, password: string): Promise<void> {
|
||||
await this.storage.registerUser(userName, password);
|
||||
}
|
||||
@@ -154,6 +154,199 @@ export class DbManager {
|
||||
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[]> {
|
||||
return this.storage.getSearchHistory(userName);
|
||||
|
||||
96
src/lib/lock.ts
Normal file
96
src/lib/lock.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// 简单的内存锁管理器
|
||||
class LockManager {
|
||||
private locks: Map<string, { locked: boolean; queue: Array<() => void> }> = new Map();
|
||||
private readonly LOCK_TIMEOUT = 10000; // 10秒超时
|
||||
|
||||
async acquire(key: string): Promise<() => void> {
|
||||
// 获取或创建锁对象
|
||||
if (!this.locks.has(key)) {
|
||||
this.locks.set(key, { locked: false, queue: [] });
|
||||
}
|
||||
|
||||
const lock = this.locks.get(key)!;
|
||||
|
||||
// 如果锁未被占用,立即获取
|
||||
if (!lock.locked) {
|
||||
lock.locked = true;
|
||||
|
||||
// 设置超时自动释放
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.release(key);
|
||||
}, this.LOCK_TIMEOUT);
|
||||
|
||||
// 返回释放函数
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
this.release(key);
|
||||
};
|
||||
}
|
||||
|
||||
// 如果锁已被占用,等待
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
// 超时,从队列中移除
|
||||
const index = lock.queue.indexOf(callback);
|
||||
if (index > -1) {
|
||||
lock.queue.splice(index, 1);
|
||||
}
|
||||
reject(new Error('获取锁超时'));
|
||||
}, this.LOCK_TIMEOUT);
|
||||
|
||||
const callback = () => {
|
||||
clearTimeout(timeoutId);
|
||||
lock.locked = true;
|
||||
|
||||
// 设置超时自动释放
|
||||
const lockTimeoutId = setTimeout(() => {
|
||||
this.release(key);
|
||||
}, this.LOCK_TIMEOUT);
|
||||
|
||||
resolve(() => {
|
||||
clearTimeout(lockTimeoutId);
|
||||
this.release(key);
|
||||
});
|
||||
};
|
||||
|
||||
lock.queue.push(callback);
|
||||
});
|
||||
}
|
||||
|
||||
private release(key: string): void {
|
||||
const lock = this.locks.get(key);
|
||||
if (!lock) return;
|
||||
|
||||
// 如果队列中有等待者,唤醒下一个
|
||||
if (lock.queue.length > 0) {
|
||||
const next = lock.queue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
// 没有等待者,释放锁
|
||||
lock.locked = false;
|
||||
// 清理空的锁对象
|
||||
this.locks.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理所有锁(用于测试或重置)
|
||||
clear(): void {
|
||||
this.locks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 全局单例
|
||||
const globalKey = Symbol.for('__MOONTV_LOCK_MANAGER__');
|
||||
let _lockManager: LockManager | undefined = (global as any)[globalKey];
|
||||
|
||||
if (!_lockManager) {
|
||||
_lockManager = new LockManager();
|
||||
(global as any)[globalKey] = _lockManager;
|
||||
}
|
||||
|
||||
// TypeScript doesn't recognize that lockManager is always defined after the if block
|
||||
export const lockManager = _lockManager as LockManager;
|
||||
97
src/lib/openlist-cache.ts
Normal file
97
src/lib/openlist-cache.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// metainfo.json 缓存 (7天)
|
||||
interface MetaInfoCacheEntry {
|
||||
expiresAt: number;
|
||||
data: MetaInfo;
|
||||
}
|
||||
|
||||
// videoinfo.json 缓存 (1天)
|
||||
interface VideoInfoCacheEntry {
|
||||
expiresAt: number;
|
||||
data: VideoInfo;
|
||||
}
|
||||
|
||||
const METAINFO_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7天
|
||||
const VIDEOINFO_CACHE_TTL_MS = (parseInt(process.env.VIDEOINFO_CACHE_MINUTES || '1440', 10)) * 60 * 1000; // 默认1天
|
||||
|
||||
const METAINFO_CACHE: Map<string, MetaInfoCacheEntry> = new Map();
|
||||
const VIDEOINFO_CACHE: Map<string, VideoInfoCacheEntry> = new Map();
|
||||
|
||||
export interface MetaInfo {
|
||||
folders: {
|
||||
[folderName: string]: {
|
||||
tmdb_id: number;
|
||||
title: string;
|
||||
poster_path: string | null;
|
||||
release_date: string;
|
||||
overview: string;
|
||||
vote_average: number;
|
||||
media_type: 'movie' | 'tv';
|
||||
last_updated: number;
|
||||
failed?: boolean; // 标记是否搜索失败
|
||||
};
|
||||
};
|
||||
last_refresh: number;
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
episodes: {
|
||||
[fileName: string]: {
|
||||
episode: number;
|
||||
season?: number;
|
||||
title?: string;
|
||||
parsed_from: 'videoinfo' | 'filename';
|
||||
};
|
||||
};
|
||||
last_updated: number;
|
||||
}
|
||||
|
||||
// MetaInfo 缓存操作
|
||||
export function getCachedMetaInfo(rootPath: string): MetaInfo | null {
|
||||
const entry = METAINFO_CACHE.get(rootPath);
|
||||
if (!entry) return null;
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
METAINFO_CACHE.delete(rootPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
export function setCachedMetaInfo(rootPath: string, data: MetaInfo): void {
|
||||
METAINFO_CACHE.set(rootPath, {
|
||||
expiresAt: Date.now() + METAINFO_CACHE_TTL_MS,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateMetaInfoCache(rootPath: string): void {
|
||||
METAINFO_CACHE.delete(rootPath);
|
||||
}
|
||||
|
||||
// VideoInfo 缓存操作
|
||||
export function getCachedVideoInfo(folderPath: string): VideoInfo | null {
|
||||
const entry = VIDEOINFO_CACHE.get(folderPath);
|
||||
if (!entry) return null;
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
VIDEOINFO_CACHE.delete(folderPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
export function setCachedVideoInfo(
|
||||
folderPath: string,
|
||||
data: VideoInfo
|
||||
): void {
|
||||
VIDEOINFO_CACHE.set(folderPath, {
|
||||
expiresAt: Date.now() + VIDEOINFO_CACHE_TTL_MS,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateVideoInfoCache(folderPath: string): void {
|
||||
VIDEOINFO_CACHE.delete(folderPath);
|
||||
}
|
||||
318
src/lib/openlist.client.ts
Normal file
318
src/lib/openlist.client.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// Token 内存缓存
|
||||
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
||||
|
||||
export interface OpenListFile {
|
||||
name: string;
|
||||
size: number;
|
||||
is_dir: boolean;
|
||||
modified: string;
|
||||
sign?: string; // 临时下载签名
|
||||
raw_url?: string; // 完整下载链接
|
||||
thumb?: string;
|
||||
type: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface OpenListListResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
content: OpenListFile[];
|
||||
total: number;
|
||||
readme: string;
|
||||
write: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenListGetResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: OpenListFile;
|
||||
}
|
||||
|
||||
export class OpenListClient {
|
||||
private token: string = '';
|
||||
|
||||
constructor(
|
||||
private baseURL: string,
|
||||
private username: string,
|
||||
private password: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 使用账号密码登录获取Token
|
||||
*/
|
||||
static async login(
|
||||
baseURL: string,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
const response = await fetch(`${baseURL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenList 登录失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.code !== 200 || !data.data?.token) {
|
||||
throw new Error('OpenList 登录失败: 未获取到Token');
|
||||
}
|
||||
|
||||
return data.data.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的 Token 或重新登录
|
||||
*/
|
||||
private async getToken(): Promise<string> {
|
||||
const cacheKey = `${this.baseURL}:${this.username}`;
|
||||
const cached = tokenCache.get(cacheKey);
|
||||
|
||||
// 如果有缓存且未过期,直接返回
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
this.token = cached.token;
|
||||
return this.token;
|
||||
}
|
||||
|
||||
// 否则重新登录
|
||||
console.log('[OpenListClient] Token 不存在或已过期,重新登录');
|
||||
this.token = await OpenListClient.login(
|
||||
this.baseURL,
|
||||
this.username,
|
||||
this.password
|
||||
);
|
||||
|
||||
// 缓存 Token,设置 1 小时过期
|
||||
tokenCache.set(cacheKey, {
|
||||
token: this.token,
|
||||
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
console.log('[OpenListClient] 登录成功,Token 已缓存');
|
||||
return this.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 Token 缓存(当 Token 失效时调用)
|
||||
*/
|
||||
private clearTokenCache(): void {
|
||||
const cacheKey = `${this.baseURL}:${this.username}`;
|
||||
tokenCache.delete(cacheKey);
|
||||
console.log('[OpenListClient] Token 缓存已清除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行请求,如果401则清除缓存并重新登录后重试
|
||||
*/
|
||||
private async fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
retried = false
|
||||
): Promise<Response> {
|
||||
// 获取 Token
|
||||
const token = await this.getToken();
|
||||
|
||||
// 更新请求头中的 Token
|
||||
const requestOptions = {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: token,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url, requestOptions);
|
||||
|
||||
// 检查 HTTP status 401
|
||||
if (response.status === 401 && !retried) {
|
||||
console.log('[OpenListClient] 收到 HTTP 401,清除 Token 缓存并重试');
|
||||
this.clearTokenCache();
|
||||
return this.fetchWithRetry(url, options, true);
|
||||
}
|
||||
|
||||
// 检查响应体中的 code 字段(OpenList 的 Token 过期时 HTTP status 是 200,但 code 是 401)
|
||||
if (response.ok && !retried) {
|
||||
try {
|
||||
// 克隆响应以便读取 JSON
|
||||
const clonedResponse = response.clone();
|
||||
const data = await clonedResponse.json();
|
||||
|
||||
if (data.code === 401) {
|
||||
console.log('[OpenListClient] 响应体 code 为 401,Token 已过期,清除缓存并重试');
|
||||
this.clearTokenCache();
|
||||
return this.fetchWithRetry(url, options, true);
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果解析 JSON 失败,忽略错误,返回原始响应
|
||||
console.warn('[OpenListClient] 解析响应 JSON 失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async getHeaders() {
|
||||
const token = await this.getToken();
|
||||
return {
|
||||
Authorization: token, // 不带 bearer
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
// 列出目录
|
||||
async listDirectory(
|
||||
path: string,
|
||||
page = 1,
|
||||
perPage = 100,
|
||||
refresh = false
|
||||
): Promise<OpenListListResponse> {
|
||||
const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/list`, {
|
||||
method: 'POST',
|
||||
headers: await this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
path,
|
||||
password: '',
|
||||
refresh,
|
||||
page,
|
||||
per_page: perPage,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenList API 错误: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
async getFile(path: string): Promise<OpenListGetResponse> {
|
||||
const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/get`, {
|
||||
method: 'POST',
|
||||
headers: await this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
path,
|
||||
password: '',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenList API 错误: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
async uploadFile(path: string, content: string): Promise<void> {
|
||||
const token = await this.getToken();
|
||||
const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/put`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: token,
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'File-Path': encodeURIComponent(path),
|
||||
'As-Task': 'false',
|
||||
},
|
||||
body: content,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`OpenList 上传失败: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
// 上传成功后刷新目录缓存
|
||||
const dir = path.substring(0, path.lastIndexOf('/')) || '/';
|
||||
await this.refreshDirectory(dir);
|
||||
}
|
||||
|
||||
// 刷新目录缓存
|
||||
async refreshDirectory(path: string): Promise<void> {
|
||||
try {
|
||||
const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/list`, {
|
||||
method: 'POST',
|
||||
headers: await this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
path,
|
||||
password: '',
|
||||
refresh: true,
|
||||
page: 1,
|
||||
per_page: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`刷新目录缓存失败: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('刷新目录缓存失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
async deleteFile(path: string): Promise<void> {
|
||||
const dir = path.substring(0, path.lastIndexOf('/')) || '/';
|
||||
const fileName = path.substring(path.lastIndexOf('/') + 1);
|
||||
|
||||
const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/remove`, {
|
||||
method: 'POST',
|
||||
headers: await this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
names: [fileName],
|
||||
dir: dir,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenList 删除失败: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查连通性
|
||||
async checkConnectivity(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await this.fetchWithRetry(`${this.baseURL}/api/me`, {
|
||||
method: 'GET',
|
||||
headers: await this.getHeaders(),
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
success: false,
|
||||
message: `HTTP 状态码错误: ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code !== 200) {
|
||||
return {
|
||||
success: false,
|
||||
message: `响应码错误: ${data.code}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '连接成功',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '连接失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
218
src/lib/pansou.client.ts
Normal file
218
src/lib/pansou.client.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
/**
|
||||
* Pansou 网盘搜索 API 客户端
|
||||
* 文档: https://github.com/fish2018/pansou
|
||||
*/
|
||||
|
||||
// Token 缓存
|
||||
let cachedToken: string | null = null;
|
||||
let tokenExpiry: number | null = null;
|
||||
|
||||
export interface PansouLink {
|
||||
url: string;
|
||||
password: string;
|
||||
note: string;
|
||||
datetime: string;
|
||||
source: string;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export interface PansouSearchResult {
|
||||
total: number;
|
||||
merged_by_type?: {
|
||||
[key: string]: PansouLink[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface PansouLoginResponse {
|
||||
token: string;
|
||||
expires_at: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录 Pansou 获取 Token
|
||||
*/
|
||||
export async function loginPansou(
|
||||
apiUrl: string,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || '登录失败');
|
||||
}
|
||||
|
||||
const data: PansouLoginResponse = await response.json();
|
||||
|
||||
// 缓存 Token
|
||||
cachedToken = data.token;
|
||||
tokenExpiry = data.expires_at;
|
||||
|
||||
return data.token;
|
||||
} catch (error) {
|
||||
console.error('Pansou 登录失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有效的 Token(自动处理登录和缓存)
|
||||
*/
|
||||
async function getValidToken(
|
||||
apiUrl: string,
|
||||
username?: string,
|
||||
password?: string
|
||||
): Promise<string | null> {
|
||||
// 如果没有配置账号密码,返回 null(不需要认证)
|
||||
if (!username || !password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查缓存的 Token 是否有效
|
||||
if (cachedToken && tokenExpiry) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// 提前 5 分钟刷新 Token
|
||||
if (tokenExpiry - now > 300) {
|
||||
return cachedToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Token 过期或不存在,重新登录
|
||||
try {
|
||||
return await loginPansou(apiUrl, username, password);
|
||||
} catch (error) {
|
||||
console.error('获取 Pansou Token 失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索网盘资源
|
||||
*/
|
||||
export async function searchPansou(
|
||||
apiUrl: string,
|
||||
keyword: string,
|
||||
options?: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
refresh?: boolean;
|
||||
cloudTypes?: string[];
|
||||
}
|
||||
): Promise<PansouSearchResult> {
|
||||
try {
|
||||
// 获取 Token(如果需要认证)
|
||||
const token = await getValidToken(
|
||||
apiUrl,
|
||||
options?.username,
|
||||
options?.password
|
||||
);
|
||||
|
||||
// 构建请求头
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// 构建请求体
|
||||
const body: any = {
|
||||
kw: keyword,
|
||||
res: 'merge', // 只返回按网盘类型分类的结果
|
||||
};
|
||||
|
||||
if (options?.refresh) {
|
||||
body.refresh = true;
|
||||
}
|
||||
|
||||
if (options?.cloudTypes && options.cloudTypes.length > 0) {
|
||||
body.cloud_types = options.cloudTypes;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/search`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || error.message || '搜索失败');
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
// Pansou API 返回的数据结构是 { code, message, data }
|
||||
// 实际数据在 data 字段中
|
||||
let data: PansouSearchResult;
|
||||
|
||||
if (responseData.data) {
|
||||
// 如果有 data 字段,使用 data 中的内容
|
||||
data = responseData.data;
|
||||
} else {
|
||||
// 否则直接使用返回的数据
|
||||
data = responseData;
|
||||
}
|
||||
|
||||
// 验证返回的数据结构
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('返回数据格式错误');
|
||||
}
|
||||
|
||||
// 确保 merged_by_type 存在
|
||||
if (!data.merged_by_type) {
|
||||
data.merged_by_type = {};
|
||||
}
|
||||
|
||||
// 确保 total 存在
|
||||
if (typeof data.total !== 'number') {
|
||||
data.total = 0;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Pansou 搜索失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存的 Token
|
||||
*/
|
||||
export function clearPansouToken(): void {
|
||||
cachedToken = null;
|
||||
tokenExpiry = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Pansou 服务是否可用
|
||||
*/
|
||||
export async function checkPansouHealth(apiUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/health`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.status === 'ok';
|
||||
} catch (error) {
|
||||
console.error('Pansou 健康检查失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -242,7 +242,7 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
await this.withRetry(() => this.client.del(this.favKey(userName, key)));
|
||||
}
|
||||
|
||||
// ---------- 用户注册 / 登录 ----------
|
||||
// ---------- 用户注册 / 登录(旧版本,保持兼容) ----------
|
||||
private userPwdKey(user: string) {
|
||||
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) {
|
||||
return `u:${user}:sh`; // u:username:sh
|
||||
@@ -348,13 +664,20 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
|
||||
// ---------- 获取全部用户 ----------
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
const keys = await this.withRetry(() => this.client.keys('u:*:pwd'));
|
||||
return keys
|
||||
.map((k) => {
|
||||
const match = k.match(/^u:(.+?):pwd$/);
|
||||
return match ? ensureString(match[1]) : undefined;
|
||||
})
|
||||
.filter((u): u is string => typeof u === 'string');
|
||||
// 从新版用户列表获取
|
||||
const userListKey = this.userListKey();
|
||||
const users = await this.withRetry(() =>
|
||||
this.client.zRange(userListKey, 0, -1)
|
||||
);
|
||||
const userList = users.map(u => ensureString(u));
|
||||
|
||||
// 确保站长在列表中(站长可能不在数据库中,使用环境变量认证)
|
||||
const ownerUsername = process.env.USERNAME;
|
||||
if (ownerUsername && !userList.includes(ownerUsername)) {
|
||||
userList.unshift(ownerUsername);
|
||||
}
|
||||
|
||||
return userList;
|
||||
}
|
||||
|
||||
// ---------- 管理员配置 ----------
|
||||
|
||||
131
src/lib/scan-task.ts
Normal file
131
src/lib/scan-task.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
/**
|
||||
* 后台扫描任务管理
|
||||
*/
|
||||
|
||||
export interface ScanTask {
|
||||
id: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
progress: {
|
||||
current: number;
|
||||
total: number;
|
||||
currentFolder?: string;
|
||||
};
|
||||
result?: {
|
||||
total: number;
|
||||
new: number;
|
||||
existing: number;
|
||||
errors: number;
|
||||
};
|
||||
error?: string;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
}
|
||||
|
||||
const tasks = new Map<string, ScanTask>();
|
||||
|
||||
export function createScanTask(): string {
|
||||
const id = `scan_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||
const task: ScanTask = {
|
||||
id,
|
||||
status: 'running',
|
||||
progress: {
|
||||
current: 0,
|
||||
total: 0,
|
||||
},
|
||||
startTime: Date.now(),
|
||||
};
|
||||
tasks.set(id, task);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function getScanTask(id: string): ScanTask | null {
|
||||
return tasks.get(id) || null;
|
||||
}
|
||||
|
||||
export function updateScanTaskProgress(
|
||||
id: string,
|
||||
current: number,
|
||||
total: number,
|
||||
currentFolder?: string
|
||||
): void {
|
||||
let task = tasks.get(id);
|
||||
if (!task) {
|
||||
// 如果任务不存在(可能因为模块重新加载),重新创建任务
|
||||
console.warn(`[ScanTask] 任务 ${id} 不存在,重新创建`);
|
||||
task = {
|
||||
id,
|
||||
status: 'running',
|
||||
progress: {
|
||||
current: 0,
|
||||
total: 0,
|
||||
},
|
||||
startTime: Date.now(),
|
||||
};
|
||||
tasks.set(id, task);
|
||||
}
|
||||
task.progress = { current, total, currentFolder };
|
||||
}
|
||||
|
||||
export function completeScanTask(
|
||||
id: string,
|
||||
result: ScanTask['result']
|
||||
): void {
|
||||
let task = tasks.get(id);
|
||||
if (!task) {
|
||||
// 如果任务不存在(可能因为模块重新加载),重新创建任务
|
||||
console.warn(`[ScanTask] 任务 ${id} 不存在,重新创建并标记为完成`);
|
||||
task = {
|
||||
id,
|
||||
status: 'completed',
|
||||
progress: {
|
||||
current: result?.total || 0,
|
||||
total: result?.total || 0,
|
||||
},
|
||||
startTime: Date.now() - 60000, // 假设任务运行了1分钟
|
||||
endTime: Date.now(),
|
||||
result,
|
||||
};
|
||||
tasks.set(id, task);
|
||||
return;
|
||||
}
|
||||
task.status = 'completed';
|
||||
task.result = result;
|
||||
task.endTime = Date.now();
|
||||
}
|
||||
|
||||
export function failScanTask(id: string, error: string): void {
|
||||
let task = tasks.get(id);
|
||||
if (!task) {
|
||||
// 如果任务不存在(可能因为模块重新加载),重新创建任务
|
||||
console.warn(`[ScanTask] 任务 ${id} 不存在,重新创建并标记为失败`);
|
||||
task = {
|
||||
id,
|
||||
status: 'failed',
|
||||
progress: {
|
||||
current: 0,
|
||||
total: 0,
|
||||
},
|
||||
startTime: Date.now() - 60000, // 假设任务运行了1分钟
|
||||
endTime: Date.now(),
|
||||
error,
|
||||
};
|
||||
tasks.set(id, task);
|
||||
return;
|
||||
}
|
||||
task.status = 'failed';
|
||||
task.error = error;
|
||||
task.endTime = Date.now();
|
||||
}
|
||||
|
||||
export function cleanupOldTasks(): void {
|
||||
const now = Date.now();
|
||||
const maxAge = 60 * 60 * 1000; // 1小时
|
||||
|
||||
for (const [id, task] of Array.from(tasks.entries())) {
|
||||
if (task.endTime && now - task.endTime > maxAge) {
|
||||
tasks.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import nodeFetch from 'node-fetch';
|
||||
|
||||
export interface TMDBMovie {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -48,28 +51,42 @@ interface TMDBTVAiringTodayResponse {
|
||||
* @param apiKey - TMDB API Key
|
||||
* @param page - 页码
|
||||
* @param region - 地区代码,默认 CN (中国)
|
||||
* @param proxy - 代理服务器地址
|
||||
* @returns 即将上映的电影列表
|
||||
*/
|
||||
export async function getTMDBUpcomingMovies(
|
||||
apiKey: string,
|
||||
page: number = 1,
|
||||
region: string = 'CN'
|
||||
region: string = 'CN',
|
||||
proxy?: string
|
||||
): Promise<{ code: number; list: TMDBMovie[] }> {
|
||||
try {
|
||||
if (!apiKey) {
|
||||
return { code: 400, list: [] };
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.themoviedb.org/3/movie/upcoming?api_key=${apiKey}&language=zh-CN&page=${page}®ion=${region}`
|
||||
);
|
||||
const url = `https://api.themoviedb.org/3/movie/upcoming?api_key=${apiKey}&language=zh-CN&page=${page}®ion=${region}`;
|
||||
const fetchOptions: any = proxy
|
||||
? {
|
||||
agent: new HttpsProxyAgent(proxy, {
|
||||
timeout: 30000,
|
||||
keepAlive: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
: {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
};
|
||||
|
||||
// 使用 node-fetch 而不是原生 fetch
|
||||
const response = await nodeFetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('TMDB API 请求失败:', response.status, response.statusText);
|
||||
return { code: response.status, list: [] };
|
||||
}
|
||||
|
||||
const data: TMDBUpcomingResponse = await response.json();
|
||||
const data: TMDBUpcomingResponse = await response.json() as TMDBUpcomingResponse;
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
@@ -85,11 +102,13 @@ export async function getTMDBUpcomingMovies(
|
||||
* 获取正在播出的电视剧
|
||||
* @param apiKey - TMDB API Key
|
||||
* @param page - 页码
|
||||
* @param proxy - 代理服务器地址
|
||||
* @returns 正在播出的电视剧列表
|
||||
*/
|
||||
export async function getTMDBUpcomingTVShows(
|
||||
apiKey: string,
|
||||
page: number = 1
|
||||
page: number = 1,
|
||||
proxy?: string
|
||||
): Promise<{ code: number; list: TMDBTVShow[] }> {
|
||||
try {
|
||||
if (!apiKey) {
|
||||
@@ -97,16 +116,28 @@ export async function getTMDBUpcomingTVShows(
|
||||
}
|
||||
|
||||
// 使用 on_the_air 接口获取正在播出的电视剧
|
||||
const response = await fetch(
|
||||
`https://api.themoviedb.org/3/tv/on_the_air?api_key=${apiKey}&language=zh-CN&page=${page}`
|
||||
);
|
||||
const url = `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) {
|
||||
console.error('TMDB TV API 请求失败:', response.status, response.statusText);
|
||||
return { code: response.status, list: [] };
|
||||
}
|
||||
|
||||
const data: TMDBTVAiringTodayResponse = await response.json();
|
||||
const data: TMDBTVAiringTodayResponse = await response.json() as TMDBTVAiringTodayResponse;
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
@@ -121,10 +152,12 @@ export async function getTMDBUpcomingTVShows(
|
||||
/**
|
||||
* 获取即将上映/播出的内容(电影+电视剧)
|
||||
* @param apiKey - TMDB API Key
|
||||
* @param proxy - 代理服务器地址
|
||||
* @returns 统一格式的即将上映/播出列表
|
||||
*/
|
||||
export async function getTMDBUpcomingContent(
|
||||
apiKey: string
|
||||
apiKey: string,
|
||||
proxy?: string
|
||||
): Promise<{ code: number; list: TMDBItem[] }> {
|
||||
try {
|
||||
if (!apiKey) {
|
||||
@@ -133,8 +166,8 @@ export async function getTMDBUpcomingContent(
|
||||
|
||||
// 并行获取电影和电视剧数据
|
||||
const [moviesResult, tvShowsResult] = await Promise.all([
|
||||
getTMDBUpcomingMovies(apiKey),
|
||||
getTMDBUpcomingTVShows(apiKey),
|
||||
getTMDBUpcomingMovies(apiKey, 1, 'CN', proxy),
|
||||
getTMDBUpcomingTVShows(apiKey, 1, proxy),
|
||||
]);
|
||||
|
||||
// 检查是否有错误
|
||||
|
||||
91
src/lib/tmdb.search.ts
Normal file
91
src/lib/tmdb.search.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import nodeFetch from 'node-fetch';
|
||||
|
||||
export interface TMDBSearchResult {
|
||||
id: number;
|
||||
title?: string; // 电影
|
||||
name?: string; // 电视剧
|
||||
poster_path: string | null;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
overview: string;
|
||||
vote_average: number;
|
||||
media_type: 'movie' | 'tv';
|
||||
}
|
||||
|
||||
interface TMDBSearchResponse {
|
||||
results: TMDBSearchResult[];
|
||||
page: number;
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索 TMDB (电影+电视剧)
|
||||
*/
|
||||
export async function searchTMDB(
|
||||
apiKey: string,
|
||||
query: string,
|
||||
proxy?: string
|
||||
): Promise<{ code: number; result: TMDBSearchResult | null }> {
|
||||
try {
|
||||
if (!apiKey) {
|
||||
return { code: 400, result: null };
|
||||
}
|
||||
|
||||
// 使用 multi search 同时搜索电影和电视剧
|
||||
const url = `https://api.themoviedb.org/3/search/multi?api_key=${apiKey}&language=zh-CN&query=${encodeURIComponent(query)}&page=1`;
|
||||
|
||||
const fetchOptions: any = proxy
|
||||
? {
|
||||
agent: new HttpsProxyAgent(proxy, {
|
||||
timeout: 30000,
|
||||
keepAlive: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
: {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
};
|
||||
|
||||
// 使用 node-fetch 而不是原生 fetch,因为原生 fetch 不支持 agent 选项
|
||||
const response = await nodeFetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('TMDB 搜索失败:', response.status, response.statusText);
|
||||
return { code: response.status, result: null };
|
||||
}
|
||||
|
||||
const data: TMDBSearchResponse = await response.json() as TMDBSearchResponse;
|
||||
|
||||
// 过滤出电影和电视剧,取第一个结果
|
||||
const validResults = data.results.filter(
|
||||
(item) => item.media_type === 'movie' || item.media_type === 'tv'
|
||||
);
|
||||
|
||||
if (validResults.length === 0) {
|
||||
return { code: 404, result: null };
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
result: validResults[0],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('TMDB 搜索异常:', error);
|
||||
return { code: 500, result: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 TMDB 图片完整 URL
|
||||
*/
|
||||
export function getTMDBImageUrl(
|
||||
path: string | null,
|
||||
size: string = 'w500'
|
||||
): string {
|
||||
if (!path) return '';
|
||||
return `https://image.tmdb.org/t/p/${size}${path}`;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Redis } from '@upstash/redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
import { userInfoCache } from './user-cache';
|
||||
|
||||
// 搜索历史最大条数
|
||||
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) {
|
||||
return `u:${user}:sh`; // u:username:sh
|
||||
@@ -254,13 +673,20 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
// ---------- 获取全部用户 ----------
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
|
||||
return keys
|
||||
.map((k) => {
|
||||
const match = k.match(/^u:(.+?):pwd$/);
|
||||
return match ? ensureString(match[1]) : undefined;
|
||||
})
|
||||
.filter((u): u is string => typeof u === 'string');
|
||||
// 从新版用户列表获取
|
||||
const userListKey = this.userListKey();
|
||||
const users = await withRetry(() =>
|
||||
this.client.zrange(userListKey, 0, -1)
|
||||
);
|
||||
const userList = users.map(u => ensureString(u));
|
||||
|
||||
// 确保站长在列表中(站长可能不在数据库中,使用环境变量认证)
|
||||
const ownerUsername = process.env.USERNAME;
|
||||
if (ownerUsername && !userList.includes(ownerUsername)) {
|
||||
userList.unshift(ownerUsername);
|
||||
}
|
||||
|
||||
return userList;
|
||||
}
|
||||
|
||||
// ---------- 管理员配置 ----------
|
||||
|
||||
132
src/lib/user-cache.ts
Normal file
132
src/lib/user-cache.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// 用户信息缓存
|
||||
interface CachedUserInfo {
|
||||
role: 'owner' | 'admin' | 'user';
|
||||
banned: boolean;
|
||||
tags?: string[];
|
||||
oidcSub?: string;
|
||||
enabledApis?: string[];
|
||||
created_at: number;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
class UserInfoCache {
|
||||
private cache: Map<string, CachedUserInfo> = new Map();
|
||||
private readonly TTL = 6 * 60 * 60 * 1000; // 6小时过期
|
||||
|
||||
get(username: string): CachedUserInfo | null {
|
||||
const cached = this.cache.get(username);
|
||||
if (!cached) return null;
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() - cached.cachedAt > this.TTL) {
|
||||
this.cache.delete(username);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
set(username: string, userInfo: Omit<CachedUserInfo, 'cachedAt'>): void {
|
||||
this.cache.set(username, {
|
||||
...userInfo,
|
||||
cachedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
delete(username: string): void {
|
||||
this.cache.delete(username);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
// 清理过期的缓存
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
const entries = Array.from(this.cache.entries());
|
||||
for (const [username, cached] of entries) {
|
||||
if (now - cached.cachedAt > this.TTL) {
|
||||
this.cache.delete(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 站长存在状态缓存
|
||||
class OwnerExistenceCache {
|
||||
private cache: Map<string, { exists: boolean; cachedAt: number }> = new Map();
|
||||
private readonly TTL = 10 * 60 * 1000; // 10分钟过期
|
||||
|
||||
get(ownerUsername: string): boolean | null {
|
||||
const cached = this.cache.get(ownerUsername);
|
||||
if (!cached) return null;
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() - cached.cachedAt > this.TTL) {
|
||||
this.cache.delete(ownerUsername);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.exists;
|
||||
}
|
||||
|
||||
set(ownerUsername: string, exists: boolean): void {
|
||||
this.cache.set(ownerUsername, {
|
||||
exists,
|
||||
cachedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
delete(ownerUsername: string): void {
|
||||
this.cache.delete(ownerUsername);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
// 清理过期的缓存
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
const entries = Array.from(this.cache.entries());
|
||||
for (const [username, cached] of entries) {
|
||||
if (now - cached.cachedAt > this.TTL) {
|
||||
this.cache.delete(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局单例
|
||||
const globalKey = Symbol.for('__MOONTV_USER_INFO_CACHE__');
|
||||
let _userInfoCache: UserInfoCache | undefined = (global as any)[globalKey];
|
||||
|
||||
if (!_userInfoCache) {
|
||||
_userInfoCache = new UserInfoCache();
|
||||
(global as any)[globalKey] = _userInfoCache;
|
||||
|
||||
// 每分钟清理一次过期缓存
|
||||
setInterval(() => {
|
||||
_userInfoCache?.cleanup();
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
export const userInfoCache = _userInfoCache as UserInfoCache;
|
||||
|
||||
const ownerExistenceGlobalKey = Symbol.for('__MOONTV_OWNER_EXISTENCE_CACHE__');
|
||||
let _ownerExistenceCache: OwnerExistenceCache | undefined = (global as any)[ownerExistenceGlobalKey];
|
||||
|
||||
if (!_ownerExistenceCache) {
|
||||
_ownerExistenceCache = new OwnerExistenceCache();
|
||||
(global as any)[ownerExistenceGlobalKey] = _ownerExistenceCache;
|
||||
|
||||
// 每分钟清理一次过期缓存
|
||||
setInterval(() => {
|
||||
_ownerExistenceCache?.cleanup();
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
export const ownerExistenceCache = _ownerExistenceCache as OwnerExistenceCache;
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const CURRENT_VERSION = '203.2.2';
|
||||
const CURRENT_VERSION = '204.0.0';
|
||||
|
||||
// 导出当前版本号供其他地方使用
|
||||
export { CURRENT_VERSION };
|
||||
|
||||
57
src/lib/video-parser.ts
Normal file
57
src/lib/video-parser.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import parseTorrentName from 'parse-torrent-name';
|
||||
|
||||
export interface ParsedVideoInfo {
|
||||
episode?: number;
|
||||
season?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析视频文件名
|
||||
*/
|
||||
export function parseVideoFileName(fileName: string): ParsedVideoInfo {
|
||||
try {
|
||||
const parsed = parseTorrentName(fileName);
|
||||
|
||||
// 如果 parse-torrent-name 成功解析出集数,直接返回
|
||||
if (parsed.episode) {
|
||||
return {
|
||||
episode: parsed.episode,
|
||||
season: parsed.season,
|
||||
title: parsed.title,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('parse-torrent-name 解析失败:', fileName, error);
|
||||
}
|
||||
|
||||
// 降级方案:使用多种正则模式提取集数
|
||||
// 按优先级排序:更具体的模式优先
|
||||
const patterns = [
|
||||
// S01E01, s01e01, S01E01.5 (支持小数) - 最具体
|
||||
/[Ss]\d+[Ee](\d+(?:\.\d+)?)/,
|
||||
// [01], (01), [01.5], (01.5) (支持小数) - 很具体
|
||||
/[\[\(](\d+(?:\.\d+)?)[\]\)]/,
|
||||
// E01, E1, e01, e1, E01.5 (支持小数)
|
||||
/[Ee](\d+(?:\.\d+)?)/,
|
||||
// 第01集, 第1集, 第01话, 第1话, 第1.5集 (支持小数)
|
||||
/第(\d+(?:\.\d+)?)[集话]/,
|
||||
// _01_, -01-, _01.5_, -01.5- (支持小数)
|
||||
/[_\-](\d+(?:\.\d+)?)[_\-]/,
|
||||
// 01.mp4, 001.mp4, 01.5.mp4 (纯数字开头,支持小数) - 最不具体
|
||||
/^(\d+(?:\.\d+)?)[^\d.]/,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = fileName.match(pattern);
|
||||
if (match && match[1]) {
|
||||
const episode = parseFloat(match[1]);
|
||||
if (episode > 0 && episode < 10000) { // 合理的集数范围
|
||||
return { episode };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果所有模式都失败,返回空对象(调用方会处理)
|
||||
return {};
|
||||
}
|
||||
30
src/types/parse-torrent-name.d.ts
vendored
Normal file
30
src/types/parse-torrent-name.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
declare module 'parse-torrent-name' {
|
||||
interface ParsedTorrent {
|
||||
title?: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
year?: number;
|
||||
resolution?: string;
|
||||
codec?: string;
|
||||
audio?: string;
|
||||
group?: string;
|
||||
region?: string;
|
||||
extended?: boolean;
|
||||
hardcoded?: boolean;
|
||||
proper?: boolean;
|
||||
repack?: boolean;
|
||||
container?: string;
|
||||
widescreen?: boolean;
|
||||
website?: string;
|
||||
language?: string;
|
||||
sbs?: string;
|
||||
unrated?: boolean;
|
||||
size?: string;
|
||||
bitDepth?: string;
|
||||
hdr?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function parseTorrentName(name: string): ParsedTorrent;
|
||||
export default parseTorrentName;
|
||||
}
|
||||
Reference in New Issue
Block a user