diff --git a/src/app/api/cron/[password]/route.ts b/src/app/api/cron/[password]/route.ts index 2d39c9c..d5c79bc 100644 --- a/src/app/api/cron/[password]/route.ts +++ b/src/app/api/cron/[password]/route.ts @@ -137,6 +137,19 @@ async function refreshRecordAndFavorites() { if (process.env.USERNAME && !users.includes(process.env.USERNAME)) { users.push(process.env.USERNAME); } + + // 环境变量控制是否跳过特定源(默认为 false,即默认跳过) + const includeSpecialSources = process.env.CRON_INCLUDE_SPECIAL_SOURCES === 'true'; + + // 检查是否应该跳过该源 + const shouldSkipSource = (source: string): boolean => { + if (includeSpecialSources) { + return false; // 如果开启了包含特殊源,则不跳过任何源 + } + // 默认跳过 emby 开头、openlist、xiaoya 和 live 开头的源 + return source.startsWith('emby') || source === 'openlist' || source === 'xiaoya' || source.startsWith('live'); + }; + // 函数级缓存:key 为 `${source}+${id}`,值为 Promise const detailCache = new Map>(); @@ -186,6 +199,13 @@ async function refreshRecordAndFavorites() { continue; } + // 检查是否应该跳过该源 + if (shouldSkipSource(source)) { + console.log(`跳过播放记录 (源被过滤): ${key}`); + processedRecords++; + continue; + } + const detail = await getDetail(source, id, record.title); if (!detail) { console.warn(`跳过无法获取详情的播放记录: ${key}`); @@ -242,6 +262,13 @@ async function refreshRecordAndFavorites() { continue; } + // 检查是否应该跳过该源 + if (shouldSkipSource(source)) { + console.log(`跳过收藏 (源被过滤): ${key}`); + processedFavorites++; + continue; + } + const favDetail = await getDetail(source, id, fav.title); if (!favDetail) { console.warn(`跳过无法获取详情的收藏: ${key}`); diff --git a/src/lib/fetchVideoDetail.ts b/src/lib/fetchVideoDetail.ts index ca61bd9..32168bf 100644 --- a/src/lib/fetchVideoDetail.ts +++ b/src/lib/fetchVideoDetail.ts @@ -2,6 +2,7 @@ import { getAvailableApiSites } from '@/lib/config'; import { SearchResult } from '@/lib/types'; import { getDetailFromApi, searchFromApi } from './downstream'; +import { getSpecialSourceDetail, isSpecialSource } from './special-sources-detail'; interface FetchVideoDetailOptions { source: string; @@ -11,14 +12,24 @@ interface FetchVideoDetailOptions { /** * 根据 source 与 id 获取视频详情。 - * 1. 若传入 fallbackTitle,则先调用 /api/search 搜索精确匹配。 - * 2. 若搜索未命中或未提供 fallbackTitle,则直接调用 /api/detail。 + * 1. 如果是特殊源(emby、openlist、xiaoya),直接调用对应的获取函数。 + * 2. 若传入 fallbackTitle,则先调用 /api/search 搜索精确匹配。 + * 3. 若搜索未命中或未提供 fallbackTitle,则直接调用 /api/detail。 */ export async function fetchVideoDetail({ source, id, fallbackTitle = '', }: FetchVideoDetailOptions): Promise { + // 检查是否是特殊源(emby、openlist、xiaoya) + if (isSpecialSource(source)) { + const detail = await getSpecialSourceDetail(source, id); + if (detail) { + return detail; + } + // 如果特殊源返回 null,继续使用标准流程 + } + // 优先通过搜索接口查找精确匹配 const apiSites = await getAvailableApiSites(); const apiSite = apiSites.find((site) => site.key === source); diff --git a/src/lib/special-sources-detail.ts b/src/lib/special-sources-detail.ts new file mode 100644 index 0000000..2d243f8 --- /dev/null +++ b/src/lib/special-sources-detail.ts @@ -0,0 +1,425 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { getConfig } from '@/lib/config'; +import { SearchResult } from '@/lib/types'; + +/** + * 获取 Emby 源的视频详情 + */ +export async function getEmbyDetail( + source: string, + id: string +): Promise { + const config = await getConfig(); + + // 检查是否有启用的 Emby 源 + if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) { + throw new Error('Emby 未配置或未启用'); + } + + // 解析 embyKey + let embyKey: string | undefined; + if (source.startsWith('emby_')) { + embyKey = source.substring(5); // 'emby_'.length = 5 + } + + // 使用 EmbyManager 获取客户端和配置 + const { embyManager } = await import('@/lib/emby-manager'); + const sources = await embyManager.getEnabledSources(); + const sourceConfig = sources.find((s) => s.key === embyKey); + const sourceName = sourceConfig?.name || 'Emby'; + + const client = await embyManager.getClient(embyKey); + + // 获取媒体详情 + const item = await client.getItem(id); + + // 根据类型处理 + if (item.Type === 'Movie') { + // 电影 + const subtitles = client.getSubtitles(item); + + return { + source: source, // 保持与请求一致(emby 或 emby_key) + source_name: sourceName, + id: item.Id, + title: item.Name, + poster: client.getImageUrl(item.Id, 'Primary'), + year: item.ProductionYear?.toString() || '', + douban_id: 0, + desc: item.Overview || '', + episodes: [await client.getStreamUrl(item.Id)], + episodes_titles: [item.Name], + subtitles: subtitles.length > 0 ? [subtitles] : [], + proxyMode: false, + }; + } else if (item.Type === 'Series') { + // 剧集 - 获取所有季和集 + const seasons = await client.getSeasons(item.Id); + const allEpisodes: any[] = []; + + for (const season of seasons) { + const episodes = await client.getEpisodes(item.Id, season.Id); + allEpisodes.push(...episodes); + } + + // 按季和集排序 + allEpisodes.sort((a, b) => { + if (a.ParentIndexNumber !== b.ParentIndexNumber) { + return (a.ParentIndexNumber || 0) - (b.ParentIndexNumber || 0); + } + return (a.IndexNumber || 0) - (b.IndexNumber || 0); + }); + + return { + source: source, // 保持与请求一致(emby 或 emby_key) + source_name: sourceName, + id: item.Id, + title: item.Name, + poster: client.getImageUrl(item.Id, 'Primary'), + year: item.ProductionYear?.toString() || '', + douban_id: 0, + desc: item.Overview || '', + episodes: await Promise.all( + allEpisodes.map((ep) => client.getStreamUrl(ep.Id)) + ), + episodes_titles: allEpisodes.map((ep) => { + const seasonNum = ep.ParentIndexNumber || 1; + const episodeNum = ep.IndexNumber || 1; + return `S${seasonNum.toString().padStart(2, '0')}E${episodeNum.toString().padStart(2, '0')}`; + }), + subtitles: allEpisodes.map((ep) => client.getSubtitles(ep)), + proxyMode: false, + }; + } else { + throw new Error('不支持的媒体类型'); + } +} + +/** + * 获取 OpenList 源的视频详情 + */ +export async function getOpenListDetail( + id: string +): Promise { + const config = await getConfig(); + const openListConfig = config.OpenListConfig; + + if ( + !openListConfig || + !openListConfig.Enabled || + !openListConfig.URL || + !openListConfig.Username || + !openListConfig.Password + ) { + throw new Error('OpenList 未配置或未启用'); + } + + const rootPath = openListConfig.RootPath || '/'; + + // 1. 读取 metainfo 获取元数据 + let metaInfo: any = null; + let folderMeta: any = null; + try { + const { getCachedMetaInfo, setCachedMetaInfo } = await import( + '@/lib/openlist-cache' + ); + const { db } = await import('@/lib/db'); + + metaInfo = getCachedMetaInfo(); + + if (!metaInfo) { + const metainfoJson = await db.getGlobalValue('video.metainfo'); + if (metainfoJson) { + metaInfo = JSON.parse(metainfoJson); + setCachedMetaInfo(metaInfo); + } + } + + // 使用 key 查找文件夹信息 + folderMeta = metaInfo?.folders?.[id]; + if (!folderMeta) { + throw new Error('未找到该视频信息'); + } + } catch (error) { + throw new Error('读取视频信息失败: ' + (error as Error).message); + } + + // 使用 folderName 构建实际路径 + const folderName = folderMeta.folderName; + const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`; + + // 2. 直接调用 OpenList 客户端获取视频列表 + const { OpenListClient } = await import('@/lib/openlist.client'); + const { getCachedVideoInfo, setCachedVideoInfo } = await import( + '@/lib/openlist-cache' + ); + const { parseVideoFileName } = await import('@/lib/video-parser'); + + const client = new OpenListClient( + openListConfig.URL, + openListConfig.Username, + openListConfig.Password + ); + + let videoInfo = getCachedVideoInfo(folderPath); + + // 获取所有分页的视频文件 + const allFiles: any[] = []; + let currentPage = 1; + const pageSize = 100; + let total = 0; + + while (true) { + const listResponse = await client.listDirectory( + folderPath, + currentPage, + pageSize + ); + + if (listResponse.code !== 200) { + throw new Error('OpenList 列表获取失败'); + } + + total = listResponse.data.total; + allFiles.push(...listResponse.data.content); + + if (allFiles.length >= total) { + break; + } + + currentPage++; + } + + const videoExtensions = [ + '.mp4', + '.mkv', + '.avi', + '.m3u8', + '.flv', + '.ts', + '.mov', + '.wmv', + '.webm', + '.rmvb', + '.rm', + '.mpg', + '.mpeg', + '.3gp', + '.f4v', + '.m4v', + '.vob', + ]; + const videoFiles = allFiles.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', + isOVA: parsed.isOVA, + }; + } + 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', + 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.isOVA + ? `OVA ${episodeInfo.episode}` + : `第${episodeInfo.episode}集`; + } + if (!displayTitle) { + displayTitle = file.name; + } + return { + fileName: file.name, + episode: episodeInfo.episode || 0, + season: episodeInfo.season, + title: displayTitle, + isOVA: episodeInfo.isOVA, + }; + }) + .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'); + + return { + source: 'openlist', + source_name: '私人影库', + id: id, + title: folderMeta?.title || folderName, + poster: folderMeta?.poster_path + ? getTMDBImageUrl(folderMeta.poster_path) + : '', + year: folderMeta?.release_date + ? folderMeta.release_date.split('-')[0] + : '', + douban_id: 0, + desc: folderMeta?.overview || '', + episodes: episodes.map( + (ep) => + `/api/openlist/play?folder=${encodeURIComponent(folderName)}&fileName=${encodeURIComponent(ep.fileName)}` + ), + episodes_titles: episodes.map((ep) => ep.title), + proxyMode: false, // openlist 源不使用代理模式 + }; +} + +/** + * 获取 Xiaoya 源的视频详情 + */ +export async function getXiaoyaDetail(id: string): Promise { + const config = await getConfig(); + const xiaoyaConfig = config.XiaoyaConfig; + + if ( + !xiaoyaConfig || + !xiaoyaConfig.Enabled || + !xiaoyaConfig.ServerURL + ) { + throw new Error('小雅未配置或未启用'); + } + + const { XiaoyaClient } = await import('@/lib/xiaoya.client'); + const { getXiaoyaMetadata, getXiaoyaEpisodes } = await import( + '@/lib/xiaoya-metadata' + ); + const { base58Decode, base58Encode } = await import('@/lib/utils'); + + const client = new XiaoyaClient( + xiaoyaConfig.ServerURL, + xiaoyaConfig.Username, + xiaoyaConfig.Password, + xiaoyaConfig.Token + ); + + // 对id进行base58解码得到目录路径 + let decodedDirPath: string; + try { + decodedDirPath = base58Decode(id); + } catch (decodeError) { + throw new Error('无效的视频ID'); + } + + // 验证解码后的路径 + if (!decodedDirPath || decodedDirPath.trim() === '') { + throw new Error('解码后的路径为空'); + } + + // 获取元数据 + const metadata = await getXiaoyaMetadata( + client, + decodedDirPath, + config.SiteConfig.TMDBApiKey, + config.SiteConfig.TMDBProxy + ); + + // 获取集数列表 + const episodes = await getXiaoyaEpisodes(client, decodedDirPath); + + return { + source: 'xiaoya', + source_name: '小雅', + id: id, // 保持编码后的目录id + title: metadata.title, + poster: metadata.poster || '', + year: metadata.year || '', + douban_id: 0, + desc: metadata.plot || '', + episodes: episodes.map( + (ep) => + `/api/xiaoya/play?path=${encodeURIComponent(base58Encode(ep.path))}` + ), + episodes_titles: episodes.map((ep) => ep.title), + subtitles: [], + proxyMode: false, + metadataSource: metadata.source, + }; +} + +/** + * 统一的特殊源详情获取接口 + * 根据 source 类型自动调用对应的获取函数 + */ +export async function getSpecialSourceDetail( + source: string, + id: string +): Promise { + try { + // Emby 源(包括 emby 和 emby_xxx) + if (source === 'emby' || source.startsWith('emby_')) { + return await getEmbyDetail(source, id); + } + + // OpenList 源 + if (source === 'openlist') { + return await getOpenListDetail(id); + } + + // Xiaoya 源 + if (source === 'xiaoya') { + return await getXiaoyaDetail(id); + } + + // 不是特殊源,返回 null + return null; + } catch (error) { + console.error(`获取特殊源详情失败 (${source}+${id}):`, error); + throw error; + } +} + +/** + * 检查是否是特殊源 + */ +export function isSpecialSource(source: string): boolean { + return ( + source === 'emby' || + source.startsWith('emby_') || + source === 'openlist' || + source === 'xiaoya' + ); +}