优化定时任务自动刷新收藏
This commit is contained in:
@@ -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<VideoDetail | null>
|
||||
const detailCache = new Map<string, Promise<SearchResult | null>>();
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<SearchResult> {
|
||||
// 检查是否是特殊源(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);
|
||||
|
||||
425
src/lib/special-sources-detail.ts
Normal file
425
src/lib/special-sources-detail.ts
Normal file
@@ -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<SearchResult> {
|
||||
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<SearchResult> {
|
||||
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<SearchResult> {
|
||||
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<SearchResult | null> {
|
||||
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'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user