/* eslint-disable @typescript-eslint/no-explicit-any, no-console */ import { getConfig } from '@/lib/config'; import { generateFolderKey } from '@/lib/crypto'; import { db } from '@/lib/db'; import { OpenListClient } from '@/lib/openlist.client'; import { getCachedMetaInfo, invalidateMetaInfoCache, MetaInfo, setCachedMetaInfo, } from '@/lib/openlist-cache'; import { cleanupOldTasks, completeScanTask, createScanTask, failScanTask, updateScanTaskProgress, } from '@/lib/scan-task'; import { parseSeasonFromTitle } from '@/lib/season-parser'; import { searchTMDB, getTVSeasonDetails } from '@/lib/tmdb.search'; import parseTorrentName from 'parse-torrent-name'; import type { AdminConfig } from '@/lib/admin.types'; /** * 获取根目录列表(兼容新旧配置) */ function getRootPaths(openListConfig: AdminConfig['OpenListConfig']): string[] { if (!openListConfig) { return ['/']; } // 如果有新字段 RootPaths,直接使用 if (openListConfig.RootPaths && openListConfig.RootPaths.length > 0) { return openListConfig.RootPaths; } // 如果只tPath,返回单元素数组 if (openListConfig.RootPath) { return [openListConfig.RootPath]; } // 默认值 return ['/']; } /** * 迁移旧版单根目录配置到多根目录 */ async function migrateToMultiRoot(openListConfig: NonNullable): Promise { const oldRootPath = openListConfig.RootPath!; console.log('[OpenList Migration] 检测到旧版配置,开始迁移...'); // 1. 读取现有 metainfo const metainfoContent = await db.getGlobalValue('video.metainfo'); if (metainfoContent) { const metaInfo: MetaInfo = JSON.parse(metainfoContent); // 2. 迁移 folderName:加上原根路径前缀 for (const [key, info] of Object.entries(metaInfo.folders)) { const oldFolderName = info.folderName; const newFolderName = `${oldRootPath}${oldRootPath.endsWith('/') ? '' : '/'}${oldFolderName}`; info.folderName = newFolderName; console.log(`[Migration] ${oldFolderName} -> ${newFolderName}`); } // 3. 保存迁移后的 metainfo await db.setGlobalValue('video.metainfo', JSON.stringify(metaInfo)); console.log('[OpenList Migration] MetaInfo 迁移完成'); } // 4. 更新配置:RootPath -> RootPaths const config = await getConfig(); config.OpenListConfig!.RootPaths = [oldRootPath]; delete config.OpenListConfig!.RootPath; await db.saveAdminConfig(config); console.log('[OpenList Migration] 配置迁移完成'); } /** * 启动 OpenList 刷新任务 */ export async function startOpenListRefresh(clearMetaInfo: boolean = false): Promise<{ taskId: string }> { const config = await getConfig(); const openListConfig = config.OpenListConfig; if ( !openListConfig || !openListConfig.Enabled || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password ) { throw new Error('OpenList 未配置或未启用'); } const tmdbApiKey = config.SiteConfig.TMDBApiKey; const tmdbProxy = config.SiteConfig.TMDBProxy; const tmdbReverseProxy = config.SiteConfig.TMDBReverseProxy; if (!tmdbApiKey) { throw new Error('TMDB API Key 未配置'); } // 检测是否需要迁移 if (openListConfig.RootPath && !openListConfig.RootPaths) { await migrateToMultiRoot(openListConfig); // 重新加载配置 const newConfig = await getConfig(); Object.assign(openListConfig, newConfig.OpenListConfig); } cleanupOldTasks(); const taskId = createScanTask(); const rootPaths = getRootPaths(openListConfig); // 顺序扫描多个根目录 performMultiRootScan( taskId, openListConfig.URL, rootPaths, tmdbApiKey, tmdbProxy, tmdbReverseProxy, openListConfig.Username, openListConfig.Password, clearMetaInfo, openListConfig.ScanMode || 'hybrid' ).catch((error) => { console.error('[OpenList Refresh] 后台扫描失败:', error); failScanTask(taskId, (error as Error).message); }); return { taskId }; } /** * 扫描多个根目录 */ async function performMultiRootScan( taskId: string, url: string, rootPaths: string[], tmdbApiKey: string, tmdbProxy: string | undefined, tmdbReverseProxy: string | undefined, username: string, password: string, clearMetaInfo: boolean, scanMode: 'torrent' | 'name' | 'hybrid' ): Promise { for (let i = 0; i < rootPaths.length; i++) { const rootPath = rootPaths[i]; console.log(`[OpenList Refresh] 扫描根目录 (${i + 1}/${rootPaths.length}): ${rootPath}`); try { await performScan( taskId, url, rootPath, tmdbApiKey, tmdbProxy, tmdbReverseProxy, username, password, clearMetaInfo && i === 0, // 只在第一个根目录时清除 scanMode ); } catch (error) { console.error(`[OpenList Refresh] 根目录 ${rootPath} 扫描失败:`, error); // 继续扫描其他根目录 } } } /** * 执行扫描任务 */ async function performScan( taskId: string, url: string, rootPath: string, tmdbApiKey: string, tmdbProxy?: string, tmdbReverseProxy?: string, username?: string, password?: string, clearMetaInfo?: boolean, scanMode: 'torrent' | 'name' | 'hybrid' = 'hybrid' ): Promise { const client = new OpenListClient(url, username!, password!); updateScanTaskProgress(taskId, 0, 0); try { 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(); const folders: any[] = []; let currentPage = 1; const pageSize = 100; let total = 0; while (true) { const listResponse = await client.listDirectory(rootPath, currentPage, pageSize, true); if (listResponse.code !== 200) { throw new Error('OpenList 列表获取失败'); } total = listResponse.data.total; const pageFolders = listResponse.data.content.filter((item) => item.is_dir); folders.push(...pageFolders); if (folders.length >= total) { break; } currentPage++; } updateScanTaskProgress(taskId, 0, folders.length); let newCount = 0; let existingCount = 0; let errorCount = 0; const existingKeys = new Set(Object.keys(metaInfo.folders)); const folderNameToKey = new Map(); for (const [key, info] of Object.entries(metaInfo.folders)) { folderNameToKey.set(info.folderName, key); } for (let i = 0; i < folders.length; i++) { const folder = folders[i]; updateScanTaskProgress(taskId, i + 1, folders.length, folder.name); // folderName 存储完整路径(包含根目录) const fullFolderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folder.name}`; if (!clearMetaInfo && folderNameToKey.has(fullFolderPath)) { existingCount++; continue; } const folderKey = generateFolderKey(fullFolderPath, existingKeys); existingKeys.add(folderKey); try { let searchQuery: string; let seasonNumber: number | null = null; let year: number | null = null; let searchResult: any; if (scanMode === 'torrent' || scanMode === 'hybrid') { const torrentInfo = parseTorrentName(folder.name); searchQuery = torrentInfo.title || folder.name; seasonNumber = torrentInfo.season || null; year = torrentInfo.year || null; console.log(`[OpenList Refresh] 种子库模式 - 文件夹: ${folder.name}`); console.log(`[OpenList Refresh] 解析结果 - 标题: ${searchQuery}, 季度: ${seasonNumber}, 年份: ${year}`); searchResult = await searchTMDB(tmdbApiKey, searchQuery, tmdbProxy, year || undefined, tmdbReverseProxy); } if (scanMode === 'name' || (scanMode === 'hybrid' && (!searchResult || searchResult.code !== 200 || !searchResult.result))) { const seasonInfo = parseSeasonFromTitle(folder.name); searchQuery = seasonInfo.cleanTitle || folder.name; seasonNumber = seasonInfo.seasonNumber; year = seasonInfo.year; console.log(`[OpenList Refresh] 名字匹配模式 - 文件夹: ${folder.name}`); console.log(`[OpenList Refresh] 清理后标题: ${searchQuery}, 季度: ${seasonNumber}, 年份: ${year}`); searchResult = await searchTMDB(tmdbApiKey, searchQuery, tmdbProxy, year || undefined, tmdbReverseProxy); } if (searchResult.code === 200 && searchResult.result) { const result = searchResult.result; const folderInfo: any = { folderName: fullFolderPath, tmdb_id: result.id, title: result.title || result.name || folder.name, poster_path: result.poster_path, release_date: result.release_date || result.first_air_date || '', overview: result.overview, vote_average: result.vote_average, media_type: result.media_type, last_updated: Date.now(), failed: false, }; if (result.media_type === 'tv' && seasonNumber) { try { const seasonDetails = await getTVSeasonDetails( tmdbApiKey, result.id, seasonNumber, tmdbProxy, tmdbReverseProxy ); if (seasonDetails.code === 200 && seasonDetails.season) { folderInfo.season_number = seasonDetails.season.season_number; folderInfo.season_name = seasonDetails.season.name; if (seasonDetails.season.season_number > 1) { folderInfo.title = `${folderInfo.title} ${seasonDetails.season.name}`; } if (seasonDetails.season.poster_path) { folderInfo.poster_path = seasonDetails.season.poster_path; } if (seasonDetails.season.overview) { folderInfo.overview = seasonDetails.season.overview; } if (seasonDetails.season.air_date) { folderInfo.release_date = seasonDetails.season.air_date; } } else { console.warn(`[OpenList Refresh] 获取季度 ${seasonNumber} 详情失败`); folderInfo.season_number = seasonNumber; } } catch (error) { console.error(`[OpenList Refresh] 获取季度详情异常:`, error); folderInfo.season_number = seasonNumber; } } metaInfo.folders[folderKey] = folderInfo; newCount++; } else { metaInfo.folders[folderKey] = { folderName: fullFolderPath, tmdb_id: 0, title: folder.name, poster_path: null, release_date: '', overview: '', vote_average: 0, media_type: 'movie', last_updated: Date.now(), failed: true, }; errorCount++; } await new Promise((resolve) => setTimeout(resolve, 300)); } catch (error) { console.error(`[OpenList Refresh] 处理文件夹失败: ${folder.name}`, error); metaInfo.folders[folderKey] = { folderName: fullFolderPath, 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++; } } metaInfo.last_refresh = Date.now(); const metainfoContent = JSON.stringify(metaInfo); await db.setGlobalValue('video.metainfo', metainfoContent); invalidateMetaInfoCache(); setCachedMetaInfo(metaInfo); const config = await getConfig(); config.OpenListConfig!.LastRefreshTime = Date.now(); config.OpenListConfig!.ResourceCount = Object.keys(metaInfo.folders).length; await db.saveAdminConfig(config); completeScanTask(taskId, { total: folders.length, new: newCount, existing: existingCount, errors: errorCount, }); } catch (error) { console.error('[OpenList Refresh] 扫描失败:', error); failScanTask(taskId, (error as Error).message); throw error; } }