diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 916b4df..16fd81a 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -6,7 +6,6 @@ import { getDetailFromApi } from '@/lib/downstream'; import { executeSavedSourceScript, normalizeScriptDetailResult, - resolveScriptDetailPlaybacks, normalizeScriptSources, parseScriptSourceValue, } from '@/lib/source-script'; @@ -51,12 +50,6 @@ export async function GET(request: NextRequest) { }, }); - const resolvedDetailResult = await resolveScriptDetailPlaybacks({ - scriptKey: parsedScriptSource.scriptKey, - sourceId: parsedScriptSource.sourceId, - result: detailExecution.result, - }); - const normalized = normalizeScriptDetailResult({ source: sourceCode, scriptKey: parsedScriptSource.scriptKey, @@ -64,7 +57,7 @@ export async function GET(request: NextRequest) { sourceId: parsedScriptSource.sourceId, sourceName: sourceInfo.name, detailId: id, - result: resolvedDetailResult, + result: detailExecution.result, }); return NextResponse.json(normalized); diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts index e4a1f3f..381cf62 100644 --- a/src/app/api/source-detail/route.ts +++ b/src/app/api/source-detail/route.ts @@ -9,7 +9,6 @@ import { getProxyToken } from '@/lib/emby-token'; import { executeSavedSourceScript, normalizeScriptDetailResult, - resolveScriptDetailPlaybacks, normalizeScriptSources, parseScriptSourceValue, } from '@/lib/source-script'; @@ -59,12 +58,6 @@ export async function GET(request: NextRequest) { }, }); - const resolvedDetailResult = await resolveScriptDetailPlaybacks({ - scriptKey: parsedScriptSource.scriptKey, - sourceId: parsedScriptSource.sourceId, - result: detailExecution.result, - }); - const normalized = normalizeScriptDetailResult({ source: sourceCode, scriptKey: parsedScriptSource.scriptKey, @@ -72,7 +65,7 @@ export async function GET(request: NextRequest) { sourceId: parsedScriptSource.sourceId, sourceName: sourceInfo.name, detailId: id, - result: resolvedDetailResult, + result: detailExecution.result, }); return NextResponse.json(normalized); diff --git a/src/app/api/source-script/play/route.ts b/src/app/api/source-script/play/route.ts new file mode 100644 index 0000000..0b505fc --- /dev/null +++ b/src/app/api/source-script/play/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { + parseScriptPlayUrlValue, + resolveSavedScriptPlayUrl, +} from '@/lib/source-script'; + +export const runtime = 'nodejs'; + +/** + * GET /api/source-script/play?key=xxx&sourceId=xxx&lineId=xxx&episodeIndex=0&playUrl=base64url&format=json + * format=json: 返回 JSON 格式(用于 play 页面) + * 默认: 返回重定向(用于播放器或外部调用) + */ +export async function GET(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未授权' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const key = searchParams.get('key'); + const sourceId = searchParams.get('sourceId'); + const lineId = searchParams.get('lineId') || 'default'; + const episodeIndexRaw = searchParams.get('episodeIndex'); + const playUrlEncoded = searchParams.get('playUrl'); + const format = searchParams.get('format'); + + if (!key || !sourceId || !episodeIndexRaw || !playUrlEncoded) { + return NextResponse.json({ error: '缺少参数' }, { status: 400 }); + } + + const episodeIndex = Number.parseInt(episodeIndexRaw, 10); + if (!Number.isInteger(episodeIndex) || episodeIndex < 0) { + return NextResponse.json({ error: '无效的 episodeIndex' }, { status: 400 }); + } + + const playUrl = parseScriptPlayUrlValue(playUrlEncoded); + if (!playUrl) { + return NextResponse.json({ error: '无效的播放地址' }, { status: 400 }); + } + + const result = await resolveSavedScriptPlayUrl({ + key, + sourceId, + lineId, + episodeIndex, + playUrl, + }); + + if (!result.url || result.url.trim() === '') { + throw new Error('获取到的播放链接为空'); + } + + if (format === 'json') { + return NextResponse.json(result); + } + + return NextResponse.redirect(result.url); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 17f2644..1862086 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -2127,10 +2127,17 @@ function PlayPageClient() { let newUrl = detailData?.episodes[episodeIndex] || ''; // 如果是小雅或 openlist 接口,先请求获取真实 URL - if (newUrl.startsWith('/api/xiaoya/play') || newUrl.startsWith('/api/openlist/play')) { + const isSpecialLazyPlayUrl = + newUrl.startsWith('/api/xiaoya/play') || + newUrl.startsWith('/api/openlist/play') || + newUrl.startsWith('/api/source-script/play'); + + if (isSpecialLazyPlayUrl) { try { // 保存原始URL(用于后续刷新) - currentXiaoyaUrlRef.current = newUrl; + if (newUrl.startsWith('/api/xiaoya/play') || newUrl.startsWith('/api/openlist/play')) { + currentXiaoyaUrlRef.current = newUrl; + } // 添加 format=json 参数 const separator = newUrl.includes('?') ? '&' : '?'; @@ -3247,6 +3254,42 @@ function PlayPageClient() { } }; + const getCachedSourcesData = (query: string): SearchResult[] => { + if (typeof window === 'undefined' || !query.trim()) { + return []; + } + + try { + const cacheKey = `search_cache_${query.trim()}`; + const cached = sessionStorage.getItem(cacheKey); + if (!cached) { + return []; + } + + const cachedData = JSON.parse(cached); + const results = cachedData.filter( + (result: SearchResult) => + normalizeTitle(result.title).toLowerCase() === + normalizeTitle(videoTitleRef.current).toLowerCase() && + (videoYearRef.current + ? result.year.toLowerCase() === videoYearRef.current.toLowerCase() || + !result.year || + result.year.trim() === '' || + result.year === 'unknown' || + !/^\d{4}$/.test(result.year) + : true) && + (searchType + ? getType(result) === searchType + : true) + ); + + return applyCorrectionsToSources(results); + } catch (error) { + console.error('[Play] 读取缓存失败:', error); + return []; + } + }; + const initAll = async () => { if (currentSource === 'directplay') { if (!currentId) { @@ -3333,22 +3376,34 @@ function PlayPageClient() { let sourcesInfo: SearchResult[] = []; if (currentSource && currentId) { - // 先快速获取当前源的详情 - try { - // currentSource 已经是完整格式(如 'emby_wumei') - // 如果是小雅源且有fileName参数,传递给API - const currentSourceDetail = await fetchSourceDetail( - currentSource, - currentId, - searchTitle || videoTitle, - currentSource === 'xiaoya' ? fileName : undefined - ); - if (currentSourceDetail.length > 0) { - detailData = currentSourceDetail[0]; - sourcesInfo = currentSourceDetail; + const cachedSources = getCachedSourcesData(searchTitle || videoTitle); + const cachedTarget = cachedSources.find( + (source) => source.source === currentSource && source.id === currentId + ); + + if (cachedTarget?.episodes?.length) { + detailData = cachedTarget; + sourcesInfo = cachedSources; + setAvailableSources(cachedSources); + setSourceSearchLoading(false); + } else { + // 先快速获取当前源的详情 + try { + // currentSource 已经是完整格式(如 'emby_wumei') + // 如果是小雅源且有fileName参数,传递给API + const currentSourceDetail = await fetchSourceDetail( + currentSource, + currentId, + searchTitle || videoTitle, + currentSource === 'xiaoya' ? fileName : undefined + ); + if (currentSourceDetail.length > 0) { + detailData = currentSourceDetail[0]; + sourcesInfo = currentSourceDetail; + } + } catch (err) { + console.error('获取当前源详情失败:', err); } - } catch (err) { - console.error('获取当前源详情失败:', err); } // 异步获取其他源信息,不阻塞播放 diff --git a/src/lib/source-script.ts b/src/lib/source-script.ts index 80383d8..3d4c42a 100644 --- a/src/lib/source-script.ts +++ b/src/lib/source-script.ts @@ -738,22 +738,39 @@ export function normalizeScriptSearchResults(input: { result: any; }) { const list = Array.isArray(input.result?.list) ? input.result.list : []; - return list.map((item: any) => ({ - id: String(item.id), - title: String(item.title || ''), - poster: item.poster || '', - episodes: Array.isArray(item.episodes) ? item.episodes : [], - episodes_titles: Array.isArray(item.episodes_titles) - ? item.episodes_titles - : [], - source: buildScriptSourceValue(input.scriptKey, input.sourceId), - source_name: `${input.scriptName} / ${input.sourceName}`, - year: item.year || '', - desc: item.desc || '', - type_name: item.type_name || '', - douban_id: item.douban_id || 0, - vod_remarks: item.vod_remarks, - })); + return list.map((item: any) => { + const titles = Array.isArray(item.episodes_titles) ? item.episodes_titles : []; + const episodes = Array.isArray(item.episodes) + ? item.episodes.map((episode: any, index: number) => { + const playUrl = + typeof episode === 'string' + ? episode + : String(episode?.playUrl || episode?.url || ''); + return buildScriptPlayUrl({ + scriptKey: input.scriptKey, + sourceId: input.sourceId, + lineId: String(episode?.lineId || item?.lineId || 'default'), + episodeIndex: index, + playUrl, + }); + }) + : []; + + return { + id: String(item.id), + title: String(item.title || ''), + poster: item.poster || '', + episodes, + episodes_titles: titles, + source: buildScriptSourceValue(input.scriptKey, input.sourceId), + source_name: `${input.scriptName} / ${input.sourceName}`, + year: item.year || '', + desc: item.desc || '', + type_name: item.type_name || '', + douban_id: item.douban_id || 0, + vod_remarks: item.vod_remarks, + }; + }); } export function normalizeScriptDetailResult(input: { @@ -790,7 +807,7 @@ export function normalizeScriptDetailResult(input: { const episodes = Array.isArray(playback.episodes) ? playback.episodes : []; episodes.forEach((episode: any, index: number) => { - const playUrl = + const rawPlayUrl = typeof episode === 'string' ? episode : String(episode?.playUrl || episode?.url || ''); @@ -799,6 +816,16 @@ export function normalizeScriptDetailResult(input: { ? String(episode.title) : String(titles[index] || `第${index + 1}集`); + const playbackSourceId = String(playback.sourceId || input.sourceId); + const lineId = String(playback.lineId || 'default'); + const playUrl = buildScriptPlayUrl({ + scriptKey: input.scriptKey, + sourceId: playbackSourceId, + lineId, + episodeIndex: index, + playUrl: rawPlayUrl, + }); + flattenedEpisodes.push(playUrl); flattenedTitles.push(`${playbackSourceName} / ${lineName} / ${episodeTitle}`); }); @@ -896,3 +923,76 @@ export async function resolveScriptDetailPlaybacks(input: { playbacks: resolvedPlaybacks, }; } + +function encodeBase64Url(value: string) { + return Buffer.from(value, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function decodeBase64Url(value: string) { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4)); + return Buffer.from(`${normalized}${padding}`, 'base64').toString('utf8'); +} + +export function buildScriptPlayUrl(input: { + scriptKey: string; + sourceId: string; + lineId: string; + episodeIndex: number; + playUrl: string; +}) { + const searchParams = new URLSearchParams({ + key: input.scriptKey, + sourceId: input.sourceId, + lineId: input.lineId, + episodeIndex: String(input.episodeIndex), + playUrl: encodeBase64Url(input.playUrl), + }); + return `/api/source-script/play?${searchParams.toString()}`; +} + +export function parseScriptPlayUrlValue(value: string) { + return decodeBase64Url(value); +} + +export async function resolveSavedScriptPlayUrl(input: { + key: string; + sourceId: string; + lineId: string; + episodeIndex: number; + playUrl: string; + configValues?: Record; +}) { + const script = await getEnabledSourceScriptByKey(input.key); + const { compiled, ctx } = await compileSourceScript(script, input.configValues); + + if (typeof compiled.resolvePlayUrl !== 'function') { + return { + url: input.playUrl, + type: 'auto', + headers: {}, + }; + } + + const result = await withTimeout( + Promise.resolve( + compiled.resolvePlayUrl(ctx, { + playUrl: input.playUrl, + sourceId: input.sourceId, + lineId: input.lineId, + episodeIndex: input.episodeIndex, + }) + ), + DEFAULT_TIMEOUT_MS + ); + + return { + url: result?.url || input.playUrl, + type: result?.type || 'auto', + headers: result?.headers || {}, + }; +}