优化定时任务自动刷新收藏

This commit is contained in:
mtvpls
2026-01-16 23:26:19 +08:00
parent c8ec2f14b6
commit 7d05246577
3 changed files with 465 additions and 2 deletions

View File

@@ -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}`);

View File

@@ -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);

View 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'
);
}