Files
MoonTVPlus/src/app/api/source-detail/route.ts

335 lines
12 KiB
TypeScript
Raw Normal View History

2025-12-29 22:58:41 +08:00
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
export const runtime = 'nodejs';
/**
* source id
* API专门用于play页面快速获取当前源的详情
*/
export async function GET(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const sourceCode = searchParams.get('source');
const title = searchParams.get('title'); // 用于搜索的标题
if (!id || !sourceCode || !title) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
2026-01-08 00:14:07 +08:00
// 特殊处理 emby 源(支持多源)
if (sourceCode === 'emby' || sourceCode.startsWith('emby_')) {
2026-01-03 01:04:38 +08:00
try {
const config = await getConfig();
2026-01-08 00:14:07 +08:00
// 检查是否有启用的 Emby 源
if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) {
2026-01-03 01:04:38 +08:00
throw new Error('Emby 未配置或未启用');
}
2026-01-08 00:14:07 +08:00
// 解析 embyKey
let embyKey: string | undefined;
if (sourceCode.startsWith('emby_')) {
embyKey = sourceCode.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);
2026-01-03 01:04:38 +08:00
// 获取媒体详情
const item = await client.getItem(id);
// 根据类型处理
if (item.Type === 'Movie') {
// 电影
const subtitles = client.getSubtitles(item);
const result = {
2026-01-08 00:14:07 +08:00
source: sourceCode, // 保持与请求一致emby 或 emby_key
source_name: sourceName,
2026-01-03 01:04:38 +08:00
id: item.Id,
title: item.Name,
poster: client.getImageUrl(item.Id, 'Primary'),
year: item.ProductionYear?.toString() || '',
douban_id: 0,
desc: item.Overview || '',
2026-01-08 19:45:39 +08:00
episodes: [await client.getStreamUrl(item.Id)],
2026-01-03 01:04:38 +08:00
episodes_titles: [item.Name],
subtitles: subtitles.length > 0 ? [subtitles] : [],
proxyMode: false,
};
return NextResponse.json(result);
} 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);
});
const result = {
2026-01-08 00:14:07 +08:00
source: sourceCode, // 保持与请求一致emby 或 emby_key
source_name: sourceName,
2026-01-03 01:04:38 +08:00
id: item.Id,
title: item.Name,
poster: client.getImageUrl(item.Id, 'Primary'),
year: item.ProductionYear?.toString() || '',
douban_id: 0,
desc: item.Overview || '',
2026-01-08 19:45:39 +08:00
episodes: await Promise.all(allEpisodes.map((ep) => client.getStreamUrl(ep.Id))),
2026-01-03 01:04:38 +08:00
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,
};
return NextResponse.json(result);
} else {
throw new Error('不支持的媒体类型');
}
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
2025-12-29 22:58:41 +08:00
// 特殊处理 openlist 源 - 直接调用 /api/detail
if (sourceCode === 'openlist') {
try {
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');
2026-01-10 10:25:14 +08:00
metaInfo = getCachedMetaInfo();
2025-12-29 22:58:41 +08:00
if (!metaInfo) {
const metainfoJson = await db.getGlobalValue('video.metainfo');
if (metainfoJson) {
metaInfo = JSON.parse(metainfoJson);
2026-01-10 10:25:14 +08:00
setCachedMetaInfo(metaInfo);
2025-12-29 22:58:41 +08:00
}
}
// 使用 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);
2026-01-02 16:49:51 +08:00
// 获取所有分页的视频文件
const allFiles: any[] = [];
let currentPage = 1;
const pageSize = 100;
let total = 0;
2025-12-29 22:58:41 +08:00
2026-01-02 16:49:51 +08:00
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++;
2025-12-29 22:58:41 +08:00
}
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob'];
2026-01-02 16:49:51 +08:00
const videoFiles = allFiles.filter((item) => {
2025-12-29 22:58:41 +08:00
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',
2026-01-03 16:40:39 +08:00
isOVA: parsed.isOVA,
2025-12-29 22:58:41 +08:00
};
}
setCachedVideoInfo(folderPath, videoInfo);
}
const episodes = videoFiles
.map((file, index) => {
const parsed = parseVideoFileName(file.name);
let episodeInfo;
if (parsed.episode) {
2026-01-03 16:40:39 +08:00
episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename', isOVA: parsed.isOVA };
2025-12-29 22:58:41 +08:00
} else {
episodeInfo = videoInfo!.episodes[file.name] || { episode: index + 1, season: undefined, title: undefined, parsed_from: 'filename' };
}
let displayTitle = episodeInfo.title;
if (!displayTitle && episodeInfo.episode) {
2026-01-03 16:40:39 +08:00
displayTitle = episodeInfo.isOVA ? `OVA ${episodeInfo.episode}` : `${episodeInfo.episode}`;
2025-12-29 22:58:41 +08:00
}
if (!displayTitle) {
displayTitle = file.name;
}
2026-01-03 16:40:39 +08:00
return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle, isOVA: episodeInfo.isOVA };
2025-12-29 22:58:41 +08:00
})
2026-01-03 16:40:39 +08:00
.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);
});
2025-12-29 22:58:41 +08:00
// 3. 从 metainfo 中获取元数据
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
const result = {
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 源不使用代理模式
};
return NextResponse.json(result);
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
// 对于其他源通过搜索API获取然后精确匹配
try {
const apiSites = await getAvailableApiSites(authInfo.username);
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
return NextResponse.json({ error: '无效的API来源' }, { status: 400 });
}
// 调用搜索API
const searchResults = await searchFromApi(apiSite, title.trim());
// 从搜索结果中精确匹配 source 和 id
const exactMatch = searchResults.find(
(item: any) =>
item.source?.toString() === sourceCode.toString() &&
item.id?.toString() === id.toString()
);
if (!exactMatch) {
return NextResponse.json(
{ error: '未找到匹配的视频源' },
{ status: 404 }
);
}
// 添加 proxyMode 到返回结果
const resultWithProxy = {
...exactMatch,
proxyMode: apiSite.proxyMode || false,
};
const cacheTime = await getCacheTime();
return NextResponse.json(resultWithProxy, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Netlify-Vary': 'query',
},
});
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}