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