diff --git a/CHANGELOG b/CHANGELOG index ac18ec3..3a89e19 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,19 @@ +## [204.0.0] - 2025-12-25 + +### Added +- ⚠️⚠️⚠️更新此版本前前务必进行备份!!!⚠️⚠️⚠️ +- 新增私人影视库功能(实验性) +- 增加弹幕热力图 +- 增加盘搜搜索资源 + +### Changed +- 完全重构用户数据存储结构 +- 提高所有弹幕接口的超时时间 +- 优化完结标识判断 +- 即将上映移动端字体大小调整 +- tmdb增加代理支持 +- 剧集更新检测改为服务器后台定时执行 + ## [203.2.2] - 2025-12-20 ### Fixed diff --git a/README.md b/README.md index 89ae989..3e44983 100644 --- a/README.md +++ b/README.md @@ -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 选项解释: diff --git a/VERSION.txt b/VERSION.txt index 617b5b5..dc6ffb7 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -203.2.2 \ No newline at end of file +204.0.0 \ No newline at end of file diff --git a/package.json b/package.json index 4527951..853b688 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7de938d..53c0307 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 39f6112..0cde3f3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -46,6 +46,7 @@ import { createPortal } from 'react-dom'; import { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; +import CorrectDialog from '@/components/CorrectDialog'; import DataMigration from '@/components/DataMigration'; import PageLayout from '@/components/PageLayout'; @@ -119,6 +120,7 @@ interface AlertModalProps { message?: string; timer?: number; showConfirm?: boolean; + onConfirm?: () => void; } const AlertModal = ({ @@ -129,6 +131,7 @@ const AlertModal = ({ message, timer, showConfirm = false, + onConfirm, }: AlertModalProps) => { const [isVisible, setIsVisible] = useState(false); @@ -195,14 +198,38 @@ const AlertModal = ({

{message}

)} - {showConfirm && ( - - )} + {showConfirm ? ( + onConfirm ? ( + // 确认操作:显示取消和确定按钮 +
+ + +
+ ) : ( + // 普通提示:只显示确定按钮 + + ) + ) : null} , @@ -219,6 +246,7 @@ const useAlertModal = () => { message?: string; timer?: number; showConfirm?: boolean; + onConfirm?: () => void; }>({ isOpen: false, type: 'success', @@ -298,6 +326,10 @@ interface SiteConfig { DanmakuApiBase: string; DanmakuApiToken: string; TMDBApiKey?: string; + TMDBProxy?: string; + PansouApiUrl?: string; + PansouUsername?: string; + PansouPassword?: string; EnableComments: boolean; EnableRegistration?: boolean; RegistrationRequireTurnstile?: boolean; @@ -390,9 +422,23 @@ interface UserConfigProps { config: AdminConfig | null; role: 'owner' | 'admin' | null; refreshConfig: () => Promise; + usersV2: Array<{ + username: string; + role: 'owner' | 'admin' | 'user'; + banned: boolean; + tags?: string[]; + oidcSub?: string; + enabledApis?: string[]; + created_at: number; + }> | null; + userPage: number; + userTotalPages: number; + userTotal: number; + fetchUsersV2: (page: number) => Promise; + userListLoading: boolean; } -const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { +const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalPages, userTotal, fetchUsersV2, userListLoading }: UserConfigProps) => { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); const [showAddUserForm, setShowAddUserForm] = useState(false); @@ -450,17 +496,31 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { // 当前登录用户名 const currentUsername = getAuthInfoFromBrowserCookie()?.username || null; + // 判断是否有旧版用户数据需要迁移 + const hasOldUserData = config?.UserConfig?.Users?.filter((u: any) => u.role !== 'owner').length ?? 0 > 0; + + // 使用新版本用户列表(如果可用且没有旧数据),否则使用配置中的用户列表 + const displayUsers: Array<{ + username: string; + role: 'owner' | 'admin' | 'user'; + banned?: boolean; + enabledApis?: string[]; + tags?: string[]; + created_at?: number; + oidcSub?: string; + }> = !hasOldUserData && usersV2 ? usersV2 : (config?.UserConfig?.Users || []); + // 使用 useMemo 计算全选状态,避免每次渲染都重新计算 const selectAllUsers = useMemo(() => { const selectableUserCount = - config?.UserConfig?.Users?.filter( + displayUsers?.filter( (user) => role === 'owner' || (role === 'admin' && (user.role === 'user' || user.username === currentUsername)) ).length || 0; return selectedUsers.size === selectableUserCount && selectedUsers.size > 0; - }, [selectedUsers.size, config?.UserConfig?.Users, role, currentUsername]); + }, [selectedUsers.size, displayUsers, role, currentUsername]); // 获取用户组列表 const userGroups = config?.UserConfig?.Tags || []; @@ -846,7 +906,7 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { throw new Error(data.error || `操作失败: ${res.status}`); } - // 成功后刷新配置(无需整页刷新) + // 成功后刷新配置和用户列表(refreshConfig 已经是 refreshConfigAndUsers) await refreshConfig(); } catch (err) { showError(err instanceof Error ? err.message : '操作失败', showAlert); @@ -884,12 +944,78 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
- {config.UserConfig.Users.length} + {!hasOldUserData && usersV2 ? userTotal : displayUsers.length}
总用户数
+ + {/* 数据迁移提示 */} + {config.UserConfig.Users && + config.UserConfig.Users.filter(u => u.role !== 'owner').length > 0 && ( +
+
+
+
+ 检测到旧版用户数据 +
+

+ 建议迁移到新的用户存储结构,以获得更好的性能和安全性。迁移后用户密码将使用SHA256加密。 +

+
+ +
+
+ )} {/* 用户组管理 */} @@ -1163,10 +1289,31 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { )} {/* 用户列表 */} -
+
+ {/* 迁移遮罩层 */} + {config.UserConfig.Users && + config.UserConfig.Users.filter(u => u.role !== 'owner').length > 0 && ( +
+
+
+ +

+ 需要迁移数据 +

+
+

+ 检测到旧版用户数据,请先迁移到新的存储结构后再进行用户管理操作。 +

+

+ 请在上方的"用户统计"区域点击"立即迁移"按钮完成数据迁移。 +

+
+
+ )} +
@@ -1234,8 +1381,21 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { {/* 按规则排序用户:自己 -> 站长(若非自己) -> 管理员 -> 其他 */} {(() => { - const sortedUsers = [...config.UserConfig.Users].sort((a, b) => { - type UserInfo = (typeof config.UserConfig.Users)[number]; + // 如果正在加载,显示加载状态 + if (userListLoading) { + return ( + + + + + + ); + } + + const sortedUsers = [...displayUsers].sort((a, b) => { + type UserInfo = (typeof displayUsers)[number]; const priority = (u: UserInfo) => { if (u.username === currentUsername) return 0; if (u.role === 'owner') return 1; @@ -1295,7 +1455,7 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
+ 加载中... +
{user.username} - {(user as any).oidcSub && ( + {user.oidcSub && ( OIDC @@ -1475,6 +1635,62 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { })()}
+ + {/* 用户列表分页 */} + {!hasOldUserData && usersV2 && userTotalPages > 1 && ( +
+
+ 共 {userTotal} 个用户,第 {userPage} / {userTotalPages} 页 +
+
+ + + + +
+
+ )} +
{/* 配置用户采集源权限弹窗 */} @@ -2523,11 +2739,595 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { message={alertModal.message} timer={alertModal.timer} showConfirm={alertModal.showConfirm} + onConfirm={alertModal.onConfirm} /> ); }; +// 私人影库配置组件 +const OpenListConfigComponent = ({ + config, + refreshConfig, +}: { + config: AdminConfig | null; + refreshConfig: () => Promise; +}) => { + const { alertModal, showAlert, hideAlert } = useAlertModal(); + const { isLoading, withLoading } = useLoadingState(); + const [enabled, setEnabled] = useState(false); + const [url, setUrl] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [rootPath, setRootPath] = useState('/'); + const [scanInterval, setScanInterval] = useState(0); + const [videos, setVideos] = useState([]); + const [refreshing, setRefreshing] = useState(false); + const [scanProgress, setScanProgress] = useState<{ + current: number; + total: number; + currentFolder?: string; + } | null>(null); + const [correctDialogOpen, setCorrectDialogOpen] = useState(false); + const [selectedVideo, setSelectedVideo] = useState(null); + + useEffect(() => { + if (config?.OpenListConfig) { + setEnabled(config.OpenListConfig.Enabled || false); + setUrl(config.OpenListConfig.URL || ''); + setUsername(config.OpenListConfig.Username || ''); + setPassword(config.OpenListConfig.Password || ''); + setRootPath(config.OpenListConfig.RootPath || '/'); + setScanInterval(config.OpenListConfig.ScanInterval || 0); + } + }, [config]); + + useEffect(() => { + if (config?.OpenListConfig?.URL && config?.OpenListConfig?.Username && config?.OpenListConfig?.Password) { + fetchVideos(); + } + }, [config]); + + const fetchVideos = async (noCache = false) => { + try { + setRefreshing(true); + const url = `/api/openlist/list?page=1&pageSize=100&includeFailed=true${noCache ? '&noCache=true' : ''}`; + const response = await fetch(url); + if (response.ok) { + const data = await response.json(); + setVideos(data.list || []); + } + } catch (error) { + console.error('获取视频列表失败:', error); + } finally { + setRefreshing(false); + } + }; + + const handleSave = async () => { + await withLoading('saveOpenList', async () => { + try { + const response = await fetch('/api/admin/openlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'save', + Enabled: enabled, + URL: url, + Username: username, + Password: password, + RootPath: rootPath, + ScanInterval: scanInterval, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '保存失败'); + } + + showSuccess('保存成功', showAlert); + await refreshConfig(); + } catch (error) { + showError(error instanceof Error ? error.message : '保存失败', showAlert); + throw error; + } + }); + }; + + const handleRefresh = async (clearMetaInfo = false) => { + setRefreshing(true); + setScanProgress(null); + try { + const response = await fetch('/api/openlist/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clearMetaInfo }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '刷新失败'); + } + + const result = await response.json(); + const taskId = result.taskId; + + if (!taskId) { + throw new Error('未获取到任务ID'); + } + + // 轮询任务进度 + const pollInterval = setInterval(async () => { + try { + const progressResponse = await fetch( + `/api/openlist/scan-progress?taskId=${taskId}` + ); + + if (!progressResponse.ok) { + clearInterval(pollInterval); + throw new Error('获取进度失败'); + } + + const progressData = await progressResponse.json(); + const task = progressData.task; + + if (task.status === 'running') { + setScanProgress(task.progress); + } else if (task.status === 'completed') { + clearInterval(pollInterval); + setScanProgress(null); + setRefreshing(false); + showSuccess( + `扫描完成!新增 ${task.result.new} 个,已存在 ${task.result.existing} 个,失败 ${task.result.errors} 个`, + showAlert + ); + // 先强制从数据库读取视频列表(这会更新缓存) + await fetchVideos(true); + // 然后再刷新配置(这会触发 useEffect,但此时缓存已经是新的了) + await refreshConfig(); + } else if (task.status === 'failed') { + clearInterval(pollInterval); + setScanProgress(null); + setRefreshing(false); + throw new Error(task.error || '扫描失败'); + } + } catch (error) { + clearInterval(pollInterval); + setScanProgress(null); + setRefreshing(false); + showError( + error instanceof Error ? error.message : '获取进度失败', + showAlert + ); + } + }, 1000); + } catch (error) { + setScanProgress(null); + setRefreshing(false); + showError(error instanceof Error ? error.message : '刷新失败', showAlert); + } + }; + + const handleRefreshVideo = async (folder: string) => { + try { + const response = await fetch('/api/openlist/refresh-video', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '刷新失败'); + } + + showSuccess('刷新成功', showAlert); + } catch (error) { + showError(error instanceof Error ? error.message : '刷新失败', showAlert); + } + }; + + const handleCorrectSuccess = () => { + fetchVideos(true); // 强制从数据库重新读取,不使用缓存 + }; + + const handleCheckConnectivity = async () => { + await withLoading('checkOpenList', async () => { + try { + const response = await fetch('/api/openlist/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url, + username, + password, + }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showSuccess('连接成功', showAlert); + } else { + throw new Error(data.error || '连接失败'); + } + } catch (error) { + showError(error instanceof Error ? error.message : '连接失败', showAlert); + throw error; + } + }); + }; + + const handleDeleteVideo = async (folder: string, title: string) => { + // 显示确认对话框,直接在 onConfirm 中执行删除操作 + showAlert({ + type: 'warning', + title: '确认删除', + message: `确定要删除视频记录"${title}"吗?此操作不会删除实际文件,只会从列表中移除。`, + showConfirm: true, + onConfirm: async () => { + try { + const response = await fetch('/api/openlist/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '删除失败'); + } + + showSuccess('删除成功', showAlert); + await fetchVideos(true); // 强制从数据库重新读取 + refreshConfig(); // 异步刷新配置以更新资源数量(不等待,避免重复刷新) + } catch (error) { + showError(error instanceof Error ? error.message : '删除失败', showAlert); + } + }, + }); + }; + + const formatDate = (timestamp?: number) => { + if (!timestamp) return '未刷新'; + return new Date(timestamp).toLocaleString('zh-CN'); + }; + + return ( +
+ {/* 使用说明 */} +
+
+ + + + + 使用说明 + +
+
+

• 私人影库功能需要配合 OpenList 使用,用于管理和播放您自己的视频文件

+

• OpenList 是一个开源的网盘聚合程序,支持多种存储后端(本地、阿里云盘、OneDrive 等)

+

• 配置后,系统会自动扫描指定目录下的视频文件夹,并通过 TMDB 匹配元数据信息

+

• 定时扫描间隔设置为 0 表示关闭自动扫描,最低间隔为 60 分钟

+

• 视频文件夹名称为影片名称,精准命名可以提高 TMDB 匹配准确率

+ +
+
+ + {/* 功能开关 */} +
+
+

+ 启用私人影库功能 +

+

+ 关闭后将不显示私人影库入口,也不会执行定时扫描 +

+
+ +
+ + {/* 配置表单 */} +
+
+ + setUrl(e.target.value)} + disabled={!enabled} + placeholder='https://your-openlist-server.com' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' + /> +
+ +
+
+ + setUsername(e.target.value)} + disabled={!enabled} + placeholder='admin' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' + /> +
+
+ + setPassword(e.target.value)} + disabled={!enabled} + placeholder='password' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' + /> +
+
+ +
+ + setRootPath(e.target.value)} + disabled={!enabled} + placeholder='/' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' + /> +

+ OpenList 中的视频文件夹路径,默认为根目录 / +

+
+ +
+ + setScanInterval(parseInt(e.target.value) || 0)} + disabled={!enabled} + placeholder='0' + min='0' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' + /> +

+ 设置为 0 关闭定时扫描,最低 60 分钟 +

+
+ +
+ + +
+
+ + {/* 视频列表区域 */} + {enabled && config?.OpenListConfig?.URL && config?.OpenListConfig?.Username && config?.OpenListConfig?.Password && ( +
+
+
+

+ 视频列表 +

+
+ 资源数: {config.OpenListConfig.ResourceCount || 0} + | + + 上次更新: {formatDate(config.OpenListConfig.LastRefreshTime)} + +
+
+
+ + +
+
+ + {refreshing && scanProgress && ( +
+
+ + 扫描进度: {scanProgress.current} / {scanProgress.total} + + + {scanProgress.total > 0 + ? Math.round((scanProgress.current / scanProgress.total) * 100) + : 0} + % + +
+
+
0 ? (scanProgress.current / scanProgress.total) * 100 : 0}%`, + }} + /> +
+ {scanProgress.currentFolder && ( +

+ 正在处理: {scanProgress.currentFolder} +

+ )} +
+ )} + + {refreshing ? ( +
+ 加载中... +
+ ) : videos.length > 0 ? ( +
+ + + + + + + + + + + + + {videos.map((video) => ( + + + + + + + + + ))} + +
+ 标题 + + 状态 + + 类型 + + 年份 + + 评分 + + 操作 +
+ {video.title} + + {video.failed ? ( + + 匹配失败 + + ) : ( + + 正常 + + )} + + {video.mediaType === 'movie' ? '电影' : '剧集'} + + {video.releaseDate ? video.releaseDate.split('-')[0] : '-'} + + {video.voteAverage > 0 ? video.voteAverage.toFixed(1) : '-'} + +
+ {!video.failed && ( + + )} + + +
+
+
+ ) : ( +
+ 暂无视频,请点击"立即扫描"扫描视频库 +
+ )} +
+ )} + + + + {/* 纠错对话框 */} + {selectedVideo && ( + setCorrectDialogOpen(false)} + folder={selectedVideo.folder} + currentTitle={selectedVideo.title} + onCorrect={handleCorrectSuccess} + /> + )} +
+ ); +}; + // 视频源配置组件 const VideoSourceConfig = ({ config, @@ -4601,6 +5401,10 @@ const SiteConfigComponent = ({ DanmakuApiBase: 'http://localhost:9321', DanmakuApiToken: '87654321', TMDBApiKey: '', + TMDBProxy: '', + PansouApiUrl: '', + PansouUsername: '', + PansouPassword: '', EnableComments: false, EnableRegistration: false, RegistrationRequireTurnstile: false, @@ -4684,6 +5488,10 @@ const SiteConfigComponent = ({ config.SiteConfig.DanmakuApiBase || 'http://localhost:9321', DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321', TMDBApiKey: config.SiteConfig.TMDBApiKey || '', + TMDBProxy: config.SiteConfig.TMDBProxy || '', + PansouApiUrl: config.SiteConfig.PansouApiUrl || '', + PansouUsername: config.SiteConfig.PansouUsername || '', + PansouPassword: config.SiteConfig.PansouPassword || '', EnableComments: config.SiteConfig.EnableComments || false, }); } @@ -5180,7 +5988,15 @@ const SiteConfigComponent = ({ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' />

- 弹幕服务器的 API 地址,默认为 http://localhost:9321 + 弹幕服务器的 API 地址,默认为 http://localhost:9321。API部署参考 + + danmu_api +

@@ -5242,6 +6058,109 @@ const SiteConfigComponent = ({

+ + {/* TMDB Proxy */} +
+ + + setSiteSettings((prev) => ({ + ...prev, + TMDBProxy: e.target.value, + })) + } + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' + /> +

+ 配置代理服务器地址,用于访问 TMDB API(可选) +

+
+ + + {/* Pansou 配置 */} +
+

+ Pansou 网盘搜索配置 +

+ + {/* Pansou API 地址 */} +
+ + + setSiteSettings((prev) => ({ + ...prev, + PansouApiUrl: e.target.value, + })) + } + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' + /> +

+ 配置 Pansou 服务器地址,用于网盘资源搜索。项目地址:{' '} + + https://github.com/fish2018/pansou + +

+
+ + {/* Pansou 账号 */} +
+ + + setSiteSettings((prev) => ({ + ...prev, + PansouUsername: e.target.value, + })) + } + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' + /> +

+ 如果 Pansou 服务启用了认证功能,需要提供账号密码 +

+
+ + {/* Pansou 密码 */} +
+ + + setSiteSettings((prev) => ({ + ...prev, + PansouPassword: e.target.value, + })) + } + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' + /> +

+ 配置账号密码后,系统会自动登录并缓存 Token +

+
{/* 评论功能配置 */} @@ -6984,6 +7903,7 @@ function AdminPageClient() { const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({ userConfig: false, videoSource: false, + openListConfig: false, liveSource: false, siteConfig: false, registrationConfig: false, @@ -7023,17 +7943,67 @@ function AdminPageClient() { } }, []); + // 新版本用户列表状态 + const [usersV2, setUsersV2] = useState | null>(null); + + // 用户列表分页状态 + const [userPage, setUserPage] = useState(1); + const [userTotalPages, setUserTotalPages] = useState(1); + const [userTotal, setUserTotal] = useState(0); + const [userListLoading, setUserListLoading] = useState(false); + const userLimit = 10; + + // 获取新版本用户列表 + const fetchUsersV2 = useCallback(async (page: number = 1) => { + try { + setUserListLoading(true); + const response = await fetch(`/api/admin/users?page=${page}&limit=${userLimit}`); + if (response.ok) { + const data = await response.json(); + setUsersV2(data.users); + setUserTotalPages(data.totalPages || 1); + setUserTotal(data.total || 0); + setUserPage(page); + } + } catch (err) { + console.error('获取新版本用户列表失败:', err); + } finally { + setUserListLoading(false); + } + }, []); + + // 刷新配置和用户列表 + const refreshConfigAndUsers = useCallback(async () => { + await fetchConfig(); + await fetchUsersV2(userPage); // 保持当前页码 + }, [fetchConfig, fetchUsersV2, userPage]); + useEffect(() => { // 首次加载时显示骨架 fetchConfig(true); + // 不再自动获取用户列表,等用户打开用户管理选项卡时再获取 }, [fetchConfig]); // 切换标签展开状态 const toggleTab = (tabKey: string) => { + const wasExpanded = expandedTabs[tabKey]; + setExpandedTabs((prev) => ({ ...prev, [tabKey]: !prev[tabKey], })); + + // 当打开用户管理选项卡时,如果还没有加载用户列表,则加载 + if (tabKey === 'userConfig' && !wasExpanded && !usersV2) { + fetchUsersV2(); + } }; // 新增: 重置配置处理函数 @@ -7182,7 +8152,13 @@ function AdminPageClient() { @@ -7210,6 +8186,18 @@ function AdminPageClient() { + {/* 私人影库配置标签 */} + + } + isExpanded={expandedTabs.openListConfig} + onToggle={() => toggleTab('openListConfig')} + > + + + {/* 分类配置标签 */} toggleTab('dataMigration')} > - + )} diff --git a/src/app/api/admin/config/route.ts b/src/app/api/admin/config/route.ts index 7544700..8fbe7eb 100644 --- a/src/app/api/admin/config/route.ts +++ b/src/app/api/admin/config/route.ts @@ -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 } + ); + } } } diff --git a/src/app/api/admin/data_migration/export/route.ts b/src/app/api/admin/data_migration/export/route.ts index 1ccb281..77ea112 100644 --- a/src/app/api/admin/data_migration/export/route.ts +++ b/src/app/api/admin/data_migration/export/route.ts @@ -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 { return null; } } + +// 辅助函数:获取V2用户的加密密码 +async function getUserPasswordV2(username: string): Promise { + 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; + } +} diff --git a/src/app/api/admin/data_migration/import/route.ts b/src/app/api/admin/data_migration/import/route.ts index 73aafcb..5d2651b 100644 --- a/src/app/api/admin/data_migration/import/route.ts +++ b/src/app/api/admin/data_migration/import/route.ts @@ -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 : '未知版本' }); diff --git a/src/app/api/admin/migrate-users/route.ts b/src/app/api/admin/migrate-users/route.ts new file mode 100644 index 0000000..59922c8 --- /dev/null +++ b/src/app/api/admin/migrate-users/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/admin/openlist/route.ts b/src/app/api/admin/openlist/route.ts new file mode 100644 index 0000000..78512ab --- /dev/null +++ b/src/app/api/admin/openlist/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts index 51cc10c..5ca0eb9 100644 --- a/src/app/api/admin/site/route.ts +++ b/src/app/api/admin/site/route.ts @@ -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, diff --git a/src/app/api/admin/source/route.ts b/src/app/api/admin/source/route.ts index 2efc3eb..f5c9ca4 100644 --- a/src/app/api/admin/source/route.ts +++ b/src/app/api/admin/source/route.ts @@ -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 }); } diff --git a/src/app/api/admin/user/route.ts b/src/app/api/admin/user/route.ts index 9473f25..bc07401 100644 --- a/src/app/api/admin/user/route.ts +++ b/src/app/api/admin/user/route.ts @@ -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: [] }); } } diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..b733627 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index 59a846e..80889c5 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -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); diff --git a/src/app/api/auth/oidc/complete-register/route.ts b/src/app/api/auth/oidc/complete-register/route.ts index c20da65..bd11e0f 100644 --- a/src/app/api/auth/oidc/complete-register/route.ts +++ b/src/app/api/auth/oidc/complete-register/route.ts @@ -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: '注册成功' }); diff --git a/src/app/api/change-password/route.ts b/src/app/api/change-password/route.ts index c9534f8..c566bb8 100644 --- a/src/app/api/change-password/route.ts +++ b/src/app/api/change-password/route.ts @@ -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) { diff --git a/src/app/api/cms-proxy/route.ts b/src/app/api/cms-proxy/route.ts index 2b29ef7..0008414 100644 --- a/src/app/api/cms-proxy/route.ts +++ b/src/app/api/cms-proxy/route.ts @@ -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, + }); +} diff --git a/src/app/api/cron/route.ts b/src/app/api/cron/route.ts index 31e4017..9fa6a02 100644 --- a/src/app/api/cron/route.ts +++ b/src/app/api/cron/route.ts @@ -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); + } +} diff --git a/src/app/api/danmaku-filter/route.ts b/src/app/api/danmaku-filter/route.ts index b973451..abe1282 100644 --- a/src/app/api/danmaku-filter/route.ts +++ b/src/app/api/danmaku-filter/route.ts @@ -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 }); } } diff --git a/src/app/api/danmaku/episodes/route.ts b/src/app/api/danmaku/episodes/route.ts index 112ee85..b28e064 100644 --- a/src/app/api/danmaku/episodes/route.ts +++ b/src/app/api/danmaku/episodes/route.ts @@ -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, { diff --git a/src/app/api/danmaku/match/route.ts b/src/app/api/danmaku/match/route.ts index 194a66c..972bf00 100644 --- a/src/app/api/danmaku/match/route.ts +++ b/src/app/api/danmaku/match/route.ts @@ -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, { diff --git a/src/app/api/danmaku/search/route.ts b/src/app/api/danmaku/search/route.ts index 670381a..ecda7ef 100644 --- a/src/app/api/danmaku/search/route.ts +++ b/src/app/api/danmaku/search/route.ts @@ -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, { diff --git a/src/app/api/debug/watch-room-config/route.ts b/src/app/api/debug/watch-room-config/route.ts deleted file mode 100644 index d21f201..0000000 --- a/src/app/api/debug/watch-room-config/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 59dba3b..f5e993d 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -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 }); } diff --git a/src/app/api/favorites/route.ts b/src/app/api/favorites/route.ts index 4bd7724..e28e637 100644 --- a/src/app/api/favorites/route.ts +++ b/src/app/api/favorites/route.ts @@ -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 }); } } diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts index 4967e95..b10c9d9 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -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 }); diff --git a/src/app/api/openlist/check/route.ts b/src/app/api/openlist/check/route.ts new file mode 100644 index 0000000..7e55479 --- /dev/null +++ b/src/app/api/openlist/check/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/openlist/correct/route.ts b/src/app/api/openlist/correct/route.ts new file mode 100644 index 0000000..beb6e95 --- /dev/null +++ b/src/app/api/openlist/correct/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/openlist/delete/route.ts b/src/app/api/openlist/delete/route.ts new file mode 100644 index 0000000..e3fc1f7 --- /dev/null +++ b/src/app/api/openlist/delete/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/openlist/detail/route.ts b/src/app/api/openlist/detail/route.ts new file mode 100644 index 0000000..68c91ee --- /dev/null +++ b/src/app/api/openlist/detail/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/openlist/list/route.ts b/src/app/api/openlist/list/route.ts new file mode 100644 index 0000000..ae32a3a --- /dev/null +++ b/src/app/api/openlist/list/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/openlist/play/route.ts b/src/app/api/openlist/play/route.ts new file mode 100644 index 0000000..95955b5 --- /dev/null +++ b/src/app/api/openlist/play/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/openlist/refresh-video/route.ts b/src/app/api/openlist/refresh-video/route.ts new file mode 100644 index 0000000..1c27877 --- /dev/null +++ b/src/app/api/openlist/refresh-video/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/openlist/refresh/route.ts b/src/app/api/openlist/refresh/route.ts new file mode 100644 index 0000000..d815371 --- /dev/null +++ b/src/app/api/openlist/refresh/route.ts @@ -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 { + 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; + } +} diff --git a/src/app/api/openlist/scan-progress/route.ts b/src/app/api/openlist/scan-progress/route.ts new file mode 100644 index 0000000..d55da41 --- /dev/null +++ b/src/app/api/openlist/scan-progress/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/pansou/search/route.ts b/src/app/api/pansou/search/route.ts new file mode 100644 index 0000000..7f10dff --- /dev/null +++ b/src/app/api/pansou/search/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/playrecords/route.ts b/src/app/api/playrecords/route.ts index 181ad0a..e1f3dbe 100644 --- a/src/app/api/playrecords/route.ts +++ b/src/app/api/playrecords/route.ts @@ -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 }); } } diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index d6c8fba..543df9f 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -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); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 229c62c..67c290b 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -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).value); - let flattenedResults = successResults.flat(); + let flattenedResults = [...openlistResults, ...successResults.flat()]; if (!config.SiteConfig.DisableYellowFilter) { flattenedResults = flattenedResults.filter((result) => { const typeName = result.type_name || ''; diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index 4d7c675..f404723 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -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({ diff --git a/src/app/api/searchhistory/route.ts b/src/app/api/searchhistory/route.ts index 9a9e717..9333e53 100644 --- a/src/app/api/searchhistory/route.ts +++ b/src/app/api/searchhistory/route.ts @@ -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 }); } } diff --git a/src/app/api/skipconfigs/route.ts b/src/app/api/skipconfigs/route.ts index 3762c4a..a011761 100644 --- a/src/app/api/skipconfigs/route.ts +++ b/src/app/api/skipconfigs/route.ts @@ -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 }); } } diff --git a/src/app/api/tmdb/search/route.ts b/src/app/api/tmdb/search/route.ts new file mode 100644 index 0000000..d6f7cfe --- /dev/null +++ b/src/app/api/tmdb/search/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/tmdb/upcoming/route.ts b/src/app/api/tmdb/upcoming/route.ts index aa9c743..5486d20 100644 --- a/src/app/api/tmdb/upcoming/route.ts +++ b/src/app/api/tmdb/upcoming/route.ts @@ -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( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3098d77..7b1cf68 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( diff --git a/src/app/page.tsx b/src/app/page.tsx index a6afaf3..c89965b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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; diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 049a8a3..3e08715 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -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(null); const danmakuSettingsRef = useRef(danmakuSettings); + // 弹幕热力图完全禁用开关(默认不禁用,即启用热力图功能) + const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(() => { + 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(() => { + 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([]); 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: '', + 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() { )} {/* 完结状态标识 */} - {detail && totalEpisodes > 1 && ( - - {isSeriesCompleted(detail) ? '已完结' : '连载中'} - - )} + {detail && totalEpisodes > 1 && (() => { + const status = getSeriesStatus(detail); + if (status === 'unknown') return null; + + return ( + + {status === 'completed' ? '已完结' : '连载中'} + + ); + })()} {/* 第二行:播放器和选集 */} @@ -4890,6 +4998,17 @@ function PlayPageClient() { > + {/* 网盘搜索按钮 */} + {/* 豆瓣评分显示 */} {doubanRating && doubanRating.value > 0 && (
@@ -5107,6 +5226,40 @@ function PlayPageClient() { }); }} /> + + {/* 网盘搜索弹窗 */} + {showPansouDialog && ( +
setShowPansouDialog(false)} + > +
e.stopPropagation()} + > + {/* 弹窗头部 */} +
+

+ 搜索网盘资源: {detail?.title || ''} +

+ +
+ + {/* 弹窗内容 */} +
+ +
+
+
+ )} ); } diff --git a/src/app/private-library/page.tsx b/src/app/private-library/page.tsx new file mode 100644 index 0000000..b7fb720 --- /dev/null +++ b/src/app/private-library/page.tsx @@ -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([]); + 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 ( + +
+
+

+ 私人影库 +

+

+ 观看自我收藏的高清视频吧 +

+
+ + {error && ( +
+

{error}

+
+ )} + + {loading ? ( +
+ {Array.from({ length: pageSize }).map((_, index) => ( +
+ ))} +
+ ) : videos.length === 0 ? ( +
+

+ 暂无视频,请在管理面板配置 OpenList 并刷新 +

+
+ ) : ( + <> +
+ {videos.map((video) => ( + + ))} +
+ + {/* 分页 */} + {totalPages > 1 && ( +
+ + + + 第 {page} / {totalPages} 页 + + + +
+ )} + + )} +
+ + ); +} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index e8166fa..f413af9 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -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([]); // 返回顶部按钮显示状态 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() { />
+ + {/* 选项卡 */} +
+ + +
{/* 搜索结果或搜索历史 */}
{showResults ? (
- {/* 标题 */} -
+ {activeTab === 'video' ? ( + <> + {/* 影视搜索结果 */} + {/* 标题 */} +

搜索结果 {isFromCache ? ( @@ -930,6 +977,21 @@ function SearchPageClient() { ))}

)} + + ) : ( + <> + {/* 网盘搜索结果 */} +
+

+ 网盘搜索结果 +

+
+ + + )}
) : searchHistory.length > 0 ? ( // 搜索历史 @@ -953,9 +1015,18 @@ function SearchPageClient() { +
+ + {/* 搜索框 */} +
+
+ 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' + /> + +
+ {error && ( +

{error}

+ )} +
+ + {/* 结果列表 */} +
+ {results.length === 0 ? ( +
+ {searching ? '搜索中...' : '请输入关键词搜索'} +
+ ) : ( +
+ {results.map((result) => ( +
+ {/* 海报 */} +
+ {result.poster_path ? ( + {result.title + ) : ( +
+ 无海报 +
+ )} +
+ + {/* 信息 */} +
+

+ {result.title || result.name} +

+

+ {result.media_type === 'movie' ? '电影' : '电视剧'} •{' '} + {result.release_date?.split('-')[0] || + result.first_air_date?.split('-')[0] || + '未知'}{' '} + • 评分: {result.vote_average.toFixed(1)} +

+

+ {result.overview || '暂无简介'} +

+
+ + {/* 选择按钮 */} +
+ +
+
+ ))} +
+ )} +
+ + , + document.body + ); +} diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index 182ae2b..7e13ce5 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -266,7 +266,8 @@ const EpisodeSelector: React.FC = ({ if ( !optimizationEnabled || // 若关闭测速则直接退出 activeTab !== 'sources' || - availableSources.length === 0 + availableSources.length === 0 || + currentSource === 'openlist' // 私人影库不进行测速 ) return; @@ -293,7 +294,7 @@ const EpisodeSelector: React.FC = ({ fetchVideoInfosInBatches(); // 依赖项保持与之前一致 - }, [activeTab, availableSources, getVideoInfo, optimizationEnabled, initialTestingCompleted]); + }, [activeTab, availableSources, getVideoInfo, optimizationEnabled, initialTestingCompleted, currentSource]); // 升序分页标签 const categoriesAsc = useMemo(() => { @@ -635,8 +636,8 @@ const EpisodeSelector: React.FC = ({ 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 = ({ {/* 重新测试按钮 */} {(() => { + // 私人影库不显示重新测试按钮 + if (source.source === 'openlist') { + return null; + } + const sourceKey = `${source.source}-${source.id}`; const isTesting = retestingSources.has(sourceKey); const videoInfo = videoInfoMap.get(sourceKey); diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index 3b15fe3..ed74e9c 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -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({ diff --git a/src/components/PansouSearch.tsx b/src/components/PansouSearch.tsx new file mode 100644 index 0000000..db0b702 --- /dev/null +++ b/src/components/PansouSearch.tsx @@ -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 = { + baidu: '百度网盘', + aliyun: '阿里云盘', + quark: '夸克网盘', + tianyi: '天翼云盘', + uc: 'UC网盘', + mobile: '移动云盘', + '115': '115网盘', + pikpak: 'PikPak', + xunlei: '迅雷网盘', + '123': '123网盘', + magnet: '磁力链接', + ed2k: '电驴链接', + others: '其他', +}; + +// 网盘类型颜色 +const CLOUD_TYPE_COLORS: Record = { + 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(null); + const [error, setError] = useState(null); + const [copiedUrl, setCopiedUrl] = useState(null); + const [selectedType, setSelectedType] = useState('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 ( +
+
+ +

+ 正在搜索网盘资源... +

+
+
+ ); + } + + if (error) { + return ( +
+
+ +

{error}

+
+
+ ); + } + + if (!results || results.total === 0 || !results.merged_by_type) { + return ( +
+
+ +

+ 未找到相关资源 +

+
+
+ ); + } + + 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 ( +
+ {/* 搜索结果统计 */} +
+ 找到 {results.total} 个资源 +
+ + {/* 网盘类型过滤器 */} +
+ + {typeStats.map(({ type, count }) => { + const typeName = CLOUD_TYPE_NAMES[type] || type; + const typeColor = CLOUD_TYPE_COLORS[type] || CLOUD_TYPE_COLORS.others; + + return ( + + ); + })} +
+ + {/* 按网盘类型分类显示 */} + {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 ( +
+ {/* 网盘类型标题 */} +
+ + {typeName} + + + {links.length} 个链接 + +
+ + {/* 链接列表 */} +
+ {links.map((link: PansouLink, index: number) => ( +
+ {/* 资源标题 */} + {link.note && ( +
+ {link.note} +
+ )} + + {/* 链接和密码 */} +
+
+
+ {link.url} +
+ {link.password && ( +
+ 提取码: {link.password} +
+ )} +
+ + {/* 操作按钮 */} +
+ + +
+
+ + {/* 来源和时间 */} +
+ {link.source && ( + 来源: {link.source} + )} + {link.datetime && ( + {new Date(link.datetime).toLocaleDateString()} + )} +
+ + {/* 图片预览 */} + {link.images && link.images.length > 0 && ( +
+ {link.images.map((img, imgIndex) => ( + + ))} +
+ )} +
+ ))} +
+
+ ); + })} +
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 12552e4..78a6d4a 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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({ diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 802fc81..a31c53b 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -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 = () => { + {/* 禁用弹幕热力 */} +
+
+

+ 禁用弹幕热力图 +

+

+ 完全关闭弹幕热力图功能以提升性能(需手动刷新页面生效) +

+
+ +
+ {/* 分割线 */}
diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 8160f6b..a7cbb76 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -659,7 +659,7 @@ const VideoCard = forwardRef(function VideoCard }} >
| 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 { 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 { 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 { } // 如果没有 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(); // 遍历用户的所有 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)); diff --git a/src/lib/db.ts b/src/lib/db.ts index 074c4cc..7bcfb2b 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -132,7 +132,7 @@ export class DbManager { return favorite !== null; } - // ---------- 用户相关 ---------- + // ---------- 用户相关(旧版本,保持兼容) ---------- async registerUser(userName: string, password: string): Promise { 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 { + 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 { + 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 { + if (typeof (this.storage as any).updateUserInfoV2 === 'function') { + await (this.storage as any).updateUserInfoV2(userName, updates); + } + } + + async changePasswordV2(userName: string, newPassword: string): Promise { + if (typeof (this.storage as any).changePasswordV2 === 'function') { + await (this.storage as any).changePasswordV2(userName, newPassword); + } + } + + async checkUserExistV2(userName: string): Promise { + if (typeof (this.storage as any).checkUserExistV2 === 'function') { + return (this.storage as any).checkUserExistV2(userName); + } + return false; + } + + async getUserByOidcSub(oidcSub: string): Promise { + 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 { + if (typeof (this.storage as any).deleteUserV2 === 'function') { + await (this.storage as any).deleteUserV2(userName); + } + } + + async getUsersByTag(tagName: string): Promise { + if (typeof (this.storage as any).getUsersByTag === 'function') { + return (this.storage as any).getUsersByTag(tagName); + } + return []; + } + + // ---------- 数据迁移 ---------- + async migrateUsersFromConfig(adminConfig: AdminConfig): Promise { + 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 { return this.storage.getSearchHistory(userName); diff --git a/src/lib/lock.ts b/src/lib/lock.ts new file mode 100644 index 0000000..e98f794 --- /dev/null +++ b/src/lib/lock.ts @@ -0,0 +1,96 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// 简单的内存锁管理器 +class LockManager { + private locks: Map 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; diff --git a/src/lib/openlist-cache.ts b/src/lib/openlist-cache.ts new file mode 100644 index 0000000..0f01899 --- /dev/null +++ b/src/lib/openlist-cache.ts @@ -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 = new Map(); +const VIDEOINFO_CACHE: Map = 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); +} diff --git a/src/lib/openlist.client.ts b/src/lib/openlist.client.ts new file mode 100644 index 0000000..7d4bdcc --- /dev/null +++ b/src/lib/openlist.client.ts @@ -0,0 +1,318 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// Token 内存缓存 +const tokenCache = new Map(); + +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 { + 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 { + 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 { + // 获取 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 { + 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 { + 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 { + 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 { + 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 { + 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 : '连接失败', + }; + } + } +} diff --git a/src/lib/pansou.client.ts b/src/lib/pansou.client.ts new file mode 100644 index 0000000..b928d3d --- /dev/null +++ b/src/lib/pansou.client.ts @@ -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 { + 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 { + // 如果没有配置账号密码,返回 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 { + try { + // 获取 Token(如果需要认证) + const token = await getValidToken( + apiUrl, + options?.username, + options?.password + ); + + // 构建请求头 + const headers: Record = { + '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 { + 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; + } +} diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index 98ba2a0..fe98a43 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -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 { + 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 { + const hashedPassword = await this.hashPassword(password); + const createdAt = Date.now(); + + // 存储用户信息到Hash + const userInfo: Record = { + 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 { + 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 { + const userInfo: Record = {}; + + 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 { + const hashedPassword = await this.hashPassword(newPassword); + await this.withRetry(() => + this.client.hSet(this.userInfoKey(userName), 'password', hashedPassword) + ); + } + + // 检查用户是否存在(新版本) + async checkUserExistV2(userName: string): Promise { + const exists = await this.withRetry(() => + this.client.exists(this.userInfoKey(userName)) + ); + return exists === 1; + } + + // 通过OIDC Sub查找用户名 + async getUserByOidcSub(oidcSub: string): Promise { + 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 { + // 获取用户信息 + 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))); + + // 从用���列表中移除 + 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 { - 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; } // ---------- 管理员配置 ---------- diff --git a/src/lib/scan-task.ts b/src/lib/scan-task.ts new file mode 100644 index 0000000..ec06c6b --- /dev/null +++ b/src/lib/scan-task.ts @@ -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(); + +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); + } + } +} diff --git a/src/lib/tmdb.client.ts b/src/lib/tmdb.client.ts index a72cc9e..d5f4ff6 100644 --- a/src/lib/tmdb.client.ts +++ b/src/lib/tmdb.client.ts @@ -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), ]); // 检查是否有错误 diff --git a/src/lib/tmdb.search.ts b/src/lib/tmdb.search.ts new file mode 100644 index 0000000..b35ec27 --- /dev/null +++ b/src/lib/tmdb.search.ts @@ -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}`; +} diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index a274f43..830f259 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -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 { + 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 { + // 先检查用户是否已存在(原子性检查) + 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 = { + 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 { + 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 { + const userInfo: Record = {}; + + 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 { + const hashedPassword = await this.hashPassword(newPassword); + await withRetry(() => + this.client.hset(this.userInfoKey(userName), { password: hashedPassword }) + ); + + // 清除缓存 + userInfoCache?.delete(userName); + } + + // 检查用户是否存在(新版本) + async checkUserExistV2(userName: string): Promise { + const exists = await withRetry(() => + this.client.exists(this.userInfoKey(userName)) + ); + return exists === 1; + } + + // 通过OIDC Sub查找用户名 + async getUserByOidcSub(oidcSub: string): Promise { + const userName = await withRetry(() => + this.client.get(this.oidcSubKey(oidcSub)) + ); + return userName ? ensureString(userName) : null; + } + + // 获取使用特定用户组的用户列表 + async getUsersByTag(tagName: string): Promise { + 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; + // 更���缓存 + 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 { + // 获取用户信息 + 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 { - 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; } // ---------- 管理员配置 ---------- diff --git a/src/lib/user-cache.ts b/src/lib/user-cache.ts new file mode 100644 index 0000000..f1ec2f3 --- /dev/null +++ b/src/lib/user-cache.ts @@ -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 = 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): 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 = 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; diff --git a/src/lib/version.ts b/src/lib/version.ts index 607ddb9..2888093 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,6 +1,6 @@ /* eslint-disable no-console */ -const CURRENT_VERSION = '203.2.2'; +const CURRENT_VERSION = '204.0.0'; // 导出当前版本号供其他地方使用 export { CURRENT_VERSION }; diff --git a/src/lib/video-parser.ts b/src/lib/video-parser.ts new file mode 100644 index 0000000..0f3d723 --- /dev/null +++ b/src/lib/video-parser.ts @@ -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 {}; +} diff --git a/src/types/parse-torrent-name.d.ts b/src/types/parse-torrent-name.d.ts new file mode 100644 index 0000000..9991c07 --- /dev/null +++ b/src/types/parse-torrent-name.d.ts @@ -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; +}