From db05ba7c318637161e53163400396fbd60e0e95a Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 20 Dec 2025 22:11:34 +0800 Subject: [PATCH 01/40] =?UTF-8?q?tmdb=E5=A2=9E=E5=8A=A0=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 20 +++++++++++++++++++ src/app/admin/page.tsx | 25 +++++++++++++++++++++++ src/app/api/admin/site/route.ts | 4 ++++ src/app/api/tmdb/upcoming/route.ts | 3 ++- src/lib/admin.types.ts | 1 + src/lib/config.ts | 1 + src/lib/tmdb.client.ts | 32 ++++++++++++++++++++---------- 8 files changed, 75 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 4527951..7040508 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7de938d..14e4b6f 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) @@ -1889,6 +1892,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'} @@ -3211,6 +3218,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'} @@ -7539,6 +7550,8 @@ snapshots: transitivePeerDependencies: - supports-color + agent-base@7.1.4: {} + aggregate-error@3.1.0: dependencies: clean-stack: 2.2.0 @@ -9088,6 +9101,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: {} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 39f6112..6744fa2 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -298,6 +298,7 @@ interface SiteConfig { DanmakuApiBase: string; DanmakuApiToken: string; TMDBApiKey?: string; + TMDBProxy?: string; EnableComments: boolean; EnableRegistration?: boolean; RegistrationRequireTurnstile?: boolean; @@ -4601,6 +4602,7 @@ const SiteConfigComponent = ({ DanmakuApiBase: 'http://localhost:9321', DanmakuApiToken: '87654321', TMDBApiKey: '', + TMDBProxy: '', EnableComments: false, EnableRegistration: false, RegistrationRequireTurnstile: false, @@ -4684,6 +4686,7 @@ const SiteConfigComponent = ({ config.SiteConfig.DanmakuApiBase || 'http://localhost:9321', DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321', TMDBApiKey: config.SiteConfig.TMDBApiKey || '', + TMDBProxy: config.SiteConfig.TMDBProxy || '', EnableComments: config.SiteConfig.EnableComments || false, }); } @@ -5242,6 +5245,28 @@ 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(可选) +

+
{/* 评论功能配置 */} diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts index 51cc10c..00d78e9 100644 --- a/src/app/api/admin/site/route.ts +++ b/src/app/api/admin/site/route.ts @@ -42,6 +42,7 @@ export async function POST(request: NextRequest) { DanmakuApiBase, DanmakuApiToken, TMDBApiKey, + TMDBProxy, EnableComments, CustomAdFilterCode, CustomAdFilterVersion, @@ -74,6 +75,7 @@ export async function POST(request: NextRequest) { DanmakuApiBase: string; DanmakuApiToken: string; TMDBApiKey?: string; + TMDBProxy?: string; EnableComments: boolean; CustomAdFilterCode?: string; CustomAdFilterVersion?: number; @@ -109,6 +111,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 +162,7 @@ export async function POST(request: NextRequest) { DanmakuApiBase, DanmakuApiToken, TMDBApiKey, + TMDBProxy, EnableComments, CustomAdFilterCode, CustomAdFilterVersion, 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/lib/admin.types.ts b/src/lib/admin.types.ts index f5b1aea..609939f 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -21,6 +21,7 @@ export interface AdminConfig { DanmakuApiToken: string; // TMDB配置 TMDBApiKey?: string; + TMDBProxy?: string; // 评论功能开关 EnableComments: boolean; // 自定义去广告代码 diff --git a/src/lib/config.ts b/src/lib/config.ts index ba7b782..91016cf 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -223,6 +223,7 @@ async function getInitConfig(configFile: string, subConfig: { DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321', // TMDB配置 TMDBApiKey: '', + TMDBProxy: '', // 评论功能开关 EnableComments: false, }, diff --git a/src/lib/tmdb.client.ts b/src/lib/tmdb.client.ts index a72cc9e..062ac81 100644 --- a/src/lib/tmdb.client.ts +++ b/src/lib/tmdb.client.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any,no-console */ +import { HttpsProxyAgent } from 'https-proxy-agent'; + export interface TMDBMovie { id: number; title: string; @@ -48,21 +50,24 @@ 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: RequestInit = proxy ? { agent: new HttpsProxyAgent(proxy) as any } : {}; + + const response = await fetch(url, fetchOptions); if (!response.ok) { console.error('TMDB API 请求失败:', response.status, response.statusText); @@ -85,11 +90,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,9 +104,10 @@ 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: RequestInit = proxy ? { agent: new HttpsProxyAgent(proxy) as any } : {}; + + const response = await fetch(url, fetchOptions); if (!response.ok) { console.error('TMDB TV API 请求失败:', response.status, response.statusText); @@ -121,10 +129,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 +143,8 @@ export async function getTMDBUpcomingContent( // 并行获取电影和电视剧数据 const [moviesResult, tvShowsResult] = await Promise.all([ - getTMDBUpcomingMovies(apiKey), - getTMDBUpcomingTVShows(apiKey), + getTMDBUpcomingMovies(apiKey, 1, 'CN', proxy), + getTMDBUpcomingTVShows(apiKey, 1, proxy), ]); // 检查是否有错误 From 3fd5d4144769dc6b1a846ca11aba981fdc4f37b1 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 20 Dec 2025 22:16:39 +0800 Subject: [PATCH 02/40] =?UTF-8?q?=E5=8D=B3=E5=B0=86=E4=B8=8A=E6=98=A0?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E5=AD=97=E4=BD=93=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/VideoCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 }} >
Date: Sat, 20 Dec 2025 22:41:56 +0800 Subject: [PATCH 03/40] =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AF=B9=E5=BC=B9=E5=B9=95api=20github?= =?UTF-8?q?=E7=9A=84=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 6744fa2..e415096 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -5183,7 +5183,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 +

From ec042b1231d8a99eff463a772c53a3e27762550d Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 21 Dec 2025 00:06:04 +0800 Subject: [PATCH 04/40] fix typeerror --- src/lib/tmdb.client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/tmdb.client.ts b/src/lib/tmdb.client.ts index 062ac81..848af7c 100644 --- a/src/lib/tmdb.client.ts +++ b/src/lib/tmdb.client.ts @@ -65,7 +65,7 @@ export async function getTMDBUpcomingMovies( } const url = `https://api.themoviedb.org/3/movie/upcoming?api_key=${apiKey}&language=zh-CN&page=${page}®ion=${region}`; - const fetchOptions: RequestInit = proxy ? { agent: new HttpsProxyAgent(proxy) as any } : {}; + const fetchOptions: any = proxy ? { agent: new HttpsProxyAgent(proxy) } : {}; const response = await fetch(url, fetchOptions); @@ -105,7 +105,7 @@ export async function getTMDBUpcomingTVShows( // 使用 on_the_air 接口获取正在播出的电视剧 const url = `https://api.themoviedb.org/3/tv/on_the_air?api_key=${apiKey}&language=zh-CN&page=${page}`; - const fetchOptions: RequestInit = proxy ? { agent: new HttpsProxyAgent(proxy) as any } : {}; + const fetchOptions: any = proxy ? { agent: new HttpsProxyAgent(proxy) } : {}; const response = await fetch(url, fetchOptions); From 3c55bc9d1ac6a30ee9e33d95309af689e75b1c4b Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 22 Dec 2025 00:42:27 +0800 Subject: [PATCH 05/40] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=A7=81=E4=BA=BA?= =?UTF-8?q?=E5=BD=B1=E8=A7=86=E5=BA=93=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 8 + src/app/admin/page.tsx | 293 ++++++++++++++++++++ src/app/api/admin/openlist/route.ts | 79 ++++++ src/app/api/admin/source/route.ts | 7 + src/app/api/cms-proxy/route.ts | 190 ++++++++++++- src/app/api/detail/route.ts | 96 ++++++- src/app/api/openlist/detail/route.ts | 228 +++++++++++++++ src/app/api/openlist/list/route.ts | 193 +++++++++++++ src/app/api/openlist/play/route.ts | 68 +++++ src/app/api/openlist/refresh-video/route.ts | 64 +++++ src/app/api/openlist/refresh/route.ts | 280 +++++++++++++++++++ src/app/api/search/ws/route.ts | 124 ++++++++- src/app/layout.tsx | 6 + src/app/private-library/page.tsx | 143 ++++++++++ src/components/EpisodeSelector.tsx | 4 +- src/components/MobileBottomNav.tsx | 11 +- src/components/Sidebar.tsx | 11 +- src/lib/admin.types.ts | 7 + src/lib/openlist-cache.ts | 96 +++++++ src/lib/openlist.client.ts | 125 +++++++++ src/lib/tmdb.client.ts | 41 ++- src/lib/tmdb.search.ts | 111 ++++++++ src/lib/video-parser.ts | 57 ++++ 24 files changed, 2233 insertions(+), 10 deletions(-) create mode 100644 src/app/api/admin/openlist/route.ts create mode 100644 src/app/api/openlist/detail/route.ts create mode 100644 src/app/api/openlist/list/route.ts create mode 100644 src/app/api/openlist/play/route.ts create mode 100644 src/app/api/openlist/refresh-video/route.ts create mode 100644 src/app/api/openlist/refresh/route.ts create mode 100644 src/app/private-library/page.tsx create mode 100644 src/lib/openlist-cache.ts create mode 100644 src/lib/openlist.client.ts create mode 100644 src/lib/tmdb.search.ts create mode 100644 src/lib/video-parser.ts diff --git a/package.json b/package.json index 7040508..c9a3f25 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "next": "^14.2.33", "next-pwa": "^5.6.0", "next-themes": "^0.4.6", + "parse-torrent-name": "^0.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14e4b6f..2855873 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + parse-torrent-name: + specifier: ^0.5.4 + version: 0.5.4 react: specifier: ^18.3.1 version: 18.3.1 @@ -4218,6 +4221,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==} @@ -10401,6 +10407,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 diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index e415096..58e0674 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2529,6 +2529,286 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { ); }; +// 私人影库配置组件 +const OpenListConfigComponent = ({ + config, + refreshConfig, +}: { + config: AdminConfig | null; + refreshConfig: () => Promise; +}) => { + const { alertModal, showAlert, hideAlert } = useAlertModal(); + const { isLoading, withLoading } = useLoadingState(); + const [url, setUrl] = useState(''); + const [token, setToken] = useState(''); + const [rootPath, setRootPath] = useState('/'); + const [videos, setVideos] = useState([]); + const [refreshing, setRefreshing] = useState(false); + + useEffect(() => { + if (config?.OpenListConfig) { + setUrl(config.OpenListConfig.URL || ''); + setToken(config.OpenListConfig.Token || ''); + setRootPath(config.OpenListConfig.RootPath || '/'); + } + }, [config]); + + useEffect(() => { + if (config?.OpenListConfig?.URL && config?.OpenListConfig?.Token) { + fetchVideos(); + } + }, [config]); + + const fetchVideos = async () => { + try { + setRefreshing(true); + const response = await fetch('/api/openlist/list?page=1&pageSize=100'); + 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', + URL: url, + Token: token, + RootPath: rootPath, + }), + }); + + 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 () => { + setRefreshing(true); + try { + const response = await fetch('/api/openlist/refresh', { + method: 'POST', + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '刷新失败'); + } + + const result = await response.json(); + showSuccess( + `刷新成功!新增 ${result.new} 个,已存在 ${result.existing} 个,失败 ${result.errors} 个`, + showAlert + ); + await refreshConfig(); + await fetchVideos(); + } catch (error) { + showError(error instanceof Error ? error.message : '刷新失败', showAlert); + } finally { + setRefreshing(false); + } + }; + + 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 formatDate = (timestamp?: number) => { + if (!timestamp) return '未刷新'; + return new Date(timestamp).toLocaleString('zh-CN'); + }; + + return ( +
+ {/* 配置表单 */} +
+
+ + setUrl(e.target.value)} + 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' + /> +
+ +
+ + setToken(e.target.value)} + placeholder='your-token' + 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' + /> +
+ +
+ + setRootPath(e.target.value)} + 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' + /> +

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

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

+ 视频列表 +

+
+ 资源数: {config.OpenListConfig.ResourceCount || 0} + | + + 上次更新: {formatDate(config.OpenListConfig.LastRefreshTime)} + +
+
+ +
+ + {refreshing ? ( +
+ 加载中... +
+ ) : videos.length > 0 ? ( +
+ + + + + + + + + + + + {videos.map((video) => ( + + + + + + + + ))} + +
+ 标题 + + 类型 + + 年份 + + 评分 + + 操作 +
+ {video.title} + + {video.mediaType === 'movie' ? '电影' : '剧集'} + + {video.releaseDate.split('-')[0]} + + {video.voteAverage.toFixed(1)} + + +
+
+ ) : ( +
+ 暂无视频,请点击"立即扫描"扫描视频库 +
+ )} +
+ )} + + +
+ ); +}; + // 视频源配置组件 const VideoSourceConfig = ({ config, @@ -7017,6 +7297,7 @@ function AdminPageClient() { const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({ userConfig: false, videoSource: false, + openListConfig: false, liveSource: false, siteConfig: false, registrationConfig: false, @@ -7231,6 +7512,18 @@ function AdminPageClient() { + {/* 私人影库配置标签 */} + + } + isExpanded={expandedTabs.openListConfig} + onToggle={() => toggleTab('openListConfig')} + > + + + {/* 直播源配置标签 */} u.username === username + ); + if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) { + return NextResponse.json({ error: '权限不足' }, { status: 401 }); + } + } + + if (action === 'save') { + // 保存配置 + if (!URL || !Token) { + return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); + } + + adminConfig.OpenListConfig = { + URL, + Token, + RootPath: RootPath || '/', + LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime, + ResourceCount: adminConfig.OpenListConfig?.ResourceCount, + }; + + 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/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/cms-proxy/route.ts b/src/app/api/cms-proxy/route.ts index 2b29ef7..15f634d 100644 --- a/src/app/api/cms-proxy/route.ts +++ b/src/app/api/cms-proxy/route.ts @@ -1,7 +1,16 @@ -/* 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 { 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 +30,11 @@ export async function GET(request: NextRequest) { ); } + // 特殊处理 openlist + if (apiUrl === 'openlist') { + return handleOpenListProxy(request); + } + // 构建完整的 API 请求 URL,包含所有查询参数 const targetUrl = new URL(apiUrl); @@ -237,3 +251,177 @@ 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.Token) { + return NextResponse.json( + { code: 0, msg: 'OpenList 未配置', list: [] }, + { status: 200 } + ); + } + + const rootPath = openListConfig.RootPath || '/'; + const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + + // 读取 metainfo.json + let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); + + if (!metaInfo) { + try { + const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; + const fileResponse = await client.getFile(metainfoPath); + + 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(); + metaInfo = JSON.parse(content); + setCachedMetaInfo(rootPath, metaInfo); + } + } catch (error) { + return NextResponse.json( + { code: 0, msg: 'metainfo.json 不存在', 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/detail/route.ts b/src/app/api/detail/route.ts index 59dba3b..21e3a41 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,100 @@ 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.URL || !openListConfig.Token) { + throw new Error('OpenList 未配置'); + } + + const rootPath = openListConfig.RootPath || '/'; + + // 1. 读取 metainfo.json 获取元数据 + let metaInfo: any = null; + try { + const { OpenListClient } = await import('@/lib/openlist.client'); + const { getCachedMetaInfo } = await import('@/lib/openlist-cache'); + const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); + + const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + metaInfo = getCachedMetaInfo(rootPath); + + if (!metaInfo) { + const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; + const fileResponse = await client.getFile(metainfoPath); + + 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(); + metaInfo = JSON.parse(content); + } + } + } catch (error) { + console.error('[Detail] 读取 metainfo.json 失败:', error); + } + + // 2. 调用 openlist detail API + const openlistResponse = await fetch( + `${request.headers.get('x-forwarded-proto') || 'http'}://${request.headers.get('host')}/api/openlist/detail?folder=${encodeURIComponent(id)}`, + { + headers: { + Cookie: request.headers.get('cookie') || '', + }, + } + ); + + if (!openlistResponse.ok) { + throw new Error('获取 OpenList 视频详情失败'); + } + + const openlistData = await openlistResponse.json(); + + if (!openlistData.success) { + throw new Error(openlistData.error || '获取视频详情失败'); + } + + // 3. 从 metainfo 中获取元数据 + const folderMeta = metaInfo?.folders?.[id]; + const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); + + // 转换为标准格式(使用懒加载 URL) + const result = { + source: 'openlist', + source_name: '私人影库', + id: openlistData.folder, + title: folderMeta?.title || openlistData.folder, + poster: folderMeta?.poster_path ? getTMDBImageUrl(folderMeta.poster_path) : '', + year: folderMeta?.release_date ? folderMeta.release_date.split('-')[0] : '', + douban_id: 0, + desc: folderMeta?.overview || '', + episodes: openlistData.episodes.map((ep: any) => + `/api/openlist/play?folder=${encodeURIComponent(openlistData.folder)}&fileName=${encodeURIComponent(ep.fileName)}` + ), + episodes_titles: openlistData.episodes.map((ep: any) => ep.title || `第${ep.episode}集`), + }; + + console.log('[Detail] result.episodes_titles:', result.episodes_titles); + + 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/openlist/detail/route.ts b/src/app/api/openlist/detail/route.ts new file mode 100644 index 0000000..d4b3648 --- /dev/null +++ b/src/app/api/openlist/detail/route.ts @@ -0,0 +1,228 @@ +/* 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.URL || !openListConfig.Token) { + return NextResponse.json({ error: 'OpenList 未配置' }, { status: 400 }); + } + + const rootPath = openListConfig.RootPath || '/'; + const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`; + const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + + // 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', + }; + } + + // 保存 videoinfo.json + const videoinfoPath = `${folderPath}/videoinfo.json`; + await client.uploadFile( + videoinfoPath, + JSON.stringify(videoInfo, null, 2) + ); + + // 缓存 + 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..ec2fd49 --- /dev/null +++ b/src/app/api/openlist/list/route.ts @@ -0,0 +1,193 @@ +/* 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 { + 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 + * 获取私人影库视频列表 + */ +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 config = await getConfig(); + const openListConfig = config.OpenListConfig; + + if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + return NextResponse.json( + { error: 'OpenList 未配置', list: [], total: 0 }, + { status: 200 } + ); + } + + const rootPath = openListConfig.RootPath || '/'; + const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + + // 读取 metainfo.json + let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); + + console.log('[OpenList List] 缓存检查:', { + rootPath, + hasCachedMetaInfo: !!metaInfo, + }); + + if (!metaInfo) { + try { + const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; + console.log('[OpenList List] 尝试读取 metainfo.json:', metainfoPath); + + const fileResponse = await client.getFile(metainfoPath); + console.log('[OpenList List] getFile 完整响应:', JSON.stringify(fileResponse, null, 2)); + + if (fileResponse.code === 200 && fileResponse.data.raw_url) { + console.log('[OpenList List] 使用 raw_url 获取文件内容'); + + const downloadUrl = fileResponse.data.raw_url; + console.log('[OpenList List] 下载 URL:', downloadUrl); + + 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', + }, + }); + console.log('[OpenList List] fetch 响应:', { + status: contentResponse.status, + ok: contentResponse.ok, + }); + + if (!contentResponse.ok) { + throw new Error(`获取文件内容失败: ${contentResponse.status}`); + } + + const content = await contentResponse.text(); + console.log('[OpenList List] 文件内容长度:', content.length); + console.log('[OpenList List] 文件内容预览:', content.substring(0, 200)); + + try { + metaInfo = JSON.parse(content); + console.log('[OpenList List] JSON 解析成功'); + console.log('[OpenList List] metaInfo 结构:', { + hasfolders: !!metaInfo?.folders, + foldersType: typeof metaInfo?.folders, + keys: metaInfo?.folders ? Object.keys(metaInfo.folders) : [], + }); + + // 验证数据结构 + if (!metaInfo || typeof metaInfo !== 'object') { + throw new Error('metaInfo 不是有效对象'); + } + if (!metaInfo.folders || typeof metaInfo.folders !== 'object') { + throw new Error('metaInfo.folders 不存在或不是对象'); + } + + console.log('[OpenList List] 解析成功,视频数量:', Object.keys(metaInfo.folders).length); + setCachedMetaInfo(rootPath, metaInfo); + } catch (parseError) { + console.error('[OpenList List] JSON 解析或验证失败:', parseError); + throw new Error(`JSON 解析失败: ${(parseError as Error).message}`); + } + } else { + console.error('[OpenList List] getFile 失败或无 sign:', { + code: fileResponse.code, + message: fileResponse.message, + data: fileResponse.data, + }); + throw new Error(`getFile 返回错误: code=${fileResponse.code}, message=${fileResponse.message}`); + } + } catch (error) { + console.error('[OpenList List] 读取 metainfo.json 失败:', error); + return NextResponse.json( + { + error: 'metainfo.json 读取失败', + details: (error as Error).message, + list: [], + total: 0, + }, + { status: 200 } + ); + } + } + + if (!metaInfo) { + console.error('[OpenList List] metaInfo 为 null'); + return NextResponse.json( + { error: '无数据', list: [], total: 0 }, + { status: 200 } + ); + } + + // 验证 metaInfo 结构 + if (!metaInfo.folders || typeof metaInfo.folders !== 'object') { + console.error('[OpenList List] metaInfo.folders 无效:', { + hasfolders: !!metaInfo.folders, + foldersType: typeof metaInfo.folders, + metaInfoKeys: Object.keys(metaInfo), + }); + return NextResponse.json( + { error: 'metainfo.json 结构无效', list: [], total: 0 }, + { status: 200 } + ); + } + + console.log('[OpenList List] 开始转换视频列表,视频数:', Object.keys(metaInfo.folders).length); + + // 转换为数组并分页 + const allVideos = Object.entries(metaInfo.folders).map( + ([folderName, info]) => ({ + id: folderName, + folder: folderName, + 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, + }) + ); + + // 按更新时间倒序排序 + 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..86cfdd0 --- /dev/null +++ b/src/app/api/openlist/play/route.ts @@ -0,0 +1,68 @@ +/* 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.URL || !openListConfig.Token) { + 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.Token); + + // 获取文件的播放链接 + 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..8e1228d --- /dev/null +++ b/src/app/api/openlist/refresh-video/route.ts @@ -0,0 +1,64 @@ +/* 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.URL || !openListConfig.Token) { + return NextResponse.json({ error: 'OpenList 未配置' }, { status: 400 }); + } + + const rootPath = openListConfig.RootPath || '/'; + const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folder}`; + const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + + // 删除 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..73e95ce --- /dev/null +++ b/src/app/api/openlist/refresh/route.ts @@ -0,0 +1,280 @@ +/* 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 { 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 config = await getConfig(); + const openListConfig = config.OpenListConfig; + + if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + 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 } + ); + } + + const rootPath = openListConfig.RootPath || '/'; + const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + + console.log('[OpenList Refresh] 开始刷新:', { + rootPath, + url: openListConfig.URL, + hasToken: !!openListConfig.Token, + }); + + // 1. 读取现有 metainfo.json (如果存在) + let existingMetaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); + + if (!existingMetaInfo) { + try { + const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; + console.log('[OpenList Refresh] 尝试读取现有 metainfo.json:', metainfoPath); + + const fileResponse = await client.getFile(metainfoPath); + console.log('[OpenList Refresh] getFile 完整响应:', JSON.stringify(fileResponse, null, 2)); + + if (fileResponse.code === 200 && fileResponse.data.raw_url) { + const downloadUrl = fileResponse.data.raw_url; + console.log('[OpenList Refresh] 下载 URL:', downloadUrl); + + 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', + }, + }); + + console.log('[OpenList Refresh] fetch 响应:', { + status: contentResponse.status, + ok: contentResponse.ok, + }); + + if (!contentResponse.ok) { + throw new Error(`下载失败: ${contentResponse.status}`); + } + + const content = await contentResponse.text(); + console.log('[OpenList Refresh] 文件内容:', { + length: content.length, + preview: content.substring(0, 300), + }); + + existingMetaInfo = JSON.parse(content); + console.log('[OpenList Refresh] 读取到现有数据:', { + hasfolders: !!existingMetaInfo?.folders, + foldersType: typeof existingMetaInfo?.folders, + videoCount: Object.keys(existingMetaInfo?.folders || {}).length, + }); + } + } catch (error) { + console.error('[OpenList Refresh] 读取 metainfo.json 失败:', error); + console.log('[OpenList Refresh] 将创建新文件'); + } + } else { + console.log('[OpenList Refresh] 使用缓存的 metainfo,视频数:', Object.keys(existingMetaInfo.folders).length); + } + + const metaInfo: MetaInfo = existingMetaInfo || { + folders: {}, + last_refresh: Date.now(), + }; + + // 确保 folders 对象存在 + if (!metaInfo.folders || typeof metaInfo.folders !== 'object') { + console.warn('[OpenList Refresh] metaInfo.folders 无效,重新初始化'); + metaInfo.folders = {}; + } + + console.log('[OpenList Refresh] metaInfo 初始化完成:', { + hasfolders: !!metaInfo.folders, + foldersType: typeof metaInfo.folders, + videoCount: Object.keys(metaInfo.folders).length, + }); + + // 2. 列出根目录下的所有文件夹 + const listResponse = await client.listDirectory(rootPath); + + if (listResponse.code !== 200) { + return NextResponse.json( + { error: 'OpenList 列表获取失败' }, + { status: 500 } + ); + } + + const folders = listResponse.data.content.filter((item) => item.is_dir); + + console.log('[OpenList Refresh] 找到文件夹:', { + total: folders.length, + names: folders.map(f => f.name), + }); + + // 3. 遍历文件夹,搜索 TMDB + let newCount = 0; + let errorCount = 0; + + for (const folder of folders) { + console.log('[OpenList Refresh] 处理文件夹:', folder.name); + + // 跳过已搜索过的文件夹 + if (metaInfo.folders[folder.name]) { + console.log('[OpenList Refresh] 跳过已存在的文件夹:', folder.name); + continue; + } + + try { + console.log('[OpenList Refresh] 搜索 TMDB:', folder.name); + // 搜索 TMDB + const searchResult = await searchTMDB( + tmdbApiKey, + folder.name, + tmdbProxy + ); + + console.log('[OpenList Refresh] TMDB 搜索结果:', { + folder: folder.name, + code: searchResult.code, + hasResult: !!searchResult.result, + }); + + 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(), + }; + + console.log('[OpenList Refresh] 添加成功:', { + folder: folder.name, + title: metaInfo.folders[folder.name].title, + }); + + newCount++; + } else { + console.warn(`[OpenList Refresh] TMDB 搜索失败: ${folder.name}`); + errorCount++; + } + + // 避免请求过快 + await new Promise((resolve) => setTimeout(resolve, 300)); + } catch (error) { + console.error(`[OpenList Refresh] 处理文件夹失败: ${folder.name}`, error); + errorCount++; + } + } + + // 4. 更新 metainfo.json + metaInfo.last_refresh = Date.now(); + + const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; + const metainfoContent = JSON.stringify(metaInfo, null, 2); + console.log('[OpenList Refresh] 上传 metainfo.json:', { + path: metainfoPath, + videoCount: Object.keys(metaInfo.folders).length, + contentLength: metainfoContent.length, + contentPreview: metainfoContent.substring(0, 300), + }); + + await client.uploadFile(metainfoPath, metainfoContent); + console.log('[OpenList Refresh] 上传成功'); + + // 验证上传:立即读取文件 + try { + console.log('[OpenList Refresh] 验证上传:读取文件'); + const verifyResponse = await client.getFile(metainfoPath); + if (verifyResponse.code === 200 && verifyResponse.data.raw_url) { + const downloadUrl = verifyResponse.data.raw_url; + const verifyContentResponse = 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 verifyContent = await verifyContentResponse.text(); + console.log('[OpenList Refresh] 验证读取成功:', { + contentLength: verifyContent.length, + contentPreview: verifyContent.substring(0, 300), + }); + + // 尝试解析 + const verifyParsed = JSON.parse(verifyContent); + console.log('[OpenList Refresh] 验证解析成功:', { + hasfolders: !!verifyParsed.folders, + foldersType: typeof verifyParsed.folders, + videoCount: Object.keys(verifyParsed.folders || {}).length, + }); + } + } catch (verifyError) { + console.error('[OpenList Refresh] 验证失败:', verifyError); + } + + // 5. 更新缓存 + invalidateMetaInfoCache(rootPath); + setCachedMetaInfo(rootPath, metaInfo); + console.log('[OpenList Refresh] 缓存已更新'); + + // 6. 更新配置 + config.OpenListConfig!.LastRefreshTime = Date.now(); + config.OpenListConfig!.ResourceCount = Object.keys(metaInfo.folders).length; + await db.saveAdminConfig(config); + + return NextResponse.json({ + success: true, + total: folders.length, + new: newCount, + existing: Object.keys(metaInfo.folders).length - newCount, + errors: errorCount, + last_refresh: metaInfo.last_refresh, + }); + } catch (error) { + console.error('刷新私人影库失败:', error); + return NextResponse.json( + { error: '刷新失败', details: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index 4d7c675..8ee868b 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -33,6 +33,9 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const apiSites = await getAvailableApiSites(authInfo.username); + // 检查是否配置了 OpenList + const hasOpenList = !!(config.OpenListConfig?.URL && config.OpenListConfig?.Token); + // 共享状态 let streamClosed = false; @@ -62,7 +65,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 +77,123 @@ export async function GET(request: NextRequest) { let completedSources = 0; const allResults: any[] = []; + // 搜索 OpenList(如果配置了) + if (hasOpenList) { + try { + const { getCachedMetaInfo } = await import('@/lib/openlist-cache'); + const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); + const { OpenListClient } = await import('@/lib/openlist.client'); + + const rootPath = config.OpenListConfig!.RootPath || '/'; + let metaInfo = getCachedMetaInfo(rootPath); + + // 如果没有缓存,尝试从 OpenList 读取 + if (!metaInfo) { + try { + const client = new OpenListClient( + config.OpenListConfig!.URL, + config.OpenListConfig!.Token + ); + const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; + const fileResponse = await client.getFile(metainfoPath); + + 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(); + metaInfo = JSON.parse(content); + } + } catch (error) { + console.error('[Search WS] 读取 metainfo.json 失败:', 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 +261,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/layout.tsx b/src/app/layout.tsx index 3098d77..0918ead 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,10 @@ export default async function RootLayout({ fluidSearch = config.SiteConfig.FluidSearch; enableComments = config.SiteConfig.EnableComments; tmdbApiKey = config.SiteConfig.TMDBApiKey || ''; + // 检查是否配置了 OpenList + openListEnabled = !!( + config.OpenListConfig?.URL && config.OpenListConfig?.Token + ); } // 将运行时配置注入到全局 window 对象,供客户端在运行时读取 @@ -103,6 +108,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/private-library/page.tsx b/src/app/private-library/page.tsx new file mode 100644 index 0000000..3f5b124 --- /dev/null +++ b/src/app/private-library/page.tsx @@ -0,0 +1,143 @@ +/* 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; + 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/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index 182ae2b..9a0b4d9 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -635,8 +635,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]; } 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/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/lib/admin.types.ts b/src/lib/admin.types.ts index 609939f..304b54d 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -91,6 +91,13 @@ export interface AdminConfig { cacheMinutes: number; // 缓存时间(分钟) cacheVersion: number; // CSS版本号(用于缓存控制) }; + OpenListConfig?: { + URL: string; // OpenList 服务器地址 + Token: string; // 认证 Token + RootPath: string; // 根目录路径,默认 "/" + LastRefreshTime?: number; // 上次刷新时间戳 + ResourceCount?: number; // 资源数量 + }; } export interface AdminConfigResult { diff --git a/src/lib/openlist-cache.ts b/src/lib/openlist-cache.ts new file mode 100644 index 0000000..d80182e --- /dev/null +++ b/src/lib/openlist-cache.ts @@ -0,0 +1,96 @@ +// 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 = 24 * 60 * 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; + }; + }; + 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..4f468e2 --- /dev/null +++ b/src/lib/openlist.client.ts @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +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 { + constructor( + private baseURL: string, + private token: string + ) {} + + private getHeaders() { + return { + Authorization: this.token, // 不带 bearer + 'Content-Type': 'application/json', + }; + } + + // 列出目录 + async listDirectory( + path: string, + page = 1, + perPage = 100 + ): Promise { + const response = await fetch(`${this.baseURL}/api/fs/list`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ + path, + password: '', + refresh: false, + 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 fetch(`${this.baseURL}/api/fs/get`, { + method: 'POST', + headers: 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 response = await fetch(`${this.baseURL}/api/fs/put`, { + method: 'PUT', + headers: { + Authorization: this.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}`); + } + } + + // 删除文件 + async deleteFile(path: string): Promise { + const dir = path.substring(0, path.lastIndexOf('/')) || '/'; + const fileName = path.substring(path.lastIndexOf('/') + 1); + + const response = await fetch(`${this.baseURL}/api/fs/remove`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ + names: [fileName], + dir: dir, + }), + }); + + if (!response.ok) { + throw new Error(`OpenList 删除失败: ${response.status}`); + } + } +} diff --git a/src/lib/tmdb.client.ts b/src/lib/tmdb.client.ts index 848af7c..5636a19 100644 --- a/src/lib/tmdb.client.ts +++ b/src/lib/tmdb.client.ts @@ -45,6 +45,29 @@ interface TMDBTVAiringTodayResponse { total_results: number; } +// 代理 agent 缓存,避免每次都创建新实例 +const proxyAgentCache = new Map>(); + +/** + * 获取或创建代理 agent(复用连接池) + */ +function getProxyAgent(proxy: string): HttpsProxyAgent { + if (!proxyAgentCache.has(proxy)) { + const agent = new HttpsProxyAgent(proxy, { + // 增加超时时间 + timeout: 30000, // 30秒 + // 保持连接活跃 + keepAlive: true, + keepAliveMsecs: 60000, // 60秒 + // 最大空闲连接数 + maxSockets: 10, + maxFreeSockets: 5, + }); + proxyAgentCache.set(proxy, agent); + } + return proxyAgentCache.get(proxy)!; +} + /** * 获取即将上映的电影 * @param apiKey - TMDB API Key @@ -65,7 +88,14 @@ export async function getTMDBUpcomingMovies( } 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) } : {}; + const fetchOptions: any = proxy + ? { + agent: getProxyAgent(proxy), + signal: AbortSignal.timeout(30000), + } + : { + signal: AbortSignal.timeout(15000), + }; const response = await fetch(url, fetchOptions); @@ -105,7 +135,14 @@ export async function getTMDBUpcomingTVShows( // 使用 on_the_air 接口获取正在播出的电视剧 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) } : {}; + const fetchOptions: any = proxy + ? { + agent: getProxyAgent(proxy), + signal: AbortSignal.timeout(30000), + } + : { + signal: AbortSignal.timeout(15000), + }; const response = await fetch(url, fetchOptions); diff --git a/src/lib/tmdb.search.ts b/src/lib/tmdb.search.ts new file mode 100644 index 0000000..c226baa --- /dev/null +++ b/src/lib/tmdb.search.ts @@ -0,0 +1,111 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { HttpsProxyAgent } from 'https-proxy-agent'; + +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; +} + +// 代理 agent 缓存,避免每次都创建新实例 +const proxyAgentCache = new Map>(); + +/** + * 获取或创建代理 agent(复用连接池) + */ +function getProxyAgent(proxy: string): HttpsProxyAgent { + if (!proxyAgentCache.has(proxy)) { + const agent = new HttpsProxyAgent(proxy, { + // 增加超时时间 + timeout: 30000, // 30秒 + // 保持连接活跃 + keepAlive: true, + keepAliveMsecs: 60000, // 60秒 + // 最大空闲连接数 + maxSockets: 10, + maxFreeSockets: 5, + }); + proxyAgentCache.set(proxy, agent); + } + return proxyAgentCache.get(proxy)!; +} + +/** + * 搜索 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: getProxyAgent(proxy), + // 设置请求超时(30秒) + signal: AbortSignal.timeout(30000), + } + : { + // 即使不用代理也设置超时 + signal: AbortSignal.timeout(15000), + }; + + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + console.error('TMDB 搜索失败:', response.status, response.statusText); + return { code: response.status, result: null }; + } + + const data: TMDBSearchResponse = await response.json(); + + // 过滤出电影和电视剧,取第一个结果 + 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/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 {}; +} From 7ca6379d931aefbdbf6f7aed5df019f5bd23fb85 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 22 Dec 2025 01:28:56 +0800 Subject: [PATCH 06/40] =?UTF-8?q?=E6=89=AB=E6=8F=8F=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=90=8E=E5=8F=B0=E6=89=AB=E6=8F=8F=EF=BC=8Copenlist?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=A2=9E=E5=8A=A0=E8=B4=A6=E5=8F=B7=E5=AF=86?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 195 ++++++++++++++-- src/app/api/admin/openlist/route.ts | 36 ++- src/app/api/cms-proxy/route.ts | 2 +- src/app/api/openlist/correct/route.ts | 131 +++++++++++ src/app/api/openlist/detail/route.ts | 7 +- src/app/api/openlist/list/route.ts | 40 ++-- src/app/api/openlist/play/route.ts | 7 +- src/app/api/openlist/refresh-video/route.ts | 7 +- src/app/api/openlist/refresh/route.ts | 134 +++++++++-- src/app/api/openlist/scan-progress/route.ts | 45 ++++ src/app/api/tmdb/search/route.ts | 98 ++++++++ src/app/private-library/page.tsx | 2 +- src/components/CorrectDialog.tsx | 234 ++++++++++++++++++++ src/lib/admin.types.ts | 2 + src/lib/openlist-cache.ts | 1 + src/lib/openlist.client.ts | 122 +++++++++- src/lib/scan-task.ts | 131 +++++++++++ src/types/parse-torrent-name.d.ts | 30 +++ 18 files changed, 1157 insertions(+), 67 deletions(-) create mode 100644 src/app/api/openlist/correct/route.ts create mode 100644 src/app/api/openlist/scan-progress/route.ts create mode 100644 src/app/api/tmdb/search/route.ts create mode 100644 src/components/CorrectDialog.tsx create mode 100644 src/lib/scan-task.ts create mode 100644 src/types/parse-torrent-name.d.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 58e0674..0dcbc12 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'; @@ -2541,14 +2542,25 @@ const OpenListConfigComponent = ({ const { isLoading, withLoading } = useLoadingState(); const [url, setUrl] = useState(''); const [token, setToken] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); const [rootPath, setRootPath] = useState('/'); 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) { setUrl(config.OpenListConfig.URL || ''); setToken(config.OpenListConfig.Token || ''); + setUsername(config.OpenListConfig.Username || ''); + setPassword(config.OpenListConfig.Password || ''); setRootPath(config.OpenListConfig.RootPath || '/'); } }, [config]); @@ -2562,7 +2574,7 @@ const OpenListConfigComponent = ({ const fetchVideos = async () => { try { setRefreshing(true); - const response = await fetch('/api/openlist/list?page=1&pageSize=100'); + const response = await fetch('/api/openlist/list?page=1&pageSize=100&includeFailed=true'); if (response.ok) { const data = await response.json(); setVideos(data.list || []); @@ -2584,6 +2596,8 @@ const OpenListConfigComponent = ({ action: 'save', URL: url, Token: token, + Username: username, + Password: password, RootPath: rootPath, }), }); @@ -2604,6 +2618,7 @@ const OpenListConfigComponent = ({ const handleRefresh = async () => { setRefreshing(true); + setScanProgress(null); try { const response = await fetch('/api/openlist/refresh', { method: 'POST', @@ -2615,16 +2630,59 @@ const OpenListConfigComponent = ({ } const result = await response.json(); - showSuccess( - `刷新成功!新增 ${result.new} 个,已存在 ${result.existing} 个,失败 ${result.errors} 个`, - showAlert - ); - await refreshConfig(); - await fetchVideos(); + 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 refreshConfig(); + await fetchVideos(); + } 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) { - showError(error instanceof Error ? error.message : '刷新失败', showAlert); - } finally { + setScanProgress(null); setRefreshing(false); + showError(error instanceof Error ? error.message : '刷新失败', showAlert); } }; @@ -2647,6 +2705,10 @@ const OpenListConfigComponent = ({ } }; + const handleCorrectSuccess = () => { + fetchVideos(); + }; + const formatDate = (timestamp?: number) => { if (!timestamp) return '未刷新'; return new Date(timestamp).toLocaleString('zh-CN'); @@ -2680,6 +2742,36 @@ const OpenListConfigComponent = ({ placeholder='your-token' 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' /> +

+ 可以直接填写Token,或使用下方账号密码登录获取 +

+
+ +
+
+ + setUsername(e.target.value)} + 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' + /> +
+
+ + setPassword(e.target.value)} + 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' + /> +
@@ -2734,6 +2826,35 @@ const OpenListConfigComponent = ({
+ {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 ? (
加载中... @@ -2746,6 +2867,9 @@ const OpenListConfigComponent = ({ 标题 + + 状态 + 类型 @@ -2762,26 +2886,50 @@ const OpenListConfigComponent = ({ {videos.map((video) => ( - + {video.title} + + {video.failed ? ( + + 匹配失败 + + ) : ( + + 正常 + + )} + {video.mediaType === 'movie' ? '电影' : '剧集'} - {video.releaseDate.split('-')[0]} + {video.releaseDate ? video.releaseDate.split('-')[0] : '-'} - {video.voteAverage.toFixed(1)} + {video.voteAverage > 0 ? video.voteAverage.toFixed(1) : '-'} - +
+ {!video.failed && ( + + )} + +
))} @@ -2805,6 +2953,17 @@ const OpenListConfigComponent = ({ timer={alertModal.timer} showConfirm={alertModal.showConfirm} /> + + {/* 纠错对话框 */} + {selectedVideo && ( + setCorrectDialogOpen(false)} + folder={selectedVideo.folder} + currentTitle={selectedVideo.title} + onCorrect={handleCorrectSuccess} + /> + )}
); }; diff --git a/src/app/api/admin/openlist/route.ts b/src/app/api/admin/openlist/route.ts index ab78d24..2311eb7 100644 --- a/src/app/api/admin/openlist/route.ts +++ b/src/app/api/admin/openlist/route.ts @@ -5,6 +5,7 @@ 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'; @@ -25,7 +26,7 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); - const { action, URL, Token, RootPath } = body; + const { action, URL, Token, Username, Password, RootPath } = body; const authInfo = getAuthInfoFromCookie(request); if (!authInfo || !authInfo.username) { @@ -48,13 +49,40 @@ export async function POST(request: NextRequest) { if (action === 'save') { // 保存配置 - if (!URL || !Token) { - return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); + if (!URL) { + return NextResponse.json({ error: '缺少URL参数' }, { status: 400 }); + } + + let finalToken = Token; + + // 如果没有Token但有账号密码,尝试登录获取Token + if (!finalToken && Username && Password) { + try { + console.log('[OpenList Config] 使用账号密码登录获取Token'); + finalToken = await OpenListClient.login(URL, Username, Password); + console.log('[OpenList Config] 登录成功,获取到Token'); + } catch (error) { + console.error('[OpenList Config] 登录失败:', error); + return NextResponse.json( + { error: '使用账号密码登录失败: ' + (error as Error).message }, + { status: 400 } + ); + } + } + + // 检查是否有Token + if (!finalToken) { + return NextResponse.json( + { error: '请提供Token或账号密码' }, + { status: 400 } + ); } adminConfig.OpenListConfig = { URL, - Token, + Token: finalToken, + Username: Username || undefined, + Password: Password || undefined, RootPath: RootPath || '/', LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime, ResourceCount: adminConfig.OpenListConfig?.ResourceCount, diff --git a/src/app/api/cms-proxy/route.ts b/src/app/api/cms-proxy/route.ts index 15f634d..31efcbc 100644 --- a/src/app/api/cms-proxy/route.ts +++ b/src/app/api/cms-proxy/route.ts @@ -291,7 +291,7 @@ async function handleOpenListProxy(request: NextRequest) { }, }); const content = await contentResponse.text(); - metaInfo = JSON.parse(content); + metaInfo = JSON.parse(content) as MetaInfo; setCachedMetaInfo(rootPath, metaInfo); } } catch (error) { diff --git a/src/app/api/openlist/correct/route.ts b/src/app/api/openlist/correct/route.ts new file mode 100644 index 0000000..cbea3cd --- /dev/null +++ b/src/app/api/openlist/correct/route.ts @@ -0,0 +1,131 @@ +/* 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 { + 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.URL || !openListConfig.Token) { + return NextResponse.json( + { error: 'OpenList 未配置' }, + { status: 400 } + ); + } + + const rootPath = openListConfig.RootPath || '/'; + const client = new OpenListClient( + openListConfig.URL, + openListConfig.Token, + openListConfig.Username, + openListConfig.Password + ); + + // 读取现有 metainfo.json + let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); + + if (!metaInfo) { + try { + const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; + const fileResponse = await client.getFile(metainfoPath); + + 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', + }, + }); + + if (!contentResponse.ok) { + throw new Error(`下载失败: ${contentResponse.status}`); + } + + const content = await contentResponse.text(); + metaInfo = JSON.parse(content); + } + } catch (error) { + console.error('[OpenList Correct] 读取 metainfo.json 失败:', error); + return NextResponse.json( + { error: 'metainfo.json 读取失败' }, + { 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.json + const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; + const metainfoContent = JSON.stringify(metaInfo, null, 2); + + await client.uploadFile(metainfoPath, 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/detail/route.ts b/src/app/api/openlist/detail/route.ts index d4b3648..118b3bd 100644 --- a/src/app/api/openlist/detail/route.ts +++ b/src/app/api/openlist/detail/route.ts @@ -41,7 +41,12 @@ export async function GET(request: NextRequest) { const rootPath = openListConfig.RootPath || '/'; const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`; - const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + const client = new OpenListClient( + openListConfig.URL, + openListConfig.Token, + openListConfig.Username, + openListConfig.Password + ); // 1. 尝试读取缓存的 videoinfo.json let videoInfo: VideoInfo | null = getCachedVideoInfo(folderPath); diff --git a/src/app/api/openlist/list/route.ts b/src/app/api/openlist/list/route.ts index ec2fd49..1fffba6 100644 --- a/src/app/api/openlist/list/route.ts +++ b/src/app/api/openlist/list/route.ts @@ -15,7 +15,7 @@ import { getTMDBImageUrl } from '@/lib/tmdb.search'; export const runtime = 'nodejs'; /** - * GET /api/openlist/list?page=1&pageSize=20 + * GET /api/openlist/list?page=1&pageSize=20&includeFailed=false * 获取私人影库视频列表 */ export async function GET(request: NextRequest) { @@ -28,6 +28,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const page = parseInt(searchParams.get('page') || '1'); const pageSize = parseInt(searchParams.get('pageSize') || '20'); + const includeFailed = searchParams.get('includeFailed') === 'true'; const config = await getConfig(); const openListConfig = config.OpenListConfig; @@ -40,7 +41,12 @@ export async function GET(request: NextRequest) { } const rootPath = openListConfig.RootPath || '/'; - const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + const client = new OpenListClient( + openListConfig.URL, + openListConfig.Token, + openListConfig.Username, + openListConfig.Password + ); // 读取 metainfo.json let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); @@ -153,19 +159,23 @@ export async function GET(request: NextRequest) { console.log('[OpenList List] 开始转换视频列表,视频数:', Object.keys(metaInfo.folders).length); // 转换为数组并分页 - const allVideos = Object.entries(metaInfo.folders).map( - ([folderName, info]) => ({ - id: folderName, - folder: folderName, - 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, - }) - ); + 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); diff --git a/src/app/api/openlist/play/route.ts b/src/app/api/openlist/play/route.ts index 86cfdd0..9e5fa9f 100644 --- a/src/app/api/openlist/play/route.ts +++ b/src/app/api/openlist/play/route.ts @@ -39,7 +39,12 @@ export async function GET(request: NextRequest) { const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`; const filePath = `${folderPath}/${fileName}`; - const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + const client = new OpenListClient( + openListConfig.URL, + openListConfig.Token, + openListConfig.Username, + openListConfig.Password + ); // 获取文件的播放链接 const fileResponse = await client.getFile(filePath); diff --git a/src/app/api/openlist/refresh-video/route.ts b/src/app/api/openlist/refresh-video/route.ts index 8e1228d..a33ef6c 100644 --- a/src/app/api/openlist/refresh-video/route.ts +++ b/src/app/api/openlist/refresh-video/route.ts @@ -36,7 +36,12 @@ export async function POST(request: NextRequest) { const rootPath = openListConfig.RootPath || '/'; const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folder}`; - const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + const client = new OpenListClient( + openListConfig.URL, + openListConfig.Token, + openListConfig.Username, + openListConfig.Password + ); // 删除 videoinfo.json const videoinfoPath = `${folderPath}/videoinfo.json`; diff --git a/src/app/api/openlist/refresh/route.ts b/src/app/api/openlist/refresh/route.ts index 73e95ce..3b3ec30 100644 --- a/src/app/api/openlist/refresh/route.ts +++ b/src/app/api/openlist/refresh/route.ts @@ -12,13 +12,20 @@ import { 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 { @@ -49,15 +56,67 @@ export async function POST(request: NextRequest) { ); } - const rootPath = openListConfig.RootPath || '/'; - const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + // 清理旧任务 + cleanupOldTasks(); - console.log('[OpenList Refresh] 开始刷新:', { - rootPath, - url: openListConfig.URL, - hasToken: !!openListConfig.Token, + // 创建后台任务 + const taskId = createScanTask(); + + // 启动后台扫描 + performScan( + taskId, + openListConfig.URL, + openListConfig.Token, + openListConfig.RootPath || '/', + tmdbApiKey, + tmdbProxy, + openListConfig.Username, + openListConfig.Password + ).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, + token: string, + rootPath: string, + tmdbApiKey: string, + tmdbProxy?: string, + username?: string, + password?: string +): Promise { + const client = new OpenListClient(url, token, username, password); + + console.log('[OpenList Refresh] 开始扫描:', { + taskId, + rootPath, + url, + hasToken: !!token, + }); + + // 立即更新进度,确保任务可被查询 + updateScanTaskProgress(taskId, 0, 0); + + try { // 1. 读取现有 metainfo.json (如果存在) let existingMetaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); @@ -132,10 +191,7 @@ export async function POST(request: NextRequest) { const listResponse = await client.listDirectory(rootPath); if (listResponse.code !== 200) { - return NextResponse.json( - { error: 'OpenList 列表获取失败' }, - { status: 500 } - ); + throw new Error('OpenList 列表获取失败'); } const folders = listResponse.data.content.filter((item) => item.is_dir); @@ -145,13 +201,20 @@ export async function POST(request: NextRequest) { names: folders.map(f => f.name), }); + // 更新任务进度 + updateScanTaskProgress(taskId, 0, folders.length); + // 3. 遍历文件夹,搜索 TMDB let newCount = 0; let errorCount = 0; - for (const folder of folders) { + for (let i = 0; i < folders.length; i++) { + const folder = folders[i]; console.log('[OpenList Refresh] 处理文件夹:', folder.name); + // 更新进度 + updateScanTaskProgress(taskId, i + 1, folders.length, folder.name); + // 跳过已搜索过的文件夹 if (metaInfo.folders[folder.name]) { console.log('[OpenList Refresh] 跳过已存在的文件夹:', folder.name); @@ -185,6 +248,7 @@ export async function POST(request: NextRequest) { vote_average: result.vote_average, media_type: result.media_type, last_updated: Date.now(), + failed: false, }; console.log('[OpenList Refresh] 添加成功:', { @@ -195,6 +259,18 @@ export async function POST(request: NextRequest) { newCount++; } else { console.warn(`[OpenList Refresh] TMDB 搜索失败: ${folder.name}`); + // 记录失败的文件夹 + 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++; } @@ -202,6 +278,18 @@ export async function POST(request: NextRequest) { 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++; } } @@ -258,23 +346,29 @@ export async function POST(request: NextRequest) { console.log('[OpenList Refresh] 缓存已更新'); // 6. 更新配置 + const config = await getConfig(); config.OpenListConfig!.LastRefreshTime = Date.now(); config.OpenListConfig!.ResourceCount = Object.keys(metaInfo.folders).length; await db.saveAdminConfig(config); - return NextResponse.json({ - success: true, + // 完成任务 + completeScanTask(taskId, { + total: folders.length, + new: newCount, + existing: Object.keys(metaInfo.folders).length - newCount, + errors: errorCount, + }); + + console.log('[OpenList Refresh] 扫描完成:', { + taskId, total: folders.length, new: newCount, existing: Object.keys(metaInfo.folders).length - newCount, errors: errorCount, - last_refresh: metaInfo.last_refresh, }); } catch (error) { - console.error('刷新私人影库失败:', error); - return NextResponse.json( - { error: '刷新失败', details: (error as Error).message }, - { status: 500 } - ); + 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/tmdb/search/route.ts b/src/app/api/tmdb/search/route.ts new file mode 100644 index 0000000..ca5fad8 --- /dev/null +++ b/src/app/api/tmdb/search/route.ts @@ -0,0 +1,98 @@ +/* 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'; + +export const runtime = 'nodejs'; + +// 代理 agent 缓存 +const proxyAgentCache = new Map>(); + +function getProxyAgent(proxy: string): HttpsProxyAgent { + if (!proxyAgentCache.has(proxy)) { + const agent = new HttpsProxyAgent(proxy, { + timeout: 30000, + keepAlive: true, + keepAliveMsecs: 60000, + maxSockets: 10, + maxFreeSockets: 5, + }); + proxyAgentCache.set(proxy, agent); + } + return proxyAgentCache.get(proxy)!; +} + +/** + * 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: getProxyAgent(tmdbProxy), + signal: AbortSignal.timeout(30000), + } + : { + signal: AbortSignal.timeout(15000), + }; + + const response = await fetch(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/private-library/page.tsx b/src/app/private-library/page.tsx index 3f5b124..e9ea9f3 100644 --- a/src/app/private-library/page.tsx +++ b/src/app/private-library/page.tsx @@ -11,6 +11,7 @@ import VideoCard from '@/components/VideoCard'; interface Video { id: string; folder: string; + tmdbId: number; title: string; poster: string; releaseDate: string; @@ -105,7 +106,6 @@ export default function PrivateLibraryPage() { title={video.title} poster={video.poster} year={video.releaseDate.split('-')[0]} - rating={video.voteAverage} from='search' /> ))} diff --git a/src/components/CorrectDialog.tsx b/src/components/CorrectDialog.tsx new file mode 100644 index 0000000..586ef5c --- /dev/null +++ b/src/components/CorrectDialog.tsx @@ -0,0 +1,234 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, no-console */ + +'use client'; + +import { Search, X } from 'lucide-react'; +import Image from 'next/image'; +import { useEffect, useState } from 'react'; + +import { getTMDBImageUrl } from '@/lib/tmdb.search'; +import { processImageUrl } from '@/lib/utils'; + +interface TMDBResult { + id: number; + title?: string; + name?: string; + poster_path: string | null; + release_date?: string; + first_air_date?: string; + overview: string; + vote_average: number; + media_type: 'movie' | 'tv'; +} + +interface CorrectDialogProps { + isOpen: boolean; + onClose: () => void; + folder: string; + currentTitle: string; + onCorrect: () => void; +} + +export default function CorrectDialog({ + isOpen, + onClose, + folder, + currentTitle, + onCorrect, +}: CorrectDialogProps) { + const [searchQuery, setSearchQuery] = useState(currentTitle); + const [searching, setSearching] = useState(false); + const [results, setResults] = useState([]); + const [error, setError] = useState(''); + const [correcting, setCorrecting] = useState(false); + + useEffect(() => { + if (isOpen) { + setSearchQuery(currentTitle); + setResults([]); + setError(''); + } + }, [isOpen, currentTitle]); + + const handleSearch = async () => { + if (!searchQuery.trim()) { + setError('请输入搜索关键词'); + return; + } + + setSearching(true); + setError(''); + setResults([]); + + try { + const response = await fetch( + `/api/tmdb/search?query=${encodeURIComponent(searchQuery)}` + ); + + if (!response.ok) { + throw new Error('搜索失败'); + } + + const data = await response.json(); + + if (data.success && data.results) { + setResults(data.results); + if (data.results.length === 0) { + setError('未找到匹配的结果'); + } + } else { + setError('搜索失败'); + } + } catch (err) { + console.error('搜索失败:', err); + setError('搜索失败,请重试'); + } finally { + setSearching(false); + } + }; + + const handleCorrect = async (result: TMDBResult) => { + setCorrecting(true); + try { + const response = await fetch('/api/openlist/correct', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + folder, + tmdbId: result.id, + title: result.title || result.name, + posterPath: result.poster_path, + releaseDate: result.release_date || result.first_air_date, + overview: result.overview, + voteAverage: result.vote_average, + mediaType: result.media_type, + }), + }); + + if (!response.ok) { + throw new Error('纠错失败'); + } + + onCorrect(); + onClose(); + } catch (err) { + console.error('纠错失败:', err); + setError('纠错失败,请重试'); + } finally { + setCorrecting(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* 头部 */} +
+

+ 纠错:{currentTitle} +

+ +
+ + {/* 搜索框 */} +
+
+ 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 || '暂无简介'} +

+
+ + {/* 选择按钮 */} +
+ +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 304b54d..8990ec1 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -94,6 +94,8 @@ export interface AdminConfig { OpenListConfig?: { URL: string; // OpenList 服务器地址 Token: string; // 认证 Token + Username?: string; // 账号(可选,用于登录获取Token) + Password?: string; // 密码(可选,用于登录获取Token) RootPath: string; // 根目录路径,默认 "/" LastRefreshTime?: number; // 上次刷新时间戳 ResourceCount?: number; // 资源数量 diff --git a/src/lib/openlist-cache.ts b/src/lib/openlist-cache.ts index d80182e..6f82558 100644 --- a/src/lib/openlist-cache.ts +++ b/src/lib/openlist-cache.ts @@ -27,6 +27,7 @@ export interface MetaInfo { vote_average: number; media_type: 'movie' | 'tv'; last_updated: number; + failed?: boolean; // 标记是否搜索失败 }; }; last_refresh: number; diff --git a/src/lib/openlist.client.ts b/src/lib/openlist.client.ts index 4f468e2..743c750 100644 --- a/src/lib/openlist.client.ts +++ b/src/lib/openlist.client.ts @@ -32,9 +32,94 @@ export interface OpenListGetResponse { export class OpenListClient { constructor( private baseURL: string, - private token: string + private token: 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 refreshToken(): Promise { + if (!this.username || !this.password) { + return false; + } + + try { + console.log('[OpenListClient] Token可能失效,尝试使用账号密码重新登录'); + this.token = await OpenListClient.login( + this.baseURL, + this.username, + this.password + ); + console.log('[OpenListClient] Token刷新成功'); + return true; + } catch (error) { + console.error('[OpenListClient] Token刷新失败:', error); + return false; + } + } + + /** + * 执行请求,如果401则尝试刷新Token后重试 + */ + private async fetchWithRetry( + url: string, + options: RequestInit, + retried = false + ): Promise { + const response = await fetch(url, options); + + // 如果是401且未重试过且有账号密码,尝试刷新Token后重试 + if (response.status === 401 && !retried && this.username && this.password) { + const refreshed = await this.refreshToken(); + if (refreshed) { + // 更新请求头中的Token + const newOptions = { + ...options, + headers: { + ...options.headers, + Authorization: this.token, + }, + }; + return this.fetchWithRetry(url, newOptions, true); + } + } + + return response; + } + private getHeaders() { return { Authorization: this.token, // 不带 bearer @@ -48,7 +133,7 @@ export class OpenListClient { page = 1, perPage = 100 ): Promise { - const response = await fetch(`${this.baseURL}/api/fs/list`, { + const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/list`, { method: 'POST', headers: this.getHeaders(), body: JSON.stringify({ @@ -69,7 +154,7 @@ export class OpenListClient { // 获取文件信息 async getFile(path: string): Promise { - const response = await fetch(`${this.baseURL}/api/fs/get`, { + const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/get`, { method: 'POST', headers: this.getHeaders(), body: JSON.stringify({ @@ -87,7 +172,7 @@ export class OpenListClient { // 上传文件 async uploadFile(path: string, content: string): Promise { - const response = await fetch(`${this.baseURL}/api/fs/put`, { + const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/put`, { method: 'PUT', headers: { Authorization: this.token, @@ -102,6 +187,33 @@ export class OpenListClient { 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: 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); + } } // 删除文件 @@ -109,7 +221,7 @@ export class OpenListClient { const dir = path.substring(0, path.lastIndexOf('/')) || '/'; const fileName = path.substring(path.lastIndexOf('/') + 1); - const response = await fetch(`${this.baseURL}/api/fs/remove`, { + const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/remove`, { method: 'POST', headers: this.getHeaders(), body: JSON.stringify({ 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/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; +} From 0450edf9bc1475e4c1f4b2f70ab7d558d70aa062 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 22 Dec 2025 20:58:14 +0800 Subject: [PATCH 07/40] =?UTF-8?q?=E4=BF=AE=E6=94=B9metainfo=E4=B8=BA?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E8=AF=BB=E5=8F=96=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8Dtmdb=E4=BB=A3=E7=90=86=E8=BF=9E=E6=8E=A5=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + pnpm-lock.yaml | 57 +++++++++++++++++++ src/app/api/cms-proxy/route.ts | 22 ++------ src/app/api/detail/route.ts | 27 +++------ src/app/api/openlist/correct/route.ts | 36 ++++-------- src/app/api/openlist/detail/route.ts | 9 +-- src/app/api/openlist/list/route.ts | 49 ++++------------ src/app/api/openlist/refresh/route.ts | 81 ++++++--------------------- src/app/api/search/ws/route.ts | 32 ++++------- src/app/api/tmdb/search/route.ts | 26 +++------ src/lib/openlist-cache.ts | 2 +- src/lib/tmdb.client.ts | 44 +++++---------- src/lib/tmdb.search.ts | 38 +++---------- 13 files changed, 154 insertions(+), 271 deletions(-) diff --git a/package.json b/package.json index c9a3f25..853b688 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "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", @@ -68,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 2855873..53c0307 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ 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 @@ -147,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 @@ -1565,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==} @@ -3007,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==} @@ -4082,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==} @@ -5057,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==} @@ -5282,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==} @@ -5334,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==} @@ -7228,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 @@ -8874,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): @@ -10249,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: {} @@ -11257,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 @@ -11501,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: {} @@ -11571,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/api/cms-proxy/route.ts b/src/app/api/cms-proxy/route.ts index 31efcbc..a11baf2 100644 --- a/src/app/api/cms-proxy/route.ts +++ b/src/app/api/cms-proxy/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; +import { db } from '@/lib/db'; import { OpenListClient } from '@/lib/openlist.client'; import { getCachedMetaInfo, @@ -273,30 +274,19 @@ async function handleOpenListProxy(request: NextRequest) { const rootPath = openListConfig.RootPath || '/'; const client = new OpenListClient(openListConfig.URL, openListConfig.Token); - // 读取 metainfo.json + // 读取 metainfo (从数据库或缓存) let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); if (!metaInfo) { try { - const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; - const fileResponse = await client.getFile(metainfoPath); - - 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(); - metaInfo = JSON.parse(content) as MetaInfo; + 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.json 不存在', list: [] }, + { code: 0, msg: 'metainfo 不存在', list: [] }, { status: 200 } ); } diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 21e3a41..66717fd 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -32,35 +32,24 @@ export async function GET(request: NextRequest) { const rootPath = openListConfig.RootPath || '/'; - // 1. 读取 metainfo.json 获取元数据 + // 1. 读取 metainfo 获取元数据 let metaInfo: any = null; try { - const { OpenListClient } = await import('@/lib/openlist.client'); - const { getCachedMetaInfo } = await import('@/lib/openlist-cache'); + const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache'); const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); + const { db } = await import('@/lib/db'); - const client = new OpenListClient(openListConfig.URL, openListConfig.Token); metaInfo = getCachedMetaInfo(rootPath); if (!metaInfo) { - const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; - const fileResponse = await client.getFile(metainfoPath); - - 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(); - metaInfo = JSON.parse(content); + const metainfoJson = await db.getGlobalValue('video.metainfo'); + if (metainfoJson) { + metaInfo = JSON.parse(metainfoJson); + setCachedMetaInfo(rootPath, metaInfo); } } } catch (error) { - console.error('[Detail] 读取 metainfo.json 失败:', error); + console.error('[Detail] 从数据库读取 metainfo 失败:', error); } // 2. 调用 openlist detail API diff --git a/src/app/api/openlist/correct/route.ts b/src/app/api/openlist/correct/route.ts index cbea3cd..8a27ea5 100644 --- a/src/app/api/openlist/correct/route.ts +++ b/src/app/api/openlist/correct/route.ts @@ -4,6 +4,7 @@ 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, @@ -53,35 +54,21 @@ export async function POST(request: NextRequest) { openListConfig.Password ); - // 读取现有 metainfo.json + // 读取现有 metainfo (从数据库或缓存) let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); if (!metaInfo) { try { - const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; - const fileResponse = await client.getFile(metainfoPath); + console.log('[OpenList Correct] 尝试从数据库读取 metainfo'); + const metainfoJson = await db.getGlobalValue('video.metainfo'); - 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', - }, - }); - - if (!contentResponse.ok) { - throw new Error(`下载失败: ${contentResponse.status}`); - } - - const content = await contentResponse.text(); - metaInfo = JSON.parse(content); + if (metainfoJson) { + metaInfo = JSON.parse(metainfoJson); } } catch (error) { - console.error('[OpenList Correct] 读取 metainfo.json 失败:', error); + console.error('[OpenList Correct] 从数据库读取 metainfo 失败:', error); return NextResponse.json( - { error: 'metainfo.json 读取失败' }, + { error: 'metainfo 读取失败' }, { status: 500 } ); } @@ -107,11 +94,10 @@ export async function POST(request: NextRequest) { failed: false, // 纠错后标记为成功 }; - // 保存 metainfo.json - const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; - const metainfoContent = JSON.stringify(metaInfo, null, 2); + // 保存 metainfo 到数据库 + const metainfoContent = JSON.stringify(metaInfo); - await client.uploadFile(metainfoPath, metainfoContent); + await db.setGlobalValue('video.metainfo', metainfoContent); // 更新缓存 invalidateMetaInfoCache(rootPath); diff --git a/src/app/api/openlist/detail/route.ts b/src/app/api/openlist/detail/route.ts index 118b3bd..1d40be3 100644 --- a/src/app/api/openlist/detail/route.ts +++ b/src/app/api/openlist/detail/route.ts @@ -125,14 +125,7 @@ export async function GET(request: NextRequest) { }; } - // 保存 videoinfo.json - const videoinfoPath = `${folderPath}/videoinfo.json`; - await client.uploadFile( - videoinfoPath, - JSON.stringify(videoInfo, null, 2) - ); - - // 缓存 + // 仅缓存到内存,不再持久化到 OpenList setCachedVideoInfo(folderPath, videoInfo); } diff --git a/src/app/api/openlist/list/route.ts b/src/app/api/openlist/list/route.ts index 1fffba6..e5e2db9 100644 --- a/src/app/api/openlist/list/route.ts +++ b/src/app/api/openlist/list/route.ts @@ -4,6 +4,7 @@ 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, @@ -48,7 +49,7 @@ export async function GET(request: NextRequest) { openListConfig.Password ); - // 读取 metainfo.json + // 读取 metainfo (从数据库或缓存) let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); console.log('[OpenList List] 缓存检查:', { @@ -58,40 +59,15 @@ export async function GET(request: NextRequest) { if (!metaInfo) { try { - const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; - console.log('[OpenList List] 尝试读取 metainfo.json:', metainfoPath); + console.log('[OpenList List] 尝试从数据库读取 metainfo'); - const fileResponse = await client.getFile(metainfoPath); - console.log('[OpenList List] getFile 完整响应:', JSON.stringify(fileResponse, null, 2)); + const metainfoJson = await db.getGlobalValue('video.metainfo'); - if (fileResponse.code === 200 && fileResponse.data.raw_url) { - console.log('[OpenList List] 使用 raw_url 获取文件内容'); - - const downloadUrl = fileResponse.data.raw_url; - console.log('[OpenList List] 下载 URL:', downloadUrl); - - 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', - }, - }); - console.log('[OpenList List] fetch 响应:', { - status: contentResponse.status, - ok: contentResponse.ok, - }); - - if (!contentResponse.ok) { - throw new Error(`获取文件内容失败: ${contentResponse.status}`); - } - - const content = await contentResponse.text(); - console.log('[OpenList List] 文件内容长度:', content.length); - console.log('[OpenList List] 文件内容预览:', content.substring(0, 200)); + if (metainfoJson) { + console.log('[OpenList List] 从数据库获取到数据,长度:', metainfoJson.length); try { - metaInfo = JSON.parse(content); + metaInfo = JSON.parse(metainfoJson); console.log('[OpenList List] JSON 解析成功'); console.log('[OpenList List] metaInfo 结构:', { hasfolders: !!metaInfo?.folders, @@ -114,18 +90,13 @@ export async function GET(request: NextRequest) { throw new Error(`JSON 解析失败: ${(parseError as Error).message}`); } } else { - console.error('[OpenList List] getFile 失败或无 sign:', { - code: fileResponse.code, - message: fileResponse.message, - data: fileResponse.data, - }); - throw new Error(`getFile 返回错误: code=${fileResponse.code}, message=${fileResponse.message}`); + throw new Error('数据库中没有 metainfo 数据'); } } catch (error) { - console.error('[OpenList List] 读取 metainfo.json 失败:', error); + console.error('[OpenList List] 从数据库读取 metainfo 失败:', error); return NextResponse.json( { - error: 'metainfo.json 读取失败', + error: 'metainfo 读取失败', details: (error as Error).message, list: [], total: 0, diff --git a/src/app/api/openlist/refresh/route.ts b/src/app/api/openlist/refresh/route.ts index 3b3ec30..409a070 100644 --- a/src/app/api/openlist/refresh/route.ts +++ b/src/app/api/openlist/refresh/route.ts @@ -117,54 +117,25 @@ async function performScan( updateScanTaskProgress(taskId, 0, 0); try { - // 1. 读取现有 metainfo.json (如果存在) + // 1. 读取现有 metainfo (从数据库或缓存) let existingMetaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); if (!existingMetaInfo) { try { - const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; - console.log('[OpenList Refresh] 尝试读取现有 metainfo.json:', metainfoPath); + console.log('[OpenList Refresh] 尝试从数据库读取 metainfo'); + const metainfoJson = await db.getGlobalValue('video.metainfo'); - const fileResponse = await client.getFile(metainfoPath); - console.log('[OpenList Refresh] getFile 完整响应:', JSON.stringify(fileResponse, null, 2)); - - if (fileResponse.code === 200 && fileResponse.data.raw_url) { - const downloadUrl = fileResponse.data.raw_url; - console.log('[OpenList Refresh] 下载 URL:', downloadUrl); - - 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', - }, - }); - - console.log('[OpenList Refresh] fetch 响应:', { - status: contentResponse.status, - ok: contentResponse.ok, - }); - - if (!contentResponse.ok) { - throw new Error(`下载失败: ${contentResponse.status}`); - } - - const content = await contentResponse.text(); - console.log('[OpenList Refresh] 文件内容:', { - length: content.length, - preview: content.substring(0, 300), - }); - - existingMetaInfo = JSON.parse(content); - console.log('[OpenList Refresh] 读取到现有数据:', { + if (metainfoJson) { + existingMetaInfo = JSON.parse(metainfoJson); + console.log('[OpenList Refresh] 从数据库读取到现有数据:', { hasfolders: !!existingMetaInfo?.folders, foldersType: typeof existingMetaInfo?.folders, videoCount: Object.keys(existingMetaInfo?.folders || {}).length, }); } } catch (error) { - console.error('[OpenList Refresh] 读取 metainfo.json 失败:', error); - console.log('[OpenList Refresh] 将创建新文件'); + console.error('[OpenList Refresh] 从数据库读取 metainfo 失败:', error); + console.log('[OpenList Refresh] 将创建新数据'); } } else { console.log('[OpenList Refresh] 使用缓存的 metainfo,视频数:', Object.keys(existingMetaInfo.folders).length); @@ -294,41 +265,23 @@ async function performScan( } } - // 4. 更新 metainfo.json + // 4. 保存 metainfo 到数据库 metaInfo.last_refresh = Date.now(); - const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; - const metainfoContent = JSON.stringify(metaInfo, null, 2); - console.log('[OpenList Refresh] 上传 metainfo.json:', { - path: metainfoPath, + const metainfoContent = JSON.stringify(metaInfo); + console.log('[OpenList Refresh] 保存 metainfo 到数据库:', { videoCount: Object.keys(metaInfo.folders).length, contentLength: metainfoContent.length, - contentPreview: metainfoContent.substring(0, 300), }); - await client.uploadFile(metainfoPath, metainfoContent); - console.log('[OpenList Refresh] 上传成功'); + await db.setGlobalValue('video.metainfo', metainfoContent); + console.log('[OpenList Refresh] 保存成功'); - // 验证上传:立即读取文件 + // 验证保存:立即读取数据库 try { - console.log('[OpenList Refresh] 验证上传:读取文件'); - const verifyResponse = await client.getFile(metainfoPath); - if (verifyResponse.code === 200 && verifyResponse.data.raw_url) { - const downloadUrl = verifyResponse.data.raw_url; - const verifyContentResponse = 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 verifyContent = await verifyContentResponse.text(); - console.log('[OpenList Refresh] 验证读取成功:', { - contentLength: verifyContent.length, - contentPreview: verifyContent.substring(0, 300), - }); - - // 尝试解析 + console.log('[OpenList Refresh] 验证保存:读取数据库'); + const verifyContent = await db.getGlobalValue('video.metainfo'); + if (verifyContent) { const verifyParsed = JSON.parse(verifyContent); console.log('[OpenList Refresh] 验证解析成功:', { hasfolders: !!verifyParsed.folders, diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index 8ee868b..0747b17 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -80,37 +80,25 @@ export async function GET(request: NextRequest) { // 搜索 OpenList(如果配置了) if (hasOpenList) { try { - const { getCachedMetaInfo } = await import('@/lib/openlist-cache'); + const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache'); const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); - const { OpenListClient } = await import('@/lib/openlist.client'); + const { db } = await import('@/lib/db'); const rootPath = config.OpenListConfig!.RootPath || '/'; let metaInfo = getCachedMetaInfo(rootPath); - // 如果没有缓存,尝试从 OpenList 读取 + // 如果没有缓存,尝试从数据库读取 if (!metaInfo) { try { - const client = new OpenListClient( - config.OpenListConfig!.URL, - config.OpenListConfig!.Token - ); - const metainfoPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}metainfo.json`; - const fileResponse = await client.getFile(metainfoPath); - - 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(); - metaInfo = JSON.parse(content); + 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.json 失败:', error); + console.error('[Search WS] 从数据库读取 metainfo 失败:', error); } } diff --git a/src/app/api/tmdb/search/route.ts b/src/app/api/tmdb/search/route.ts index ca5fad8..d6f7cfe 100644 --- a/src/app/api/tmdb/search/route.ts +++ b/src/app/api/tmdb/search/route.ts @@ -5,26 +5,10 @@ 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'; -// 代理 agent 缓存 -const proxyAgentCache = new Map>(); - -function getProxyAgent(proxy: string): HttpsProxyAgent { - if (!proxyAgentCache.has(proxy)) { - const agent = new HttpsProxyAgent(proxy, { - timeout: 30000, - keepAlive: true, - keepAliveMsecs: 60000, - maxSockets: 10, - maxFreeSockets: 5, - }); - proxyAgentCache.set(proxy, agent); - } - return proxyAgentCache.get(proxy)!; -} - /** * GET /api/tmdb/search?query=xxx * 搜索TMDB,返回多个结果供用户选择 @@ -59,14 +43,18 @@ export async function GET(request: NextRequest) { const fetchOptions: any = tmdbProxy ? { - agent: getProxyAgent(tmdbProxy), + agent: new HttpsProxyAgent(tmdbProxy, { + timeout: 30000, + keepAlive: false, + }), signal: AbortSignal.timeout(30000), } : { signal: AbortSignal.timeout(15000), }; - const response = await fetch(url, fetchOptions); + // 使用 node-fetch 而不是原生 fetch + const response = await nodeFetch(url, fetchOptions); if (!response.ok) { console.error('TMDB 搜索失败:', response.status, response.statusText); diff --git a/src/lib/openlist-cache.ts b/src/lib/openlist-cache.ts index 6f82558..1b8240e 100644 --- a/src/lib/openlist-cache.ts +++ b/src/lib/openlist-cache.ts @@ -11,7 +11,7 @@ interface VideoInfoCacheEntry { } const METAINFO_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7天 -const VIDEOINFO_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1天 +const VIDEOINFO_CACHE_TTL_MS = 60 * 60 * 1000; // 1小时 const METAINFO_CACHE: Map = new Map(); const VIDEOINFO_CACHE: Map = new Map(); diff --git a/src/lib/tmdb.client.ts b/src/lib/tmdb.client.ts index 5636a19..d5f4ff6 100644 --- a/src/lib/tmdb.client.ts +++ b/src/lib/tmdb.client.ts @@ -1,6 +1,7 @@ /* 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; @@ -45,29 +46,6 @@ interface TMDBTVAiringTodayResponse { total_results: number; } -// 代理 agent 缓存,避免每次都创建新实例 -const proxyAgentCache = new Map>(); - -/** - * 获取或创建代理 agent(复用连接池) - */ -function getProxyAgent(proxy: string): HttpsProxyAgent { - if (!proxyAgentCache.has(proxy)) { - const agent = new HttpsProxyAgent(proxy, { - // 增加超时时间 - timeout: 30000, // 30秒 - // 保持连接活跃 - keepAlive: true, - keepAliveMsecs: 60000, // 60秒 - // 最大空闲连接数 - maxSockets: 10, - maxFreeSockets: 5, - }); - proxyAgentCache.set(proxy, agent); - } - return proxyAgentCache.get(proxy)!; -} - /** * 获取即将上映的电影 * @param apiKey - TMDB API Key @@ -90,21 +68,25 @@ export async function getTMDBUpcomingMovies( const url = `https://api.themoviedb.org/3/movie/upcoming?api_key=${apiKey}&language=zh-CN&page=${page}®ion=${region}`; const fetchOptions: any = proxy ? { - agent: getProxyAgent(proxy), + agent: new HttpsProxyAgent(proxy, { + timeout: 30000, + keepAlive: false, + }), signal: AbortSignal.timeout(30000), } : { signal: AbortSignal.timeout(15000), }; - const response = await fetch(url, fetchOptions); + // 使用 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, @@ -137,21 +119,25 @@ export async function getTMDBUpcomingTVShows( const url = `https://api.themoviedb.org/3/tv/on_the_air?api_key=${apiKey}&language=zh-CN&page=${page}`; const fetchOptions: any = proxy ? { - agent: getProxyAgent(proxy), + agent: new HttpsProxyAgent(proxy, { + timeout: 30000, + keepAlive: false, + }), signal: AbortSignal.timeout(30000), } : { signal: AbortSignal.timeout(15000), }; - const response = await fetch(url, fetchOptions); + // 使用 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, diff --git a/src/lib/tmdb.search.ts b/src/lib/tmdb.search.ts index c226baa..b35ec27 100644 --- a/src/lib/tmdb.search.ts +++ b/src/lib/tmdb.search.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { HttpsProxyAgent } from 'https-proxy-agent'; +import nodeFetch from 'node-fetch'; export interface TMDBSearchResult { id: number; @@ -21,29 +22,6 @@ interface TMDBSearchResponse { total_results: number; } -// 代理 agent 缓存,避免每次都创建新实例 -const proxyAgentCache = new Map>(); - -/** - * 获取或创建代理 agent(复用连接池) - */ -function getProxyAgent(proxy: string): HttpsProxyAgent { - if (!proxyAgentCache.has(proxy)) { - const agent = new HttpsProxyAgent(proxy, { - // 增加超时时间 - timeout: 30000, // 30秒 - // 保持连接活跃 - keepAlive: true, - keepAliveMsecs: 60000, // 60秒 - // 最大空闲连接数 - maxSockets: 10, - maxFreeSockets: 5, - }); - proxyAgentCache.set(proxy, agent); - } - return proxyAgentCache.get(proxy)!; -} - /** * 搜索 TMDB (电影+电视剧) */ @@ -59,26 +37,28 @@ export async function searchTMDB( // 使用 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: getProxyAgent(proxy), - // 设置请求超时(30秒) + agent: new HttpsProxyAgent(proxy, { + timeout: 30000, + keepAlive: false, + }), signal: AbortSignal.timeout(30000), } : { - // 即使不用代理也设置超时 signal: AbortSignal.timeout(15000), }; - const response = await fetch(url, fetchOptions); + // 使用 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(); + const data: TMDBSearchResponse = await response.json() as TMDBSearchResponse; // 过滤出电影和电视剧,取第一个结果 const validResults = data.results.filter( From 84fa95fc5749b9b71faddf9dab1965dae7acf750 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 22 Dec 2025 21:38:51 +0800 Subject: [PATCH 08/40] =?UTF-8?q?=E7=A7=BB=E9=99=A4openlist=20token?= =?UTF-8?q?=E7=9A=84=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 27 +---- src/app/api/admin/openlist/route.ts | 46 ++++---- src/app/api/cms-proxy/route.ts | 8 +- src/app/api/detail/route.ts | 2 +- src/app/api/openlist/correct/route.ts | 3 +- src/app/api/openlist/detail/route.ts | 3 +- src/app/api/openlist/list/route.ts | 3 +- src/app/api/openlist/play/route.ts | 3 +- src/app/api/openlist/refresh-video/route.ts | 3 +- src/app/api/openlist/refresh/route.ts | 7 +- src/app/api/search/ws/route.ts | 2 +- src/app/layout.tsx | 4 +- src/lib/admin.types.ts | 5 +- src/lib/openlist.client.ts | 112 ++++++++++++-------- 14 files changed, 111 insertions(+), 117 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 0dcbc12..8dff29c 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2541,7 +2541,6 @@ const OpenListConfigComponent = ({ const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); const [url, setUrl] = useState(''); - const [token, setToken] = useState(''); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [rootPath, setRootPath] = useState('/'); @@ -2558,7 +2557,6 @@ const OpenListConfigComponent = ({ useEffect(() => { if (config?.OpenListConfig) { setUrl(config.OpenListConfig.URL || ''); - setToken(config.OpenListConfig.Token || ''); setUsername(config.OpenListConfig.Username || ''); setPassword(config.OpenListConfig.Password || ''); setRootPath(config.OpenListConfig.RootPath || '/'); @@ -2566,7 +2564,7 @@ const OpenListConfigComponent = ({ }, [config]); useEffect(() => { - if (config?.OpenListConfig?.URL && config?.OpenListConfig?.Token) { + if (config?.OpenListConfig?.URL && config?.OpenListConfig?.Username && config?.OpenListConfig?.Password) { fetchVideos(); } }, [config]); @@ -2595,7 +2593,6 @@ const OpenListConfigComponent = ({ body: JSON.stringify({ action: 'save', URL: url, - Token: token, Username: username, Password: password, RootPath: rootPath, @@ -2731,26 +2728,10 @@ const OpenListConfigComponent = ({ />
-
- - setToken(e.target.value)} - placeholder='your-token' - 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' - /> -

- 可以直接填写Token,或使用下方账号密码登录获取 -

-
-
{/* 视频列表区域 */} - {config?.OpenListConfig?.URL && config?.OpenListConfig?.Token && ( + {config?.OpenListConfig?.URL && config?.OpenListConfig?.Username && config?.OpenListConfig?.Password && (
diff --git a/src/app/api/admin/openlist/route.ts b/src/app/api/admin/openlist/route.ts index 2311eb7..a8393fb 100644 --- a/src/app/api/admin/openlist/route.ts +++ b/src/app/api/admin/openlist/route.ts @@ -26,7 +26,7 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); - const { action, URL, Token, Username, Password, RootPath } = body; + const { action, URL, Username, Password, RootPath } = body; const authInfo = getAuthInfoFromCookie(request); if (!authInfo || !authInfo.username) { @@ -49,40 +49,30 @@ export async function POST(request: NextRequest) { if (action === 'save') { // 保存配置 - if (!URL) { - return NextResponse.json({ error: '缺少URL参数' }, { status: 400 }); - } - - let finalToken = Token; - - // 如果没有Token但有账号密码,尝试登录获取Token - if (!finalToken && Username && Password) { - try { - console.log('[OpenList Config] 使用账号密码登录获取Token'); - finalToken = await OpenListClient.login(URL, Username, Password); - console.log('[OpenList Config] 登录成功,获取到Token'); - } catch (error) { - console.error('[OpenList Config] 登录失败:', error); - return NextResponse.json( - { error: '使用账号密码登录失败: ' + (error as Error).message }, - { status: 400 } - ); - } - } - - // 检查是否有Token - if (!finalToken) { + if (!URL || !Username || !Password) { return NextResponse.json( - { error: '请提供Token或账号密码' }, + { error: '请提供 URL、账号和密码' }, + { 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 = { URL, - Token: finalToken, - Username: Username || undefined, - Password: Password || undefined, + Username, + Password, RootPath: RootPath || '/', LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime, ResourceCount: adminConfig.OpenListConfig?.ResourceCount, diff --git a/src/app/api/cms-proxy/route.ts b/src/app/api/cms-proxy/route.ts index a11baf2..0008414 100644 --- a/src/app/api/cms-proxy/route.ts +++ b/src/app/api/cms-proxy/route.ts @@ -264,7 +264,7 @@ async function handleOpenListProxy(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { return NextResponse.json( { code: 0, msg: 'OpenList 未配置', list: [] }, { status: 200 } @@ -272,7 +272,11 @@ async function handleOpenListProxy(request: NextRequest) { } const rootPath = openListConfig.RootPath || '/'; - const client = new OpenListClient(openListConfig.URL, openListConfig.Token); + const client = new OpenListClient( + openListConfig.URL, + openListConfig.Username, + openListConfig.Password + ); // 读取 metainfo (从数据库或缓存) let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 66717fd..580cd4a 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -26,7 +26,7 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { throw new Error('OpenList 未配置'); } diff --git a/src/app/api/openlist/correct/route.ts b/src/app/api/openlist/correct/route.ts index 8a27ea5..e956b2a 100644 --- a/src/app/api/openlist/correct/route.ts +++ b/src/app/api/openlist/correct/route.ts @@ -39,7 +39,7 @@ export async function POST(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { return NextResponse.json( { error: 'OpenList 未配置' }, { status: 400 } @@ -49,7 +49,6 @@ export async function POST(request: NextRequest) { const rootPath = openListConfig.RootPath || '/'; const client = new OpenListClient( openListConfig.URL, - openListConfig.Token, openListConfig.Username, openListConfig.Password ); diff --git a/src/app/api/openlist/detail/route.ts b/src/app/api/openlist/detail/route.ts index 1d40be3..300afea 100644 --- a/src/app/api/openlist/detail/route.ts +++ b/src/app/api/openlist/detail/route.ts @@ -35,7 +35,7 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { return NextResponse.json({ error: 'OpenList 未配置' }, { status: 400 }); } @@ -43,7 +43,6 @@ export async function GET(request: NextRequest) { const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`; const client = new OpenListClient( openListConfig.URL, - openListConfig.Token, openListConfig.Username, openListConfig.Password ); diff --git a/src/app/api/openlist/list/route.ts b/src/app/api/openlist/list/route.ts index e5e2db9..4c18a84 100644 --- a/src/app/api/openlist/list/route.ts +++ b/src/app/api/openlist/list/route.ts @@ -34,7 +34,7 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { return NextResponse.json( { error: 'OpenList 未配置', list: [], total: 0 }, { status: 200 } @@ -44,7 +44,6 @@ export async function GET(request: NextRequest) { const rootPath = openListConfig.RootPath || '/'; const client = new OpenListClient( openListConfig.URL, - openListConfig.Token, openListConfig.Username, openListConfig.Password ); diff --git a/src/app/api/openlist/play/route.ts b/src/app/api/openlist/play/route.ts index 9e5fa9f..731db4f 100644 --- a/src/app/api/openlist/play/route.ts +++ b/src/app/api/openlist/play/route.ts @@ -31,7 +31,7 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { return NextResponse.json({ error: 'OpenList 未配置' }, { status: 400 }); } @@ -41,7 +41,6 @@ export async function GET(request: NextRequest) { const client = new OpenListClient( openListConfig.URL, - openListConfig.Token, openListConfig.Username, openListConfig.Password ); diff --git a/src/app/api/openlist/refresh-video/route.ts b/src/app/api/openlist/refresh-video/route.ts index a33ef6c..50527e3 100644 --- a/src/app/api/openlist/refresh-video/route.ts +++ b/src/app/api/openlist/refresh-video/route.ts @@ -30,7 +30,7 @@ export async function POST(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { return NextResponse.json({ error: 'OpenList 未配置' }, { status: 400 }); } @@ -38,7 +38,6 @@ export async function POST(request: NextRequest) { const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folder}`; const client = new OpenListClient( openListConfig.URL, - openListConfig.Token, openListConfig.Username, openListConfig.Password ); diff --git a/src/app/api/openlist/refresh/route.ts b/src/app/api/openlist/refresh/route.ts index 409a070..dccd53b 100644 --- a/src/app/api/openlist/refresh/route.ts +++ b/src/app/api/openlist/refresh/route.ts @@ -39,7 +39,7 @@ export async function POST(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Token) { + if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { return NextResponse.json( { error: 'OpenList 未配置' }, { status: 400 } @@ -66,7 +66,6 @@ export async function POST(request: NextRequest) { performScan( taskId, openListConfig.URL, - openListConfig.Token, openListConfig.RootPath || '/', tmdbApiKey, tmdbProxy, @@ -97,20 +96,18 @@ export async function POST(request: NextRequest) { async function performScan( taskId: string, url: string, - token: string, rootPath: string, tmdbApiKey: string, tmdbProxy?: string, username?: string, password?: string ): Promise { - const client = new OpenListClient(url, token, username, password); + const client = new OpenListClient(url, username!, password!); console.log('[OpenList Refresh] 开始扫描:', { taskId, rootPath, url, - hasToken: !!token, }); // 立即更新进度,确保任务可被查询 diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index 0747b17..c896d41 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -34,7 +34,7 @@ export async function GET(request: NextRequest) { const apiSites = await getAvailableApiSites(authInfo.username); // 检查是否配置了 OpenList - const hasOpenList = !!(config.OpenListConfig?.URL && config.OpenListConfig?.Token); + const hasOpenList = !!(config.OpenListConfig?.URL && config.OpenListConfig?.Username && config.OpenListConfig?.Password); // 共享状态 let streamClosed = false; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0918ead..7fc07cc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -90,7 +90,9 @@ export default async function RootLayout({ tmdbApiKey = config.SiteConfig.TMDBApiKey || ''; // 检查是否配置了 OpenList openListEnabled = !!( - config.OpenListConfig?.URL && config.OpenListConfig?.Token + config.OpenListConfig?.URL && + config.OpenListConfig?.Username && + config.OpenListConfig?.Password ); } diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 8990ec1..528178b 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -93,9 +93,8 @@ export interface AdminConfig { }; OpenListConfig?: { URL: string; // OpenList 服务器地址 - Token: string; // 认证 Token - Username?: string; // 账号(可选,用于登录获取Token) - Password?: string; // 密码(可选,用于登录获取Token) + Username: string; // 账号(用于登录获取Token) + Password: string; // 密码(用于登录获取Token) RootPath: string; // 根目录路径,默认 "/" LastRefreshTime?: number; // 上次刷新时间戳 ResourceCount?: number; // 资源数量 diff --git a/src/lib/openlist.client.ts b/src/lib/openlist.client.ts index 743c750..fd79721 100644 --- a/src/lib/openlist.client.ts +++ b/src/lib/openlist.client.ts @@ -1,5 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +// Token 内存缓存 +const tokenCache = new Map(); + export interface OpenListFile { name: string; size: number; @@ -30,11 +33,12 @@ export interface OpenListGetResponse { } export class OpenListClient { + private token: string = ''; + constructor( private baseURL: string, - private token: string, - private username?: string, - private password?: string + private username: string, + private password: string ) {} /** @@ -69,60 +73,81 @@ export class OpenListClient { } /** - * 刷新Token(如果配置了账号密码) + * 获取缓存的 Token 或重新登录 */ - private async refreshToken(): Promise { - if (!this.username || !this.password) { - return false; + 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; } - try { - console.log('[OpenListClient] Token可能失效,尝试使用账号密码重新登录'); - this.token = await OpenListClient.login( - this.baseURL, - this.username, - this.password - ); - console.log('[OpenListClient] Token刷新成功'); - return true; - } catch (error) { - console.error('[OpenListClient] Token刷新失败:', error); - return false; - } + // 否则重新登录 + 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; } /** - * 执行请求,如果401则尝试刷新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 { - const response = await fetch(url, options); + // 获取 Token + const token = await this.getToken(); - // 如果是401且未重试过且有账号密码,尝试刷新Token后重试 - if (response.status === 401 && !retried && this.username && this.password) { - const refreshed = await this.refreshToken(); - if (refreshed) { - // 更新请求头中的Token - const newOptions = { - ...options, - headers: { - ...options.headers, - Authorization: this.token, - }, - }; - return this.fetchWithRetry(url, newOptions, true); - } + // 更新请求头中的 Token + const requestOptions = { + ...options, + headers: { + ...options.headers, + Authorization: token, + }, + }; + + const response = await fetch(url, requestOptions); + + // 如果是401且未重试过,清除缓存并重新登录后重试 + if (response.status === 401 && !retried) { + console.log('[OpenListClient] 收到 401,清除 Token 缓存并重试'); + this.clearTokenCache(); + return this.fetchWithRetry(url, options, true); } return response; } - private getHeaders() { + private async getHeaders() { + const token = await this.getToken(); return { - Authorization: this.token, // 不带 bearer + Authorization: token, // 不带 bearer 'Content-Type': 'application/json', }; } @@ -135,7 +160,7 @@ export class OpenListClient { ): Promise { const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/list`, { method: 'POST', - headers: this.getHeaders(), + headers: await this.getHeaders(), body: JSON.stringify({ path, password: '', @@ -156,7 +181,7 @@ export class OpenListClient { async getFile(path: string): Promise { const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/get`, { method: 'POST', - headers: this.getHeaders(), + headers: await this.getHeaders(), body: JSON.stringify({ path, password: '', @@ -172,10 +197,11 @@ export class OpenListClient { // 上传文件 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: this.token, + Authorization: token, 'Content-Type': 'text/plain; charset=utf-8', 'File-Path': encodeURIComponent(path), 'As-Task': 'false', @@ -198,7 +224,7 @@ export class OpenListClient { try { const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/list`, { method: 'POST', - headers: this.getHeaders(), + headers: await this.getHeaders(), body: JSON.stringify({ path, password: '', @@ -223,7 +249,7 @@ export class OpenListClient { const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/remove`, { method: 'POST', - headers: this.getHeaders(), + headers: await this.getHeaders(), body: JSON.stringify({ names: [fileName], dir: dir, From 2104abb84bd2e581eec144556bbcc7cb22bc715c Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 22 Dec 2025 21:41:11 +0800 Subject: [PATCH 09/40] =?UTF-8?q?=E4=BF=AE=E6=AD=A3openlist=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E9=87=8D=E6=96=B0=E8=8E=B7=E5=8F=96token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/openlist.client.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/lib/openlist.client.ts b/src/lib/openlist.client.ts index fd79721..f291f47 100644 --- a/src/lib/openlist.client.ts +++ b/src/lib/openlist.client.ts @@ -134,13 +134,31 @@ export class OpenListClient { const response = await fetch(url, requestOptions); - // 如果是401且未重试过,清除缓存并重新登录后重试 + // 检查 HTTP status 401 if (response.status === 401 && !retried) { - console.log('[OpenListClient] 收到 401,清除 Token 缓存并重试'); + 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; } From 76ee99cda2eb7ebc7d46d05d75b07081a384a0ca Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 22 Dec 2025 22:07:09 +0800 Subject: [PATCH 10/40] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=AB=8B=E5=8D=B3?= =?UTF-8?q?=E6=89=AB=E6=8F=8Fopenlist=E7=AB=AF=E4=B8=8D=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 11 ++- src/app/api/openlist/list/route.ts | 39 ++++------ src/app/api/openlist/refresh/route.ts | 108 ++------------------------ src/lib/openlist.client.ts | 5 +- 4 files changed, 30 insertions(+), 133 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8dff29c..9121e76 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2569,10 +2569,11 @@ const OpenListConfigComponent = ({ } }, [config]); - const fetchVideos = async () => { + const fetchVideos = async (noCache = false) => { try { setRefreshing(true); - const response = await fetch('/api/openlist/list?page=1&pageSize=100&includeFailed=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 || []); @@ -2658,8 +2659,10 @@ const OpenListConfigComponent = ({ `扫描完成!新增 ${task.result.new} 个,已存在 ${task.result.existing} 个,失败 ${task.result.errors} 个`, showAlert ); + // 先强制从数据库读取视频列表(这会更新缓存) + await fetchVideos(true); + // 然后再刷新配置(这会触发 useEffect,但此时缓存已经是新的了) await refreshConfig(); - await fetchVideos(); } else if (task.status === 'failed') { clearInterval(pollInterval); setScanProgress(null); @@ -2703,7 +2706,7 @@ const OpenListConfigComponent = ({ }; const handleCorrectSuccess = () => { - fetchVideos(); + fetchVideos(true); // 强制从数据库重新读取,不使用缓存 }; const formatDate = (timestamp?: number) => { diff --git a/src/app/api/openlist/list/route.ts b/src/app/api/openlist/list/route.ts index 4c18a84..8c67b18 100644 --- a/src/app/api/openlist/list/route.ts +++ b/src/app/api/openlist/list/route.ts @@ -16,7 +16,7 @@ import { getTMDBImageUrl } from '@/lib/tmdb.search'; export const runtime = 'nodejs'; /** - * GET /api/openlist/list?page=1&pageSize=20&includeFailed=false + * GET /api/openlist/list?page=1&pageSize=20&includeFailed=false&noCache=false * 获取私人影库视频列表 */ export async function GET(request: NextRequest) { @@ -30,6 +30,7 @@ export async function GET(request: NextRequest) { 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; @@ -49,30 +50,22 @@ export async function GET(request: NextRequest) { ); // 读取 metainfo (从数据库或缓存) - let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); + let metaInfo: MetaInfo | null = null; - console.log('[OpenList List] 缓存检查:', { - rootPath, - hasCachedMetaInfo: !!metaInfo, - }); + // 如果不使用缓存,直接从数据库读取 + if (noCache) { + // noCache 模式:跳过缓存 + } else { + metaInfo = getCachedMetaInfo(rootPath); + } if (!metaInfo) { try { - console.log('[OpenList List] 尝试从数据库读取 metainfo'); - const metainfoJson = await db.getGlobalValue('video.metainfo'); if (metainfoJson) { - console.log('[OpenList List] 从数据库获取到数据,长度:', metainfoJson.length); - try { metaInfo = JSON.parse(metainfoJson); - console.log('[OpenList List] JSON 解析成功'); - console.log('[OpenList List] metaInfo 结构:', { - hasfolders: !!metaInfo?.folders, - foldersType: typeof metaInfo?.folders, - keys: metaInfo?.folders ? Object.keys(metaInfo.folders) : [], - }); // 验证数据结构 if (!metaInfo || typeof metaInfo !== 'object') { @@ -82,8 +75,10 @@ export async function GET(request: NextRequest) { throw new Error('metaInfo.folders 不存在或不是对象'); } - console.log('[OpenList List] 解析成功,视频数量:', Object.keys(metaInfo.folders).length); - setCachedMetaInfo(rootPath, metaInfo); + // 只有在不是 noCache 模式时才更新缓存 + if (!noCache) { + setCachedMetaInfo(rootPath, metaInfo); + } } catch (parseError) { console.error('[OpenList List] JSON 解析或验证失败:', parseError); throw new Error(`JSON 解析失败: ${(parseError as Error).message}`); @@ -106,7 +101,6 @@ export async function GET(request: NextRequest) { } if (!metaInfo) { - console.error('[OpenList List] metaInfo 为 null'); return NextResponse.json( { error: '无数据', list: [], total: 0 }, { status: 200 } @@ -115,19 +109,12 @@ export async function GET(request: NextRequest) { // 验证 metaInfo 结构 if (!metaInfo.folders || typeof metaInfo.folders !== 'object') { - console.error('[OpenList List] metaInfo.folders 无效:', { - hasfolders: !!metaInfo.folders, - foldersType: typeof metaInfo.folders, - metaInfoKeys: Object.keys(metaInfo), - }); return NextResponse.json( { error: 'metainfo.json 结构无效', list: [], total: 0 }, { status: 200 } ); } - console.log('[OpenList List] 开始转换视频列表,视频数:', Object.keys(metaInfo.folders).length); - // 转换为数组并分页 const allVideos = Object.entries(metaInfo.folders) .filter(([, info]) => includeFailed || !info.failed) // 根据参数过滤失败的视频 diff --git a/src/app/api/openlist/refresh/route.ts b/src/app/api/openlist/refresh/route.ts index dccd53b..e728858 100644 --- a/src/app/api/openlist/refresh/route.ts +++ b/src/app/api/openlist/refresh/route.ts @@ -104,59 +104,21 @@ async function performScan( ): Promise { const client = new OpenListClient(url, username!, password!); - console.log('[OpenList Refresh] 开始扫描:', { - taskId, - rootPath, - url, - }); + // 立即清除缓存,确保后续读取的是新数据 + invalidateMetaInfoCache(rootPath); // 立即更新进度,确保任务可被查询 updateScanTaskProgress(taskId, 0, 0); try { - // 1. 读取现有 metainfo (从数据库或缓存) - let existingMetaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); - - if (!existingMetaInfo) { - try { - console.log('[OpenList Refresh] 尝试从数据库读取 metainfo'); - const metainfoJson = await db.getGlobalValue('video.metainfo'); - - if (metainfoJson) { - existingMetaInfo = JSON.parse(metainfoJson); - console.log('[OpenList Refresh] 从数据库读取到现有数据:', { - hasfolders: !!existingMetaInfo?.folders, - foldersType: typeof existingMetaInfo?.folders, - videoCount: Object.keys(existingMetaInfo?.folders || {}).length, - }); - } - } catch (error) { - console.error('[OpenList Refresh] 从数据库读取 metainfo 失败:', error); - console.log('[OpenList Refresh] 将创建新数据'); - } - } else { - console.log('[OpenList Refresh] 使用缓存的 metainfo,视频数:', Object.keys(existingMetaInfo.folders).length); - } - - const metaInfo: MetaInfo = existingMetaInfo || { + // 1. 不读取现有数据,直接创建新的 metainfo + const metaInfo: MetaInfo = { folders: {}, last_refresh: Date.now(), }; - // 确保 folders 对象存在 - if (!metaInfo.folders || typeof metaInfo.folders !== 'object') { - console.warn('[OpenList Refresh] metaInfo.folders 无效,重新初始化'); - metaInfo.folders = {}; - } - - console.log('[OpenList Refresh] metaInfo 初始化完成:', { - hasfolders: !!metaInfo.folders, - foldersType: typeof metaInfo.folders, - videoCount: Object.keys(metaInfo.folders).length, - }); - - // 2. 列出根目录下的所有文件夹 - const listResponse = await client.listDirectory(rootPath); + // 2. 列出根目录下的所有文件夹(强制刷新 OpenList 缓存) + const listResponse = await client.listDirectory(rootPath, 1, 100, true); if (listResponse.code !== 200) { throw new Error('OpenList 列表获取失败'); @@ -164,11 +126,6 @@ async function performScan( const folders = listResponse.data.content.filter((item) => item.is_dir); - console.log('[OpenList Refresh] 找到文件夹:', { - total: folders.length, - names: folders.map(f => f.name), - }); - // 更新任务进度 updateScanTaskProgress(taskId, 0, folders.length); @@ -178,19 +135,11 @@ async function performScan( for (let i = 0; i < folders.length; i++) { const folder = folders[i]; - console.log('[OpenList Refresh] 处理文件夹:', folder.name); // 更新进度 updateScanTaskProgress(taskId, i + 1, folders.length, folder.name); - // 跳过已搜索过的文件夹 - if (metaInfo.folders[folder.name]) { - console.log('[OpenList Refresh] 跳过已存在的文件夹:', folder.name); - continue; - } - try { - console.log('[OpenList Refresh] 搜索 TMDB:', folder.name); // 搜索 TMDB const searchResult = await searchTMDB( tmdbApiKey, @@ -198,12 +147,6 @@ async function performScan( tmdbProxy ); - console.log('[OpenList Refresh] TMDB 搜索结果:', { - folder: folder.name, - code: searchResult.code, - hasResult: !!searchResult.result, - }); - if (searchResult.code === 200 && searchResult.result) { const result = searchResult.result; @@ -219,14 +162,8 @@ async function performScan( failed: false, }; - console.log('[OpenList Refresh] 添加成功:', { - folder: folder.name, - title: metaInfo.folders[folder.name].title, - }); - newCount++; } else { - console.warn(`[OpenList Refresh] TMDB 搜索失败: ${folder.name}`); // 记录失败的文件夹 metaInfo.folders[folder.name] = { tmdb_id: 0, @@ -266,34 +203,11 @@ async function performScan( metaInfo.last_refresh = Date.now(); const metainfoContent = JSON.stringify(metaInfo); - console.log('[OpenList Refresh] 保存 metainfo 到数据库:', { - videoCount: Object.keys(metaInfo.folders).length, - contentLength: metainfoContent.length, - }); - await db.setGlobalValue('video.metainfo', metainfoContent); - console.log('[OpenList Refresh] 保存成功'); - - // 验证保存:立即读取数据库 - try { - console.log('[OpenList Refresh] 验证保存:读取数据库'); - const verifyContent = await db.getGlobalValue('video.metainfo'); - if (verifyContent) { - const verifyParsed = JSON.parse(verifyContent); - console.log('[OpenList Refresh] 验证解析成功:', { - hasfolders: !!verifyParsed.folders, - foldersType: typeof verifyParsed.folders, - videoCount: Object.keys(verifyParsed.folders || {}).length, - }); - } - } catch (verifyError) { - console.error('[OpenList Refresh] 验证失败:', verifyError); - } // 5. 更新缓存 invalidateMetaInfoCache(rootPath); setCachedMetaInfo(rootPath, metaInfo); - console.log('[OpenList Refresh] 缓存已更新'); // 6. 更新配置 const config = await getConfig(); @@ -305,15 +219,7 @@ async function performScan( completeScanTask(taskId, { total: folders.length, new: newCount, - existing: Object.keys(metaInfo.folders).length - newCount, - errors: errorCount, - }); - - console.log('[OpenList Refresh] 扫描完成:', { - taskId, - total: folders.length, - new: newCount, - existing: Object.keys(metaInfo.folders).length - newCount, + existing: 0, errors: errorCount, }); } catch (error) { diff --git a/src/lib/openlist.client.ts b/src/lib/openlist.client.ts index f291f47..790caad 100644 --- a/src/lib/openlist.client.ts +++ b/src/lib/openlist.client.ts @@ -174,7 +174,8 @@ export class OpenListClient { async listDirectory( path: string, page = 1, - perPage = 100 + perPage = 100, + refresh = false ): Promise { const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/list`, { method: 'POST', @@ -182,7 +183,7 @@ export class OpenListClient { body: JSON.stringify({ path, password: '', - refresh: false, + refresh, page, per_page: perPage, }), From 6f05f91a806f29097cd753bb88190ffde3e404c6 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 22 Dec 2025 22:28:54 +0800 Subject: [PATCH 11/40] =?UTF-8?q?openlist=E5=A2=9E=E5=8A=A0=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E6=89=AB=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 20 ++++++++++ src/app/api/admin/openlist/route.ts | 12 +++++- src/app/api/cron/route.ts | 57 +++++++++++++++++++++++++++++ src/lib/admin.types.ts | 1 + 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 9121e76..bf2f56a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2544,6 +2544,7 @@ const OpenListConfigComponent = ({ 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<{ @@ -2560,6 +2561,7 @@ const OpenListConfigComponent = ({ setUsername(config.OpenListConfig.Username || ''); setPassword(config.OpenListConfig.Password || ''); setRootPath(config.OpenListConfig.RootPath || '/'); + setScanInterval(config.OpenListConfig.ScanInterval || 0); } }, [config]); @@ -2597,6 +2599,7 @@ const OpenListConfigComponent = ({ Username: username, Password: password, RootPath: rootPath, + ScanInterval: scanInterval, }), }); @@ -2774,6 +2777,23 @@ const OpenListConfigComponent = ({

+
+ + setScanInterval(parseInt(e.target.value) || 0)} + 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' + /> +

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

+
+
{/* 视频列表区域 */} - {config?.OpenListConfig?.URL && config?.OpenListConfig?.Username && config?.OpenListConfig?.Password && ( + {enabled && config?.OpenListConfig?.URL && config?.OpenListConfig?.Username && config?.OpenListConfig?.Password && (
@@ -7675,18 +7734,6 @@ function AdminPageClient() { - {/* 私人影库配置标签 */} - - } - isExpanded={expandedTabs.openListConfig} - onToggle={() => toggleTab('openListConfig')} - > - - - {/* 直播源配置标签 */} + {/* 私人影库配置标签 */} + + } + isExpanded={expandedTabs.openListConfig} + onToggle={() => toggleTab('openListConfig')} + > + + + {/* 分类配置标签 */}
-

- 私人影库 -

+
+

+ 私人影库 +

+

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

+
{error && (
diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 4cfb376..e76fa01 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -92,6 +92,7 @@ export interface AdminConfig { cacheVersion: number; // CSS版本号(用于缓存控制) }; OpenListConfig?: { + Enabled: boolean; // 是否启用私人影库功能 URL: string; // OpenList 服务器地址 Username: string; // 账号(用于登录获取Token) Password: string; // 密码(用于登录获取Token) From bca4e773c49e1fc979b134f7eccd87b6c5d0293e Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 23 Dec 2025 00:47:24 +0800 Subject: [PATCH 14/40] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=9B=98=E6=90=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 87 ++++++++ src/app/api/admin/site/route.ts | 9 + src/app/api/pansou/search/route.ts | 61 ++++++ src/app/api/server-config/route.ts | 4 + src/app/play/page.tsx | 51 ++++- src/app/search/page.tsx | 70 ++++++- src/components/PansouSearch.tsx | 314 +++++++++++++++++++++++++++++ src/lib/admin.types.ts | 4 + src/lib/pansou.client.ts | 218 ++++++++++++++++++++ 9 files changed, 813 insertions(+), 5 deletions(-) create mode 100644 src/app/api/pansou/search/route.ts create mode 100644 src/components/PansouSearch.tsx create mode 100644 src/lib/pansou.client.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8170d79..ccb8c7b 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -5105,6 +5105,9 @@ const SiteConfigComponent = ({ DanmakuApiToken: '87654321', TMDBApiKey: '', TMDBProxy: '', + PansouApiUrl: '', + PansouUsername: '', + PansouPassword: '', EnableComments: false, EnableRegistration: false, RegistrationRequireTurnstile: false, @@ -5189,6 +5192,9 @@ const SiteConfigComponent = ({ 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, }); } @@ -5779,6 +5785,87 @@ const SiteConfigComponent = ({
+ {/* 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 +

+
+
+ {/* 评论功能配置 */}

diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts index 00d78e9..5ca0eb9 100644 --- a/src/app/api/admin/site/route.ts +++ b/src/app/api/admin/site/route.ts @@ -43,6 +43,9 @@ export async function POST(request: NextRequest) { DanmakuApiToken, TMDBApiKey, TMDBProxy, + PansouApiUrl, + PansouUsername, + PansouPassword, EnableComments, CustomAdFilterCode, CustomAdFilterVersion, @@ -76,6 +79,9 @@ export async function POST(request: NextRequest) { DanmakuApiToken: string; TMDBApiKey?: string; TMDBProxy?: string; + PansouApiUrl?: string; + PansouUsername?: string; + PansouPassword?: string; EnableComments: boolean; CustomAdFilterCode?: string; CustomAdFilterVersion?: number; @@ -163,6 +169,9 @@ export async function POST(request: NextRequest) { DanmakuApiToken, TMDBApiKey, TMDBProxy, + PansouApiUrl, + PansouUsername, + PansouPassword, EnableComments, CustomAdFilterCode, CustomAdFilterVersion, 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/server-config/route.ts b/src/app/api/server-config/route.ts index 0be1393..2701b09 100644 --- a/src/app/api/server-config/route.ts +++ b/src/app/api/server-config/route.ts @@ -46,6 +46,10 @@ export async function GET(request: NextRequest) { EnableOIDCLogin: config.SiteConfig.EnableOIDCLogin || false, EnableOIDCRegistration: config.SiteConfig.EnableOIDCRegistration || false, OIDCButtonText: config.SiteConfig.OIDCButtonText || '', + SiteConfig: { + PansouApiUrl: config.SiteConfig.PansouApiUrl || '', + // 不暴露用户名和密码,认证在后端处理 + }, }; return NextResponse.json(result); } diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 049a8a3..ea15678 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 } 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; @@ -4890,6 +4894,17 @@ function PlayPageClient() { > + {/* 网盘搜索按钮 */} + {/* 豆瓣评分显示 */} {doubanRating && doubanRating.value > 0 && (
@@ -5107,6 +5122,40 @@ function PlayPageClient() { }); }} /> + + {/* 网盘搜索弹窗 */} + {showPansouDialog && ( +
setShowPansouDialog(false)} + > +
e.stopPropagation()} + > + {/* 弹窗头部 */} +
+

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

+ +
+ + {/* 弹窗内容 */} +
+ +
+
+
+ )} ); } diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index e8166fa..9174fa4 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 ? ( // 搜索历史 diff --git a/src/components/PansouSearch.tsx b/src/components/PansouSearch.tsx new file mode 100644 index 0000000..84d3d1d --- /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/lib/admin.types.ts b/src/lib/admin.types.ts index e76fa01..bd51ec2 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -22,6 +22,10 @@ export interface AdminConfig { // TMDB配置 TMDBApiKey?: string; TMDBProxy?: string; + // Pansou配置 + PansouApiUrl?: string; + PansouUsername?: string; + PansouPassword?: string; // 评论功能开关 EnableComments: boolean; // 自定义去广告代码 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; + } +} From afb45fccc0b70bef2b4486f323a8f27043ad4feb Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 23 Dec 2025 00:55:16 +0800 Subject: [PATCH 15/40] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dopenlist=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E5=88=B7=E6=96=B0=E6=97=B6=E4=B8=8D=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/openlist/refresh/route.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/app/api/openlist/refresh/route.ts b/src/app/api/openlist/refresh/route.ts index e728858..a7acad3 100644 --- a/src/app/api/openlist/refresh/route.ts +++ b/src/app/api/openlist/refresh/route.ts @@ -118,14 +118,31 @@ async function performScan( }; // 2. 列出根目录下的所有文件夹(强制刷新 OpenList 缓存) - const listResponse = await client.listDirectory(rootPath, 1, 100, true); + // 循环获取所有页的数据 + const folders: any[] = []; + let currentPage = 1; + const pageSize = 100; + let total = 0; - if (listResponse.code !== 200) { - throw new Error('OpenList 列表获取失败'); + while (true) { + const listResponse = await client.listDirectory(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++; } - const folders = listResponse.data.content.filter((item) => item.is_dir); - // 更新任务进度 updateScanTaskProgress(taskId, 0, folders.length); From ffa6a3aefe03738a1dedc4fbb9977a6aba54b3cc Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 23 Dec 2025 01:03:08 +0800 Subject: [PATCH 16/40] =?UTF-8?q?=E7=A7=BB=E9=99=A4pansou=E5=9C=A8server-c?= =?UTF-8?q?onfig=E4=B8=AD=E7=9A=84=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 3 +++ src/app/api/server-config/route.ts | 4 ---- src/components/PansouSearch.tsx | 6 +++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index ccb8c7b..f447b10 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -300,6 +300,9 @@ interface SiteConfig { DanmakuApiToken: string; TMDBApiKey?: string; TMDBProxy?: string; + PansouApiUrl?: string; + PansouUsername?: string; + PansouPassword?: string; EnableComments: boolean; EnableRegistration?: boolean; RegistrationRequireTurnstile?: boolean; diff --git a/src/app/api/server-config/route.ts b/src/app/api/server-config/route.ts index 2701b09..0be1393 100644 --- a/src/app/api/server-config/route.ts +++ b/src/app/api/server-config/route.ts @@ -46,10 +46,6 @@ export async function GET(request: NextRequest) { EnableOIDCLogin: config.SiteConfig.EnableOIDCLogin || false, EnableOIDCRegistration: config.SiteConfig.EnableOIDCRegistration || false, OIDCButtonText: config.SiteConfig.OIDCButtonText || '', - SiteConfig: { - PansouApiUrl: config.SiteConfig.PansouApiUrl || '', - // 不暴露用户名和密码,认证在后端处理 - }, }; return NextResponse.json(result); } diff --git a/src/components/PansouSearch.tsx b/src/components/PansouSearch.tsx index 84d3d1d..db0b702 100644 --- a/src/components/PansouSearch.tsx +++ b/src/components/PansouSearch.tsx @@ -154,7 +154,7 @@ export default function PansouSearch({ ); } - const cloudTypes = Object.keys(results.merged_by_type); + const cloudTypes = Object.keys(results.merged_by_type || {}); // 过滤显示的网盘类型 const filteredCloudTypes = selectedType === 'all' @@ -164,7 +164,7 @@ export default function PansouSearch({ // 计算每种网盘类型的数量 const typeStats = cloudTypes.map(type => ({ type, - count: results.merged_by_type[type]?.length || 0, + count: results.merged_by_type?.[type]?.length || 0, })); return ( @@ -208,7 +208,7 @@ export default function PansouSearch({ {/* 按网盘类型分类显示 */} {filteredCloudTypes.map((cloudType) => { - const links = results.merged_by_type[cloudType]; + const links = results.merged_by_type?.[cloudType]; if (!links || links.length === 0) return null; const typeName = CLOUD_TYPE_NAMES[cloudType] || cloudType; From b549736c6079ee4f911a54334985c6e0aa10027e Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 23 Dec 2025 01:06:14 +0800 Subject: [PATCH 17/40] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9C=A8=E7=BD=91?= =?UTF-8?q?=E7=9B=98=E6=90=9C=E7=B4=A2=E9=80=89=E9=A1=B9=E5=8D=A1=E4=B8=AD?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E5=8E=86=E5=8F=B2=E8=AE=B0=E5=BD=95=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E7=9A=84=E4=B8=8D=E6=98=AF=E7=BD=91=E7=9B=98=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/search/page.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 9174fa4..f413af9 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -1015,9 +1015,18 @@ function SearchPageClient() {
{error && ( @@ -229,6 +230,7 @@ export default function CorrectDialog({ )}
-
+
, + document.body ); } From e116b98c9679966e955a93e94e00291c76fdadb5 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 23 Dec 2025 20:42:09 +0800 Subject: [PATCH 20/40] =?UTF-8?q?=E7=A7=81=E4=BA=BA=E5=BD=B1=E5=BA=93?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=AB=8B=E5=8D=B3=E6=89=AB=E6=8F=8F=E5=92=8C?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E6=89=AB=E6=8F=8F=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 107 ++++++++++++++++++++++---- src/app/api/cron/route.ts | 3 +- src/app/api/openlist/delete/route.ts | 97 +++++++++++++++++++++++ src/app/api/openlist/refresh/route.ts | 59 +++++++++++--- 4 files changed, 238 insertions(+), 28 deletions(-) create mode 100644 src/app/api/openlist/delete/route.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index f447b10..6d3fa4e 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -120,6 +120,7 @@ interface AlertModalProps { message?: string; timer?: number; showConfirm?: boolean; + onConfirm?: () => void; } const AlertModal = ({ @@ -130,6 +131,7 @@ const AlertModal = ({ message, timer, showConfirm = false, + onConfirm, }: AlertModalProps) => { const [isVisible, setIsVisible] = useState(false); @@ -196,14 +198,38 @@ const AlertModal = ({

{message}

)} - {showConfirm && ( - - )} + {showConfirm ? ( + onConfirm ? ( + // 确认操作:显示取消和确定按钮 +
+ + +
+ ) : ( + // 普通提示:只显示确定按钮 + + ) + ) : null}
, @@ -220,6 +246,7 @@ const useAlertModal = () => { message?: string; timer?: number; showConfirm?: boolean; + onConfirm?: () => void; }>({ isOpen: false, type: 'success', @@ -2623,12 +2650,14 @@ const OpenListConfigComponent = ({ }); }; - const handleRefresh = async () => { + 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) { @@ -2718,6 +2747,36 @@ const OpenListConfigComponent = ({ fetchVideos(true); // 强制从数据库重新读取,不使用缓存 }; + 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'); @@ -2883,13 +2942,22 @@ const OpenListConfigComponent = ({
- +
+ + +
{refreshing && scanProgress && ( @@ -2995,6 +3063,12 @@ const OpenListConfigComponent = ({ > {video.failed ? '立即纠错' : '纠错'} + @@ -3018,6 +3092,7 @@ const OpenListConfigComponent = ({ message={alertModal.message} timer={alertModal.timer} showConfirm={alertModal.showConfirm} + onConfirm={alertModal.onConfirm} /> {/* 纠错对话框 */} diff --git a/src/app/api/cron/route.ts b/src/app/api/cron/route.ts index 2e4a692..9fa6a02 100644 --- a/src/app/api/cron/route.ts +++ b/src/app/api/cron/route.ts @@ -330,12 +330,13 @@ async function refreshOpenList() { 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) { diff --git a/src/app/api/openlist/delete/route.ts b/src/app/api/openlist/delete/route.ts new file mode 100644 index 0000000..7a04e04 --- /dev/null +++ b/src/app/api/openlist/delete/route.ts @@ -0,0 +1,97 @@ +/* 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.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/refresh/route.ts b/src/app/api/openlist/refresh/route.ts index a7acad3..4fa3527 100644 --- a/src/app/api/openlist/refresh/route.ts +++ b/src/app/api/openlist/refresh/route.ts @@ -35,6 +35,10 @@ export async function POST(request: NextRequest) { 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; @@ -70,7 +74,8 @@ export async function POST(request: NextRequest) { tmdbApiKey, tmdbProxy, openListConfig.Username, - openListConfig.Password + openListConfig.Password, + clearMetaInfo ).catch((error) => { console.error('[OpenList Refresh] 后台扫描失败:', error); failScanTask(taskId, (error as Error).message); @@ -100,22 +105,47 @@ async function performScan( tmdbApiKey: string, tmdbProxy?: string, username?: string, - password?: string + password?: string, + clearMetaInfo?: boolean ): Promise { const client = new OpenListClient(url, username!, password!); - // 立即清除缓存,确保后续读取的是新数据 - invalidateMetaInfoCache(rootPath); - // 立即更新进度,确保任务可被查询 updateScanTaskProgress(taskId, 0, 0); try { - // 1. 不读取现有数据,直接创建新的 metainfo - const metaInfo: MetaInfo = { - folders: {}, - last_refresh: Date.now(), - }; + // 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 缓存) // 循环获取所有页的数据 @@ -148,6 +178,7 @@ async function performScan( // 3. 遍历文件夹,搜索 TMDB let newCount = 0; + let existingCount = 0; let errorCount = 0; for (let i = 0; i < folders.length; i++) { @@ -156,6 +187,12 @@ async function performScan( // 更新进度 updateScanTaskProgress(taskId, i + 1, folders.length, folder.name); + // 如果是立即扫描(不清空 metainfo),且文件夹已存在,跳过 + if (!clearMetaInfo && metaInfo.folders[folder.name]) { + existingCount++; + continue; + } + try { // 搜索 TMDB const searchResult = await searchTMDB( @@ -236,7 +273,7 @@ async function performScan( completeScanTask(taskId, { total: folders.length, new: newCount, - existing: 0, + existing: existingCount, errors: errorCount, }); } catch (error) { From 1a0ec5345272aaf8a10abc53e281917bb92da28a Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 23 Dec 2025 20:48:15 +0800 Subject: [PATCH 21/40] =?UTF-8?q?openlist=E9=85=8D=E7=BD=AE=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=BF=9E=E9=80=9A=E6=80=A7=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 34 ++++++++++++++++ src/app/api/openlist/check/route.ts | 61 +++++++++++++++++++++++++++++ src/lib/openlist.client.ts | 36 +++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/app/api/openlist/check/route.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 6d3fa4e..9bc8a82 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2747,6 +2747,33 @@ const OpenListConfigComponent = ({ 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({ @@ -2916,6 +2943,13 @@ const OpenListConfigComponent = ({
+ +
+ + )} {/* 用户组管理 */} @@ -1195,10 +1287,31 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { )} {/* 用户列表 */} -
+
+ {/* 迁移遮罩层 */} + {config.UserConfig.Users && + config.UserConfig.Users.filter(u => u.role !== 'owner').length > 0 && ( +
+
+
+ +

+ 需要迁移数据 +

+
+

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

+

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

+
+
+ )} +
@@ -1266,8 +1379,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; @@ -1507,6 +1633,62 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { })()}
+ 加载中... +
+ + {/* 用户列表分页 */} + {!hasOldUserData && usersV2 && userTotalPages > 1 && ( +
+
+ 共 {userTotal} 个用户,第 {userPage} / {userTotalPages} 页 +
+
+ + + + +
+
+ )} +
{/* 配置用户采集源权限弹窗 */} @@ -2555,6 +2737,7 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { message={alertModal.message} timer={alertModal.timer} showConfirm={alertModal.showConfirm} + onConfirm={alertModal.onConfirm} /> ); @@ -7758,17 +7941,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(); + }, [fetchConfig, fetchUsersV2]); + useEffect(() => { // 首次加载时显示骨架 fetchConfig(true); + // 不再自动获取用户列表,等用户打开用户管理选项卡时再获取 }, [fetchConfig]); // 切换标签展开状态 const toggleTab = (tabKey: string) => { + const wasExpanded = expandedTabs[tabKey]; + setExpandedTabs((prev) => ({ ...prev, [tabKey]: !prev[tabKey], })); + + // 当打开用户管理选项卡时,如果还没有加载用户列表,则加载 + if (tabKey === 'userConfig' && !wasExpanded && !usersV2) { + fetchUsersV2(); + } }; // 新增: 重置配置处理函数 @@ -7917,7 +8150,13 @@ function AdminPageClient() {
@@ -8013,7 +8252,7 @@ function AdminPageClient() { isExpanded={expandedTabs.dataMigration} onToggle={() => 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/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/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..7825353 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,9 +152,19 @@ export async function POST(request: NextRequest) { try { // 生成随机密码(OIDC用户不需要密码登录) const randomPassword = crypto.randomUUID(); + + // 获取默认用户组 + const defaultTags = siteConfig.DefaultUserTags && siteConfig.DefaultUserTags.length > 0 + ? siteConfig.DefaultUserTags + : undefined; + + // 使用新版本创建用户(带SHA256加密和OIDC绑定) + await db.createUserV2(username, randomPassword, 'user', defaultTags, oidcSession.sub); + + // 同时在旧版本存储中创建(保持兼容性) await db.registerUser(username, randomPassword); - // 将用户添加到配置中 + // 将用户添加到配置中(保持兼容性) const newUser: any = { username: username, role: 'user', @@ -143,8 +173,8 @@ export async function POST(request: NextRequest) { }; // 如果配置了默认用户组,分配给新用户 - if (siteConfig.DefaultUserTags && siteConfig.DefaultUserTags.length > 0) { - newUser.tags = siteConfig.DefaultUserTags; + if (defaultTags) { + newUser.tags = defaultTags; } config.UserConfig.Users.push(newUser); 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/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/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/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..7240d97 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -93,7 +93,20 @@ export async function POST(req: 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( @@ -131,24 +144,31 @@ export async function POST(req: NextRequest) { // 创建用户 try { - // 1. 在数据库中创建用户密码 + // 1. 使用新版本创建用户(带SHA256加密) + const defaultTags = siteConfig.DefaultUserTags && siteConfig.DefaultUserTags.length > 0 + ? siteConfig.DefaultUserTags + : undefined; + + await db.createUserV2(username, password, 'user', defaultTags); + + // 2. 同时在旧版本存储中创建(保持兼容性) await db.registerUser(username, password); - // 2. 将用户添加到管理员配置的用户列表中 + // 3. 将用户添加到管理员配置的用户列表中(保持兼容性) const newUser: any = { username: username, role: 'user', banned: false, }; - // 3. 如果配置了默认用户组,分配给新用户 - if (siteConfig.DefaultUserTags && siteConfig.DefaultUserTags.length > 0) { - newUser.tags = siteConfig.DefaultUserTags; + // 4. 如果配置了默认用户组,分配给新用户 + if (defaultTags) { + newUser.tags = defaultTags; } config.UserConfig.Users.push(newUser); - // 4. 保存更新后的配置 + // 5. 保存更新后的配置 await db.saveAdminConfig(config); // 注册成功 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/lib/config.ts b/src/lib/config.ts index 91016cf..67e5432 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -470,14 +470,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, @@ -487,11 +488,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..7291d6a 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,197 @@ 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[]; + 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.role === 'owner') { + 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 if (user.username === process.env.USERNAME && process.env.PASSWORD) { + password = process.env.PASSWORD; + console.log(`用户 ${user.username} (站长) 使用环境变量密码迁移`); + } + // 尝试从旧的存储中获取密码 + 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'; + } + } + + // 创建新用户 + await this.createUserV2( + user.username, + password, + user.role, + 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/redis-base.db.ts b/src/lib/redis-base.db.ts index 98ba2a0..94aa0ba 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,256 @@ 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 + ): 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 (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, + })); + } + + // 验证用户密码(新版本) + 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; + 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, + created_at: parseInt(userInfo.created_at || '0', 10), + }; + } + + // 更新用户信息(新版本) + async updateUserInfoV2( + userName: string, + updates: { + role?: 'owner' | 'admin' | 'user'; + banned?: boolean; + tags?: string[]; + oidcSub?: 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.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[]; + created_at: number; + }>; + total: number; + }> { + // 获取总数 + const total = await this.withRetry(() => this.client.zCard(this.userListKey())); + + // 获取用户列表(按注册时间升序) + const usernames = await this.withRetry(() => + this.client.zRange(this.userListKey(), offset, offset + limit - 1) + ); + + const users = []; + + // 如果有站长,确保站长始终在第一位 + if (ownerUsername && offset === 0) { + const ownerInfo = await this.getUserInfoV2(ownerUsername); + if (ownerInfo) { + users.push({ + username: ownerUsername, + role: 'owner' as const, + banned: ownerInfo.banned, + tags: ownerInfo.tags, + created_at: ownerInfo.created_at, + }); + } + } + + // 获取其他用户信息 + 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 diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index a274f43..4044f72 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,366 @@ 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 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, + })); + } + + // 验证用户密码(新版本) + 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[]; + enabledApis?: string[]; + created_at: number; + }>; + total: number; + }> { + // 获取总数 + const total = await withRetry(() => this.client.zcard(this.userListKey())); + + // 获取用户列表(按注册时间升序) + const usernames = await withRetry(() => + this.client.zrange(this.userListKey(), offset, offset + limit - 1) + ); + + const users = []; + + // 如果有站长,确保站长始终在第一位 + if (ownerUsername && offset === 0) { + const ownerInfo = await this.getUserInfoV2(ownerUsername); + if (ownerInfo) { + users.push({ + username: ownerUsername, + role: 'owner' as const, + banned: ownerInfo.banned, + tags: ownerInfo.tags, + enabledApis: ownerInfo.enabledApis, + created_at: ownerInfo.created_at, + }); + } + } + + // 获取其他用户信息 + 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, + 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 diff --git a/src/lib/user-cache.ts b/src/lib/user-cache.ts new file mode 100644 index 0000000..92517b8 --- /dev/null +++ b/src/lib/user-cache.ts @@ -0,0 +1,72 @@ +/* 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); + } + } + } +} + +// 全局单例 +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 { userInfoCache }; From bc8b9515a897130791a0da3c88744addd651e1d9 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 24 Dec 2025 00:42:28 +0800 Subject: [PATCH 23/40] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=96=B0=E7=94=A8=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 3 +- .../api/admin/data_migration/export/route.ts | 36 +++++++++- .../api/admin/data_migration/import/route.ts | 65 ++++++++++++++++++- src/lib/config.ts | 25 +++++++ src/lib/db.ts | 1 + src/lib/upstash.db.ts | 3 + 6 files changed, 127 insertions(+), 6 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5cdfb98..92943c3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -427,6 +427,7 @@ interface UserConfigProps { role: 'owner' | 'admin' | 'user'; banned: boolean; tags?: string[]; + oidcSub?: string; enabledApis?: string[]; created_at: number; }> | null; @@ -1453,7 +1454,7 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP
{user.username} - {(user as any).oidcSub && ( + {user.oidcSub && ( OIDC 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..7b246ac 100644 --- a/src/app/api/admin/data_migration/import/route.ts +++ b/src/app/api/admin/data_migration/import/route.ts @@ -94,13 +94,73 @@ 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.role === 'owner') { + continue; + } + + // 获取用户的加密密码 + const userData = importData.data.userData[userV2.username]; + const passwordV2 = userData?.passwordV2; + + if (passwordV2) { + // 直接使用加密后的密码创建用户 + 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: userV2.role, + 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 +199,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/lib/config.ts b/src/lib/config.ts index 67e5432..bfbebbf 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -328,6 +328,31 @@ export async function getConfig(): Promise { } 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); + // 不影响主流程,继续执行 + } + } + return cachedConfig; } diff --git a/src/lib/db.ts b/src/lib/db.ts index 7291d6a..06c12ce 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -234,6 +234,7 @@ export class DbManager { role: 'owner' | 'admin' | 'user'; banned: boolean; tags?: string[]; + oidcSub?: string; enabledApis?: string[]; created_at: number; }>; diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index 4044f72..6580d9c 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -504,6 +504,7 @@ export class UpstashRedisStorage implements IStorage { role: 'owner' | 'admin' | 'user'; banned: boolean; tags?: string[]; + oidcSub?: string; enabledApis?: string[]; created_at: number; }>; @@ -528,6 +529,7 @@ export class UpstashRedisStorage implements IStorage { role: 'owner' as const, banned: ownerInfo.banned, tags: ownerInfo.tags, + oidcSub: ownerInfo.oidcSub, enabledApis: ownerInfo.enabledApis, created_at: ownerInfo.created_at, }); @@ -549,6 +551,7 @@ export class UpstashRedisStorage implements IStorage { role: userInfo.role, banned: userInfo.banned, tags: userInfo.tags, + oidcSub: userInfo.oidcSub, enabledApis: userInfo.enabledApis, created_at: userInfo.created_at, }); From 3112e99395eea31bad48c56843f6705e789bdd66 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 24 Dec 2025 00:55:37 +0800 Subject: [PATCH 24/40] =?UTF-8?q?=E6=B3=A8=E5=86=8C=E5=8A=A0=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/register/route.ts | 144 ++++++++++++++++------------------ src/lib/lock.ts | 95 ++++++++++++++++++++++ src/lib/upstash.db.ts | 8 ++ 3 files changed, 170 insertions(+), 77 deletions(-) create mode 100644 src/lib/lock.ts diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index 7240d97..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,89 +94,78 @@ export async function POST(req: 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( - { error: '用户名已存在' }, - { status: 409 } - ); - } - - // 如果开启了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 } - ); - } - } - - // 创建用户 + // 获取用户名锁,防止并发注册 + let releaseLock: (() => void) | null = null; try { - // 1. 使用新版本创建用户(带SHA256加密) - const defaultTags = siteConfig.DefaultUserTags && siteConfig.DefaultUserTags.length > 0 - ? siteConfig.DefaultUserTags - : undefined; + releaseLock = await lockManager.acquire(`register:${username}`); + } catch (error) { + return NextResponse.json( + { error: '服务器繁忙,请稍后重试' }, + { status: 503 } + ); + } - await db.createUserV2(username, password, 'user', defaultTags); - - // 2. 同时在旧版本存储中创建(保持兼容性) - await db.registerUser(username, password); - - // 3. 将用户添加到管理员配置的用户列表中(保持兼容性) - const newUser: any = { - username: username, - role: 'user', - banned: false, - }; - - // 4. 如果配置了默认用户组,分配给新用户 - if (defaultTags) { - newUser.tags = defaultTags; + try { + // 检查用户是否已存在(只检查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 } + ); + } - // 5. 保存更新后的配置 - 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/lib/lock.ts b/src/lib/lock.ts new file mode 100644 index 0000000..1bceac7 --- /dev/null +++ b/src/lib/lock.ts @@ -0,0 +1,95 @@ +/* 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; +} + +export { lockManager }; diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index 6580d9c..bd76783 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -252,6 +252,14 @@ export class UpstashRedisStorage implements IStorage { 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(); From b64ce1c3f2c7c9b750dd58945c2d3858a8c18df6 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 24 Dec 2025 09:47:46 +0800 Subject: [PATCH 25/40] =?UTF-8?q?=E7=A7=81=E4=BA=BA=E5=BD=B1=E5=BA=93?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E4=B8=8D=E5=86=8D=E4=BB=8Ehttp=E5=86=85?= =?UTF-8?q?=E9=83=A8=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/detail/route.ts | 102 +++++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 24 deletions(-) diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 580cd4a..8a03620 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -31,12 +31,12 @@ export async function GET(request: NextRequest) { } 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 { getTMDBImageUrl } = await import('@/lib/tmdb.search'); const { db } = await import('@/lib/db'); metaInfo = getCachedMetaInfo(rootPath); @@ -49,51 +49,105 @@ export async function GET(request: NextRequest) { } } } catch (error) { - console.error('[Detail] 从数据库读取 metainfo 失败:', error); + // 忽略错误 } - // 2. 调用 openlist detail API - const openlistResponse = await fetch( - `${request.headers.get('x-forwarded-proto') || 'http'}://${request.headers.get('host')}/api/openlist/detail?folder=${encodeURIComponent(id)}`, - { - headers: { - Cookie: request.headers.get('cookie') || '', - }, - } + // 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 ); - if (!openlistResponse.ok) { - throw new Error('获取 OpenList 视频详情失败'); + 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 openlistData = await openlistResponse.json(); + const listResponse = await client.listDirectory(folderPath); - if (!openlistData.success) { - throw new Error(openlistData.error || '获取视频详情失败'); + 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'); - // 转换为标准格式(使用懒加载 URL) const result = { source: 'openlist', source_name: '私人影库', - id: openlistData.folder, - title: folderMeta?.title || openlistData.folder, + 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: openlistData.episodes.map((ep: any) => - `/api/openlist/play?folder=${encodeURIComponent(openlistData.folder)}&fileName=${encodeURIComponent(ep.fileName)}` - ), - episodes_titles: openlistData.episodes.map((ep: any) => ep.title || `第${ep.episode}集`), + episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(id)}&fileName=${encodeURIComponent(ep.fileName)}`), + episodes_titles: episodes.map((ep) => ep.title), }; - console.log('[Detail] result.episodes_titles:', result.episodes_titles); - return NextResponse.json(result); } catch (error) { return NextResponse.json( From 8cf8eac55f97c689f3d49cfb093c17f13d0e607d Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 24 Dec 2025 09:55:58 +0800 Subject: [PATCH 26/40] fix typeerror --- src/app/admin/page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 92943c3..130c3a3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -507,6 +507,7 @@ const UserConfig = ({ config, role, refreshConfig, usersV2, userPage, userTotalP enabledApis?: string[]; tags?: string[]; created_at?: number; + oidcSub?: string; }> = !hasOldUserData && usersV2 ? usersV2 : (config?.UserConfig?.Users || []); // 使用 useMemo 计算全选状态,避免每次渲染都重新计算 From 21baa51fe7f183b6725325c8d3795cc7cc18cc5d Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 24 Dec 2025 10:29:26 +0800 Subject: [PATCH 27/40] =?UTF-8?q?=E7=A7=BB=E9=99=A4oidc=E5=AF=B9=E6=97=A7?= =?UTF-8?q?=E7=89=88=E6=B3=A8=E5=86=8C=E7=9A=84=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/auth/oidc/complete-register/route.ts | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/src/app/api/auth/oidc/complete-register/route.ts b/src/app/api/auth/oidc/complete-register/route.ts index 7825353..bd11e0f 100644 --- a/src/app/api/auth/oidc/complete-register/route.ts +++ b/src/app/api/auth/oidc/complete-register/route.ts @@ -161,27 +161,6 @@ export async function POST(request: NextRequest) { // 使用新版本创建用户(带SHA256加密和OIDC绑定) await db.createUserV2(username, randomPassword, 'user', defaultTags, oidcSession.sub); - // 同时在旧版本存储中创建(保持兼容性) - await db.registerUser(username, randomPassword); - - // 将用户添加到配置中(保持兼容性) - const newUser: any = { - username: username, - role: 'user', - banned: false, - oidcSub: oidcSession.sub, // 保存OIDC标识符 - }; - - // 如果配置了默认用户组,分配给新用户 - if (defaultTags) { - newUser.tags = defaultTags; - } - - config.UserConfig.Users.push(newUser); - - // 保存配置 - await db.saveAdminConfig(config); - // 设置认证cookie const response = NextResponse.json({ ok: true, message: '注册成功' }); const cookieValue = await generateAuthCookie(username, 'user'); From 9e6c98a8ff5aa0206970ef37e63e23eb92583337 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 24 Dec 2025 11:53:32 +0800 Subject: [PATCH 28/40] =?UTF-8?q?=E7=A7=81=E4=BA=BA=E5=BD=B1=E5=BA=93?= =?UTF-8?q?=E6=8D=A2=E6=BA=90=E4=B8=8D=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/EpisodeSelector.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index 9a0b4d9..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(() => { @@ -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); From 187e6f1bd432da3f7fb26a795a0c10513fb7b11a Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 24 Dec 2025 20:05:58 +0800 Subject: [PATCH 29/40] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=A7=81=E4=BA=BA?= =?UTF-8?q?=E5=BD=B1=E5=BA=93=E4=BB=8E=E6=90=9C=E7=B4=A2=E8=BF=9B=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E6=92=AD=E6=94=BE=EF=BC=8C=E4=BC=98=E5=8C=96=E5=AE=8C?= =?UTF-8?q?=E7=BB=93=E6=A0=87=E8=AF=86=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/search/route.ts | 57 ++++++++++++++++++++++++++++- src/app/play/page.tsx | 71 ++++++++++++++++++++++++++----------- 2 files changed, 106 insertions(+), 22 deletions(-) diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 229c62c..2b5a0ca 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -36,6 +36,61 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const apiSites = await getAvailableApiSites(authInfo.username); + // 检查是否配置了 OpenList + const hasOpenList = !!(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 +109,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/play/page.tsx b/src/app/play/page.tsx index ea15678..4ba0b0f 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -606,9 +606,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) { @@ -620,23 +620,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'; }; // 播放源优选函数 @@ -1776,7 +1780,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) ); @@ -1829,6 +1835,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); @@ -1849,6 +1864,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); @@ -2841,7 +2865,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); @@ -4408,17 +4432,22 @@ function PlayPageClient() { )} {/* 完结状态标识 */} - {detail && totalEpisodes > 1 && ( - - {isSeriesCompleted(detail) ? '已完结' : '连载中'} - - )} + {detail && totalEpisodes > 1 && (() => { + const status = getSeriesStatus(detail); + if (status === 'unknown') return null; + + return ( + + {status === 'completed' ? '已完结' : '连载中'} + + ); + })()} {/* 第二行:播放器和选集 */} From 558ba174fef21d3d50dbbbe25db99b9bf0d64835 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 24 Dec 2025 20:13:09 +0800 Subject: [PATCH 30/40] =?UTF-8?q?=E6=96=B0=E7=89=88=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=B0=86=E7=AB=99=E9=95=BF=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?=E4=B8=BA=E6=99=AE=E9=80=9A=E8=B4=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/admin/data_migration/import/route.ts | 13 ++++++++++--- src/lib/db.ts | 17 +++++++++-------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/app/api/admin/data_migration/import/route.ts b/src/app/api/admin/data_migration/import/route.ts index 7b246ac..5d2651b 100644 --- a/src/app/api/admin/data_migration/import/route.ts +++ b/src/app/api/admin/data_migration/import/route.ts @@ -98,8 +98,9 @@ export async function POST(req: NextRequest) { if (importData.data.usersV2 && Array.isArray(importData.data.usersV2)) { for (const userV2 of importData.data.usersV2) { try { - // 跳过站长(站长使用环境变量认证) - if (userV2.role === 'owner') { + // 跳过环境变量中的站长(站长使用环境变量认证) + if (userV2.username === process.env.USERNAME) { + console.log(`跳过站长 ${userV2.username} 的导入`); continue; } @@ -108,6 +109,12 @@ export async function POST(req: NextRequest) { 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') { @@ -115,7 +122,7 @@ export async function POST(req: NextRequest) { const createdAt = userV2.created_at || Date.now(); const userInfo: any = { - role: userV2.role, + role: importedRole, banned: userV2.banned, password: passwordV2, created_at: createdAt.toString(), diff --git a/src/lib/db.ts b/src/lib/db.ts index 06c12ce..7bcfb2b 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -274,8 +274,8 @@ export class DbManager { for (const user of users) { try { - // 跳过站长(站长使用环境变量认证,不需要迁移) - if (user.role === 'owner') { + // 跳过环境变量中的站长(站长使用环境变量认证,不需要迁移) + if (user.username === process.env.USERNAME) { console.log(`跳过站长 ${user.username} 的迁移`); continue; } @@ -295,11 +295,6 @@ export class DbManager { password = crypto.randomUUID(); console.log(`用户 ${user.username} (OIDC用户) 使用随机密码迁移`); } - // 如果是站长,使用环境变量中的密码 - else if (user.username === process.env.USERNAME && process.env.PASSWORD) { - password = process.env.PASSWORD; - console.log(`用户 ${user.username} (站长) 使用环境变量密码迁移`); - } // 尝试从旧的存储中获取密码 else { try { @@ -322,11 +317,17 @@ export class DbManager { } } + // 将站长角色转换为普通角色 + const migratedRole = user.role === 'owner' ? 'user' : user.role; + if (user.role === 'owner') { + console.log(`用户 ${user.username} 的角色从 owner 转换为 user`); + } + // 创建新用户 await this.createUserV2( user.username, password, - user.role, + migratedRole, user.tags, (user as any).oidcSub, user.enabledApis From c425db7e0e473a787117ca88a4d2c26b550d7c00 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 24 Dec 2025 20:49:00 +0800 Subject: [PATCH 31/40] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 4 +-- src/lib/redis-base.db.ts | 75 ++++++++++++++++++++++++++++++-------- src/lib/upstash.db.ts | 77 ++++++++++++++++++++++++++++++++-------- src/lib/user-cache.ts | 60 ++++++++++++++++++++++++++++++- 4 files changed, 184 insertions(+), 32 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 130c3a3..0cde3f3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -7982,8 +7982,8 @@ function AdminPageClient() { // 刷新配置和用户列表 const refreshConfigAndUsers = useCallback(async () => { await fetchConfig(); - await fetchUsersV2(); - }, [fetchConfig, fetchUsersV2]); + await fetchUsersV2(userPage); // 保持当前页码 + }, [fetchConfig, fetchUsersV2, userPage]); useEffect(() => { // 首次加载时显示骨架 diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index 94aa0ba..9a93c48 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -372,6 +372,12 @@ export abstract class BaseRedisStorage implements IStorage { score: createdAt, value: userName, })); + + // 如果创建的是站长用户,清除站长存在状态缓存 + if (userName === process.env.USERNAME) { + const { ownerExistenceCache } = await import('./user-cache'); + ownerExistenceCache.delete(userName); + } } // 验证用户密码(新版本) @@ -498,27 +504,68 @@ export abstract class BaseRedisStorage implements IStorage { total: number; }> { // 获取总数 - const total = await this.withRetry(() => this.client.zCard(this.userListKey())); + 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(), offset, offset + limit - 1) + this.client.zRange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1) ); const users = []; - // 如果有站长,确保站长始终在第一位 + // 如果有站长且在第一页,确保站长始终在第一位 if (ownerUsername && offset === 0) { - const ownerInfo = await this.getUserInfoV2(ownerUsername); - if (ownerInfo) { - users.push({ - username: ownerUsername, - role: 'owner' as const, - banned: ownerInfo.banned, - tags: ownerInfo.tags, - created_at: ownerInfo.created_at, - }); - } + // 即使站长不在数据库中,也要添加站长(站长使用环境变量认证) + users.push({ + username: ownerUsername, + role: 'owner' as const, + banned: ownerInfo?.banned || false, + tags: ownerInfo?.tags, + created_at: ownerInfo?.created_at || 0, + }); } // 获取其他用户信息 @@ -557,7 +604,7 @@ export abstract class BaseRedisStorage implements IStorage { // 删除用户信息Hash await this.withRetry(() => this.client.del(this.userInfoKey(userName))); - // 从用户列表中移除 + // 从用���列表中移除 await this.withRetry(() => this.client.zRem(this.userListKey(), userName)); // 删除用户的其他数据(播放记录、收藏等) diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index bd76783..b8ed0b1 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -292,6 +292,12 @@ export class UpstashRedisStorage implements IStorage { score: createdAt, member: userName, })); + + // 如果创建的是站长用户,清除站长存在状态缓存 + if (userName === process.env.USERNAME) { + const { ownerExistenceCache } = await import('./user-cache'); + ownerExistenceCache.delete(userName); + } } // 验证用户密码(新版本) @@ -519,29 +525,70 @@ export class UpstashRedisStorage implements IStorage { total: number; }> { // 获取总数 - const total = await withRetry(() => this.client.zcard(this.userListKey())); + 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(), offset, offset + limit - 1) + this.client.zrange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1) ); const users = []; - // 如果有站长,确保站长始终在第一位 + // 如果有站长且在第一页,确保站长始终在第一位 if (ownerUsername && offset === 0) { - const ownerInfo = await this.getUserInfoV2(ownerUsername); - if (ownerInfo) { - users.push({ - username: ownerUsername, - role: 'owner' as const, - banned: ownerInfo.banned, - tags: ownerInfo.tags, - oidcSub: ownerInfo.oidcSub, - enabledApis: ownerInfo.enabledApis, - created_at: ownerInfo.created_at, - }); - } + // 即使站长不在数据库中,也要添加站长(站长使用环境变量认证) + 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, + }); } // 获取其他用户信息 diff --git a/src/lib/user-cache.ts b/src/lib/user-cache.ts index 92517b8..9c71e18 100644 --- a/src/lib/user-cache.ts +++ b/src/lib/user-cache.ts @@ -55,6 +55,51 @@ class UserInfoCache { } } +// 站长存在状态缓存 +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]; @@ -69,4 +114,17 @@ if (!userInfoCache) { }, 60 * 1000); } -export { 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 { userInfoCache, ownerExistenceCache }; From e11f162b87aa50f9ca5cbcb76062829dd4d65d07 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 24 Dec 2025 20:59:17 +0800 Subject: [PATCH 32/40] =?UTF-8?q?=E4=BA=91=E7=9B=98=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E5=8F=98=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/play/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 4ba0b0f..f913a6c 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -2,7 +2,7 @@ 'use client'; -import { Heart, Search, X } from 'lucide-react'; +import { Heart, Search, X, Cloud } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useRef, useState } from 'react'; @@ -4932,7 +4932,7 @@ function PlayPageClient() { className='flex-shrink-0 hover:opacity-80 transition-opacity' title='搜索网盘资源' > - + {/* 豆瓣评分显示 */} {doubanRating && doubanRating.value > 0 && ( From bc8ac693b429d422ceb95571767d3a1c4a464ad7 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 24 Dec 2025 21:55:27 +0800 Subject: [PATCH 33/40] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=83=AD=E5=8A=9B?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/play/page.tsx | 75 +++++++++++++++++++++++++++++++++++++ src/components/UserMenu.tsx | 39 +++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index f913a6c..3e08715 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -293,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); @@ -2311,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 ? '显示' : '隐藏'); + } + } } }; @@ -3136,6 +3173,7 @@ function PlayPageClient() { antiOverlap: true, synchronousPlayback: danmakuSettingsRef.current.synchronousPlayback, emitter: false, + heatmap: !danmakuHeatmapDisabledRef.current, // 根据禁用状态决定是否创建热力图 // 主题 theme: 'dark', filter: (danmu: any) => { @@ -3216,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超分', @@ -3900,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(); } 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 = () => { + {/* 禁用弹幕热力 */} +
+
+

+ 禁用弹幕热力图 +

+

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

+
+ +
+ {/* 分割线 */}
From c7b9097447d55b988edbcf1c5694db6dbbf6790d Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 24 Dec 2025 23:19:06 +0800 Subject: [PATCH 34/40] fix typeeror --- src/lib/lock.ts | 11 ++++++----- src/lib/user-cache.ts | 24 +++++++++++++----------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/lib/lock.ts b/src/lib/lock.ts index 1bceac7..e98f794 100644 --- a/src/lib/lock.ts +++ b/src/lib/lock.ts @@ -85,11 +85,12 @@ class LockManager { // 全局单例 const globalKey = Symbol.for('__MOONTV_LOCK_MANAGER__'); -let lockManager: LockManager | undefined = (global as any)[globalKey]; +let _lockManager: LockManager | undefined = (global as any)[globalKey]; -if (!lockManager) { - lockManager = new LockManager(); - (global as any)[globalKey] = lockManager; +if (!_lockManager) { + _lockManager = new LockManager(); + (global as any)[globalKey] = _lockManager; } -export { 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/user-cache.ts b/src/lib/user-cache.ts index 9c71e18..f1ec2f3 100644 --- a/src/lib/user-cache.ts +++ b/src/lib/user-cache.ts @@ -102,29 +102,31 @@ class OwnerExistenceCache { // 全局单例 const globalKey = Symbol.for('__MOONTV_USER_INFO_CACHE__'); -let userInfoCache: UserInfoCache | undefined = (global as any)[globalKey]; +let _userInfoCache: UserInfoCache | undefined = (global as any)[globalKey]; -if (!userInfoCache) { - userInfoCache = new UserInfoCache(); - (global as any)[globalKey] = userInfoCache; +if (!_userInfoCache) { + _userInfoCache = new UserInfoCache(); + (global as any)[globalKey] = _userInfoCache; // 每分钟清理一次过期缓存 setInterval(() => { - userInfoCache?.cleanup(); + _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]; +let _ownerExistenceCache: OwnerExistenceCache | undefined = (global as any)[ownerExistenceGlobalKey]; -if (!ownerExistenceCache) { - ownerExistenceCache = new OwnerExistenceCache(); - (global as any)[ownerExistenceGlobalKey] = ownerExistenceCache; +if (!_ownerExistenceCache) { + _ownerExistenceCache = new OwnerExistenceCache(); + (global as any)[ownerExistenceGlobalKey] = _ownerExistenceCache; // 每分钟清理一次过期缓存 setInterval(() => { - ownerExistenceCache?.cleanup(); + _ownerExistenceCache?.cleanup(); }, 60 * 1000); } -export { userInfoCache, ownerExistenceCache }; +export const ownerExistenceCache = _ownerExistenceCache as OwnerExistenceCache; From e26c412c47134ba7dc34563cc3026a2103ad117e Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 24 Dec 2025 23:43:28 +0800 Subject: [PATCH 35/40] =?UTF-8?q?=E4=BF=AE=E6=AD=A3getAllUsers()=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=90=AF=E5=8A=A8=E6=97=B6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E6=89=A7=E8=A1=8C=E5=A4=9A=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/config.ts | 105 ++++++++++++++++++++++----------------- src/lib/redis-base.db.ts | 13 +++-- src/lib/upstash.db.ts | 13 +++-- 3 files changed, 71 insertions(+), 60 deletions(-) diff --git a/src/lib/config.ts b/src/lib/config.ts index bfbebbf..4762d6e 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -55,6 +55,7 @@ export const API_CONFIG = { // 在模块加载时根据环境决定配置来源 let cachedConfig: AdminConfig; +let configInitPromise: Promise | null = null; // 从配置文件补充管理员配置 @@ -303,57 +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); - } - } - adminConfig = configSelfCheck(adminConfig); - cachedConfig = adminConfig; - - // 自动迁移用户(如果配置中有用户且V2存储支持) - // 过滤掉站长后检查是否有需要迁移的用户 - const nonOwnerUsers = adminConfig.UserConfig.Users.filter( - (u) => u.username !== process.env.USERNAME - ); - if (!dbReadFailed && nonOwnerUsers.length > 0) { + // 创建初始化 Promise + configInitPromise = (async () => { + // 读 db + let adminConfig: AdminConfig | null = null; + let dbReadFailed = false; 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); - // 不影响主流程,继续执行 + adminConfig = await db.getAdminConfig(); + } catch (e) { + console.error('获取管理员配置失败:', e); + dbReadFailed = true; } - } - 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 { diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index 9a93c48..e513c67 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -645,13 +645,12 @@ 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) + ); + return users.map(u => ensureString(u)); } // ---------- 管理员配置 ---------- diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index b8ed0b1..a0856ed 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -673,13 +673,12 @@ 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) + ); + return users.map(u => ensureString(u)); } // ---------- 管理员配置 ---------- From 012a3beb5e2ddfe689b853950fad2de50f69a4c4 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 25 Dec 2025 00:00:22 +0800 Subject: [PATCH 36/40] =?UTF-8?q?=E4=BF=AE=E6=AD=A3getAllUsers()=E4=B8=8D?= =?UTF-8?q?=E5=8C=85=E5=90=AB=E7=AB=99=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/debug/watch-room-config/route.ts | 57 -------------------- src/lib/redis-base.db.ts | 10 +++- src/lib/upstash.db.ts | 10 +++- 3 files changed, 18 insertions(+), 59 deletions(-) delete mode 100644 src/app/api/debug/watch-room-config/route.ts 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/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index e513c67..c63320d 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -650,7 +650,15 @@ export abstract class BaseRedisStorage implements IStorage { const users = await this.withRetry(() => this.client.zRange(userListKey, 0, -1) ); - return users.map(u => ensureString(u)); + 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/upstash.db.ts b/src/lib/upstash.db.ts index a0856ed..830f259 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -678,7 +678,15 @@ export class UpstashRedisStorage implements IStorage { const users = await withRetry(() => this.client.zrange(userListKey, 0, -1) ); - return users.map(u => ensureString(u)); + const userList = users.map(u => ensureString(u)); + + // 确保站长在列表中(站长可能不在数据库中,使用环境变量认证) + const ownerUsername = process.env.USERNAME; + if (ownerUsername && !userList.includes(ownerUsername)) { + userList.unshift(ownerUsername); + } + + return userList; } // ---------- 管理员配置 ---------- From 2e545466cbff9d16c44d329b7b9406c36bd0b5ad Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 25 Dec 2025 00:12:03 +0800 Subject: [PATCH 37/40] =?UTF-8?q?=E6=8F=90=E9=AB=98=E6=89=80=E6=9C=89?= =?UTF-8?q?=E5=BC=B9=E5=B9=95=E6=8E=A5=E5=8F=A3=E7=9A=84=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/danmaku/episodes/route.ts | 2 +- src/app/api/danmaku/match/route.ts | 2 +- src/app/api/danmaku/search/route.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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, { From 35fc89b33ebce6acc5ce0fc8d6e16b1470d7c3ba Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 25 Dec 2025 00:39:30 +0800 Subject: [PATCH 38/40] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1enabledApis=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/redis-base.db.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index c63320d..fe98a43 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -342,7 +342,8 @@ export abstract class BaseRedisStorage implements IStorage { password: string, role: 'owner' | 'admin' | 'user' = 'user', tags?: string[], - oidcSub?: string + oidcSub?: string, + enabledApis?: string[] ): Promise { const hashedPassword = await this.hashPassword(password); const createdAt = Date.now(); @@ -359,6 +360,10 @@ export abstract class BaseRedisStorage implements IStorage { userInfo.tags = JSON.stringify(tags); } + if (enabledApis && enabledApis.length > 0) { + userInfo.enabledApis = JSON.stringify(enabledApis); + } + if (oidcSub) { userInfo.oidcSub = oidcSub; // 创建OIDC映射 @@ -400,6 +405,7 @@ export abstract class BaseRedisStorage implements IStorage { banned: boolean; tags?: string[]; oidcSub?: string; + enabledApis?: string[]; created_at: number; } | null> { const userInfo = await this.withRetry(() => @@ -415,6 +421,7 @@ export abstract class BaseRedisStorage implements IStorage { 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), }; } @@ -427,6 +434,7 @@ export abstract class BaseRedisStorage implements IStorage { banned?: boolean; tags?: string[]; oidcSub?: string; + enabledApis?: string[]; } ): Promise { const userInfo: Record = {}; @@ -448,6 +456,15 @@ export abstract class BaseRedisStorage implements IStorage { } } + 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) { @@ -499,6 +516,8 @@ export abstract class BaseRedisStorage implements IStorage { role: 'owner' | 'admin' | 'user'; banned: boolean; tags?: string[]; + oidcSub?: string; + enabledApis?: string[]; created_at: number; }>; total: number; From b0da4e90e7122f1846a90071de62ecd2c9a31a84 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 25 Dec 2025 01:02:22 +0800 Subject: [PATCH 39/40] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=A7=81=E4=BA=BA?= =?UTF-8?q?=E5=BD=B1=E5=BA=93=E5=85=B3=E9=97=AD=E5=90=8E=E8=BF=98=E8=83=BD?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=88=B0=E7=A7=81=E4=BA=BA=E5=BD=B1=E5=BA=93?= =?UTF-8?q?=E7=9A=84=E8=A7=86=E9=A2=91=E5=92=8C=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/detail/route.ts | 10 ++++++++-- src/app/api/openlist/correct/route.ts | 10 ++++++++-- src/app/api/openlist/delete/route.ts | 8 ++++++-- src/app/api/openlist/detail/route.ts | 10 ++++++++-- src/app/api/openlist/list/route.ts | 10 ++++++++-- src/app/api/openlist/play/route.ts | 10 ++++++++-- src/app/api/openlist/refresh-video/route.ts | 10 ++++++++-- src/app/api/openlist/refresh/route.ts | 10 ++++++++-- src/app/api/search/route.ts | 7 ++++++- src/app/api/search/ws/route.ts | 7 ++++++- 10 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 8a03620..f5e993d 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -26,8 +26,14 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { - throw new Error('OpenList 未配置'); + if ( + !openListConfig || + !openListConfig.Enabled || + !openListConfig.URL || + !openListConfig.Username || + !openListConfig.Password + ) { + throw new Error('OpenList 未配置或未启用'); } const rootPath = openListConfig.RootPath || '/'; diff --git a/src/app/api/openlist/correct/route.ts b/src/app/api/openlist/correct/route.ts index e956b2a..beb6e95 100644 --- a/src/app/api/openlist/correct/route.ts +++ b/src/app/api/openlist/correct/route.ts @@ -39,9 +39,15 @@ export async function POST(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { + if ( + !openListConfig || + !openListConfig.Enabled || + !openListConfig.URL || + !openListConfig.Username || + !openListConfig.Password + ) { return NextResponse.json( - { error: 'OpenList 未配置' }, + { error: 'OpenList 未配置或未启用' }, { status: 400 } ); } diff --git a/src/app/api/openlist/delete/route.ts b/src/app/api/openlist/delete/route.ts index 7a04e04..e3fc1f7 100644 --- a/src/app/api/openlist/delete/route.ts +++ b/src/app/api/openlist/delete/route.ts @@ -38,9 +38,13 @@ export async function POST(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL) { + if ( + !openListConfig || + !openListConfig.Enabled || + !openListConfig.URL + ) { return NextResponse.json( - { error: 'OpenList 未配置' }, + { error: 'OpenList 未配置或未启用' }, { status: 400 } ); } diff --git a/src/app/api/openlist/detail/route.ts b/src/app/api/openlist/detail/route.ts index 300afea..68c91ee 100644 --- a/src/app/api/openlist/detail/route.ts +++ b/src/app/api/openlist/detail/route.ts @@ -35,8 +35,14 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { - return NextResponse.json({ error: 'OpenList 未配置' }, { status: 400 }); + if ( + !openListConfig || + !openListConfig.Enabled || + !openListConfig.URL || + !openListConfig.Username || + !openListConfig.Password + ) { + return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 }); } const rootPath = openListConfig.RootPath || '/'; diff --git a/src/app/api/openlist/list/route.ts b/src/app/api/openlist/list/route.ts index 8c67b18..ae32a3a 100644 --- a/src/app/api/openlist/list/route.ts +++ b/src/app/api/openlist/list/route.ts @@ -35,9 +35,15 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { + if ( + !openListConfig || + !openListConfig.Enabled || + !openListConfig.URL || + !openListConfig.Username || + !openListConfig.Password + ) { return NextResponse.json( - { error: 'OpenList 未配置', list: [], total: 0 }, + { error: 'OpenList 未配置或未启用', list: [], total: 0 }, { status: 200 } ); } diff --git a/src/app/api/openlist/play/route.ts b/src/app/api/openlist/play/route.ts index 731db4f..95955b5 100644 --- a/src/app/api/openlist/play/route.ts +++ b/src/app/api/openlist/play/route.ts @@ -31,8 +31,14 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { - return NextResponse.json({ error: 'OpenList 未配置' }, { status: 400 }); + if ( + !openListConfig || + !openListConfig.Enabled || + !openListConfig.URL || + !openListConfig.Username || + !openListConfig.Password + ) { + return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 }); } const rootPath = openListConfig.RootPath || '/'; diff --git a/src/app/api/openlist/refresh-video/route.ts b/src/app/api/openlist/refresh-video/route.ts index 50527e3..1c27877 100644 --- a/src/app/api/openlist/refresh-video/route.ts +++ b/src/app/api/openlist/refresh-video/route.ts @@ -30,8 +30,14 @@ export async function POST(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { - return NextResponse.json({ error: 'OpenList 未配置' }, { status: 400 }); + if ( + !openListConfig || + !openListConfig.Enabled || + !openListConfig.URL || + !openListConfig.Username || + !openListConfig.Password + ) { + return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 }); } const rootPath = openListConfig.RootPath || '/'; diff --git a/src/app/api/openlist/refresh/route.ts b/src/app/api/openlist/refresh/route.ts index 4fa3527..d815371 100644 --- a/src/app/api/openlist/refresh/route.ts +++ b/src/app/api/openlist/refresh/route.ts @@ -43,9 +43,15 @@ export async function POST(request: NextRequest) { const config = await getConfig(); const openListConfig = config.OpenListConfig; - if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) { + if ( + !openListConfig || + !openListConfig.Enabled || + !openListConfig.URL || + !openListConfig.Username || + !openListConfig.Password + ) { return NextResponse.json( - { error: 'OpenList 未配置' }, + { error: 'OpenList 未配置或未启用' }, { status: 400 } ); } diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 2b5a0ca..67c290b 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -37,7 +37,12 @@ export async function GET(request: NextRequest) { const apiSites = await getAvailableApiSites(authInfo.username); // 检查是否配置了 OpenList - const hasOpenList = !!(config.OpenListConfig?.URL && config.OpenListConfig?.Username && config.OpenListConfig?.Password); + const hasOpenList = !!( + config.OpenListConfig?.Enabled && + config.OpenListConfig?.URL && + config.OpenListConfig?.Username && + config.OpenListConfig?.Password + ); // 搜索 OpenList(如果配置了) let openlistResults: any[] = []; diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index c896d41..f404723 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -34,7 +34,12 @@ export async function GET(request: NextRequest) { const apiSites = await getAvailableApiSites(authInfo.username); // 检查是否配置了 OpenList - const hasOpenList = !!(config.OpenListConfig?.URL && config.OpenListConfig?.Username && config.OpenListConfig?.Password); + const hasOpenList = !!( + config.OpenListConfig?.Enabled && + config.OpenListConfig?.URL && + config.OpenListConfig?.Username && + config.OpenListConfig?.Password + ); // 共享状态 let streamClosed = false; From 8999577e80dd4064bdf3a27f95295e21fcf00ddb Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 25 Dec 2025 20:24:21 +0800 Subject: [PATCH 40/40] =?UTF-8?q?=E7=BC=96=E5=86=99v204=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG | 16 ++++++++++++++++ VERSION.txt | 2 +- src/lib/changelog.ts | 22 +++++++++++++++++++++- src/lib/version.ts | 2 +- 4 files changed, 39 insertions(+), 3 deletions(-) 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/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/src/lib/changelog.ts b/src/lib/changelog.ts index e1ea012..7820890 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -11,6 +11,25 @@ export interface ChangelogEntry { export const changelog: ChangelogEntry[] = [ { + version: '204.0.0', + date: '2025-12-20', + added: [ + "新增私人影视库功能(实验性)", + "增加弹幕热力图", + "增加盘搜搜索资源" + ], + changed: [ + "完全重构用户数据存储结构", + "提高所有弹幕接口的超时时间", + "优化完结标识判断", + "即将上映移动端字体大小调整", + "tmdb增加代理支持", + "剧集更新检测改为服务器后台定时执行" + ], + fixed: [ + ] + }, + { version: '203.2.2', date: '2025-12-20', added: [ @@ -21,7 +40,8 @@ export const changelog: ChangelogEntry[] = [ "修复IOS端换集报错播放器初始化失败", "修复超分切换时重复渲染" ] - },{ + }, + { version: '203.2.0', date: '2025-12-19', added: [ 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 };