diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index b26f4e5..04d6825 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -121,6 +121,7 @@ export async function GET(request: NextRequest) { season: parsed.season, title: parsed.title, parsed_from: 'filename', + isOVA: parsed.isOVA, }; } setCachedVideoInfo(folderPath, videoInfo); @@ -131,20 +132,26 @@ export async function GET(request: NextRequest) { const parsed = parseVideoFileName(file.name); let episodeInfo; if (parsed.episode) { - episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename' }; + episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename', isOVA: parsed.isOVA }; } 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}集`; + displayTitle = episodeInfo.isOVA ? `OVA ${episodeInfo.episode}` : `第${episodeInfo.episode}集`; } if (!displayTitle) { displayTitle = file.name; } - return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle }; + return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle, isOVA: episodeInfo.isOVA }; }) - .sort((a, b) => a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName)); + .sort((a, b) => { + // OVA 排在最后 + if (a.isOVA && !b.isOVA) return 1; + if (!a.isOVA && b.isOVA) return -1; + // 都是 OVA 或都不是 OVA,按集数排序 + return a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName); + }); // 3. 从 metainfo 中获取元数据 const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); diff --git a/src/app/api/openlist/cms-proxy/[token]/route.ts b/src/app/api/openlist/cms-proxy/[token]/route.ts index 7f5e6ab..b386b39 100644 --- a/src/app/api/openlist/cms-proxy/[token]/route.ts +++ b/src/app/api/openlist/cms-proxy/[token]/route.ts @@ -315,6 +315,7 @@ async function handleDetail( season: parsed.season, title: parsed.title, parsed_from: 'filename', + isOVA: parsed.isOVA, }; } setCachedVideoInfo(folderPath, videoInfo); @@ -332,13 +333,13 @@ async function handleDetail( const parsed = parseVideoFileName(file.name); let episodeInfo; if (parsed.episode) { - episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename' }; + episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename', isOVA: parsed.isOVA }; } 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}集`; + displayTitle = episodeInfo.isOVA ? `OVA ${episodeInfo.episode}` : `第${episodeInfo.episode}集`; } if (!displayTitle) { displayTitle = file.name; @@ -353,9 +354,16 @@ async function handleDetail( season: episodeInfo.season, title: displayTitle, playUrl, + isOVA: episodeInfo.isOVA, }; }) - .sort((a, b) => a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName)); + .sort((a, b) => { + // OVA 排在最后 + if (a.isOVA && !b.isOVA) return 1; + if (!a.isOVA && b.isOVA) return -1; + // 都是 OVA 或都不是 OVA,按集数排序 + return a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName); + }); // 转换为 CMS vod_play_url 格式 // 格式:第1集$url1#第2集$url2#第3集$url3 diff --git a/src/app/api/openlist/detail/route.ts b/src/app/api/openlist/detail/route.ts index 68c91ee..d0503e0 100644 --- a/src/app/api/openlist/detail/route.ts +++ b/src/app/api/openlist/detail/route.ts @@ -127,6 +127,7 @@ export async function GET(request: NextRequest) { season: parsed.season, title: parsed.title, parsed_from: 'filename', + isOVA: parsed.isOVA, }; } @@ -174,6 +175,7 @@ export async function GET(request: NextRequest) { season: parsed.season, title: parsed.title, parsed_from: 'filename', + isOVA: parsed.isOVA, }; } else { // 如果解析失败,尝试从 videoInfo 获取 @@ -192,8 +194,7 @@ export async function GET(request: NextRequest) { // 优先使用解析出的标题,其次是"第X集"格式,最后才是文件名 let displayTitle = episodeInfo.title; if (!displayTitle && episodeInfo.episode) { - // 支持小数集数显示 - displayTitle = `第${episodeInfo.episode}集`; + displayTitle = episodeInfo.isOVA ? `OVA ${episodeInfo.episode}` : `第${episodeInfo.episode}集`; } if (!displayTitle) { displayTitle = file.name; @@ -205,9 +206,13 @@ export async function GET(request: NextRequest) { season: episodeInfo.season, title: displayTitle, size: file.size, + isOVA: episodeInfo.isOVA, }; }) .sort((a, b) => { + // OVA 排在最后 + if (a.isOVA && !b.isOVA) return 1; + if (!a.isOVA && b.isOVA) return -1; // 确保排序稳定,即使 episode 相同也按文件名排序 if (a.episode !== b.episode) { return a.episode - b.episode; diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts index 7ef7ccb..a50b202 100644 --- a/src/app/api/source-detail/route.ts +++ b/src/app/api/source-detail/route.ts @@ -214,6 +214,7 @@ export async function GET(request: NextRequest) { season: parsed.season, title: parsed.title, parsed_from: 'filename', + isOVA: parsed.isOVA, }; } setCachedVideoInfo(folderPath, videoInfo); @@ -224,20 +225,26 @@ export async function GET(request: NextRequest) { const parsed = parseVideoFileName(file.name); let episodeInfo; if (parsed.episode) { - episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename' }; + episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename', isOVA: parsed.isOVA }; } 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}集`; + displayTitle = episodeInfo.isOVA ? `OVA ${episodeInfo.episode}` : `第${episodeInfo.episode}集`; } if (!displayTitle) { displayTitle = file.name; } - return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle }; + return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle, isOVA: episodeInfo.isOVA }; }) - .sort((a, b) => a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName)); + .sort((a, b) => { + // OVA 排在最后 + if (a.isOVA && !b.isOVA) return 1; + if (!a.isOVA && b.isOVA) return -1; + // 都是 OVA 或都不是 OVA,按集数排序 + return a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName); + }); // 3. 从 metainfo 中获取元数据 const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); diff --git a/src/components/DownloadEpisodeSelector.tsx b/src/components/DownloadEpisodeSelector.tsx index c7579c5..46f6de8 100644 --- a/src/components/DownloadEpisodeSelector.tsx +++ b/src/components/DownloadEpisodeSelector.tsx @@ -291,6 +291,10 @@ const DownloadEpisodeSelector: React.FC = ({ if (!title) { return episodeNumber; } + // 如果是 OVA 格式,直接返回完整标题 + if (title.match(/^OVA\s+\d+/i)) { + return title; + } // 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字 const match = title.match(/(?:第)?(\d+)(?:集|话)/); if (match) { diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index d283d7d..484ce8a 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -676,6 +676,10 @@ const EpisodeSelector: React.FC = ({ if (!title) { return episodeNumber; } + // 如果是 OVA 格式,直接返回完整标题 + if (title.match(/^OVA\s+\d+/i)) { + return title; + } // 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字(支持小数) const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/); if (match) { diff --git a/src/lib/openlist-cache.ts b/src/lib/openlist-cache.ts index 1816c8c..18b51d4 100644 --- a/src/lib/openlist-cache.ts +++ b/src/lib/openlist-cache.ts @@ -43,6 +43,7 @@ export interface VideoInfo { season?: number; title?: string; parsed_from: 'videoinfo' | 'filename'; + isOVA?: boolean; }; }; last_updated: number; diff --git a/src/lib/video-parser.ts b/src/lib/video-parser.ts index 0f3d723..dc86e80 100644 --- a/src/lib/video-parser.ts +++ b/src/lib/video-parser.ts @@ -4,6 +4,7 @@ export interface ParsedVideoInfo { episode?: number; season?: number; title?: string; + isOVA?: boolean; } /** @@ -27,27 +28,29 @@ export function parseVideoFileName(fileName: string): ParsedVideoInfo { // 降级方案:使用多种正则模式提取集数 // 按优先级排序:更具体的模式优先 - const patterns = [ + const patterns: Array<{ pattern: RegExp; isOVA?: boolean }> = [ + // OVA01, OVA 01, ova01, ova 01 (OVA特殊处理) - 最优先 + { pattern: /OVA\s*(\d+(?:\.\d+)?)/i, isOVA: true }, // S01E01, s01e01, S01E01.5 (支持小数) - 最具体 - /[Ss]\d+[Ee](\d+(?:\.\d+)?)/, - // [01], (01), [01.5], (01.5) (支持小数) - 很具体 - /[\[\(](\d+(?:\.\d+)?)[\]\)]/, + { pattern: /[Ss]\d+[Ee](\d+(?:\.\d+)?)/ }, + // [01], (01), [01.5], (01.5) (支持小数,但要排除中文括号内容) - 很具体 + { pattern: /[\[\(](\d+(?:\.\d+)?)[\]\)]/ }, // E01, E1, e01, e1, E01.5 (支持小数) - /[Ee](\d+(?:\.\d+)?)/, + { pattern: /[Ee](\d+(?:\.\d+)?)/ }, // 第01集, 第1集, 第01话, 第1话, 第1.5集 (支持小数) - /第(\d+(?:\.\d+)?)[集话]/, + { pattern: /第(\d+(?:\.\d+)?)[集话]/ }, // _01_, -01-, _01.5_, -01.5- (支持小数) - /[_\-](\d+(?:\.\d+)?)[_\-]/, + { pattern: /[_\-](\d+(?:\.\d+)?)[_\-]/ }, // 01.mp4, 001.mp4, 01.5.mp4 (纯数字开头,支持小数) - 最不具体 - /^(\d+(?:\.\d+)?)[^\d.]/, + { pattern: /^(\d+(?:\.\d+)?)[^\d.]/ }, ]; - for (const pattern of patterns) { + for (const { pattern, isOVA } of patterns) { const match = fileName.match(pattern); if (match && match[1]) { const episode = parseFloat(match[1]); if (episode > 0 && episode < 10000) { // 合理的集数范围 - return { episode }; + return { episode, isOVA }; } } }