diff --git a/app/api/probe-resolution/route.ts b/app/api/probe-resolution/route.ts index 2e6f844..0a6440f 100644 --- a/app/api/probe-resolution/route.ts +++ b/app/api/probe-resolution/route.ts @@ -8,6 +8,12 @@ import { NextRequest } from 'next/server'; import { getSourceById } from '@/lib/api/video-sources'; import { getVideoDetail } from '@/lib/api/detail-api'; import { fetchWithTimeout } from '@/lib/api/http-utils'; +import { + extractResolutionHint, + extractVariantPlaylistUrls, + parseResolutionFromManifest, + type ResolutionProbeLabel, +} from '@/lib/player/resolution-probe-utils'; import type { VideoSource } from '@/lib/types'; export const runtime = 'edge'; @@ -46,99 +52,101 @@ function buildSourceConfigMap(rawConfigs: unknown): Map { return configs; } -function getResolutionLabel(width: number, height: number): { label: string; color: string } { - const h = Math.min(width, height); // height is the shorter side - if (h >= 2160) return { label: '4K', color: 'bg-amber-500' }; - if (h >= 1440) return { label: '2K', color: 'bg-emerald-500' }; - if (h >= 1080) return { label: '1080P', color: 'bg-green-500' }; - if (h >= 720) return { label: '720P', color: 'bg-teal-500' }; - if (h >= 480) return { label: '480P', color: 'bg-sky-500' }; - if (h >= 360) return { label: '360P', color: 'bg-gray-500' }; - return { label: `${h}P`, color: 'bg-gray-500' }; +async function fetchManifestText(url: string, timeoutMs: number): Promise { + const response = await fetchWithTimeout(url, { + headers: { 'User-Agent': 'Mozilla/5.0' }, + }, timeoutMs); + return response.text(); } -function parseResolutionFromM3u8(content: string): { width: number; height: number } | null { - const resolutions: { width: number; height: number }[] = []; - const regex = /RESOLUTION=(\d+)x(\d+)/gi; - let match; - while ((match = regex.exec(content)) !== null) { - resolutions.push({ width: parseInt(match[1]), height: parseInt(match[2]) }); +async function probeManifestResolution( + targetUrl: string, + m3u8Content: string, + detailHint: ResolutionProbeLabel | null +): Promise<{ resolution: ResolutionProbeLabel | null; origin: 'manifest' | 'hint' }> { + const directResolution = parseResolutionFromManifest(m3u8Content, targetUrl); + if (directResolution) { + return { resolution: directResolution, origin: 'manifest' }; } - if (resolutions.length === 0) return null; - // Return highest resolution - return resolutions.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0]; + + const variantUrls = extractVariantPlaylistUrls(m3u8Content, targetUrl).slice(0, 4); + for (const variantUrl of variantUrls) { + const variantHint = extractResolutionHint(variantUrl); + if (variantHint?.width || variantHint?.height) { + return { resolution: variantHint, origin: 'manifest' }; + } + + try { + const variantContent = await fetchManifestText(variantUrl, 6000); + const variantResolution = parseResolutionFromManifest(variantContent, variantUrl); + if (variantResolution) { + return { resolution: variantResolution, origin: 'manifest' }; + } + } catch { + // Continue trying the next variant. + } + } + + const fallbackHint = extractResolutionHint(targetUrl, m3u8Content) || detailHint; + return { + resolution: fallbackHint, + origin: fallbackHint ? 'hint' : 'manifest', + }; } async function probeOne(video: ProbeRequest, providedConfigs: Map): Promise<{ id: string | number; source: string; episodeIndex?: number; - resolution: { width: number; height: number; label: string; color: string } | null; + resolution: ResolutionProbeLabel | null; + resolutionOrigin: 'manifest' | 'hint'; }> { try { const sourceConfig = providedConfigs.get(video.source) || getSourceById(video.source); - if (!sourceConfig) return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null }; + if (!sourceConfig) { + return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null, resolutionOrigin: 'manifest' }; + } // 1. Get detail to find first episode URL const detail = await getVideoDetail(video.id, sourceConfig); if (!detail.episodes || detail.episodes.length === 0) { - return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null }; + return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null, resolutionOrigin: 'manifest' }; } const episodeIndex = typeof video.episodeIndex === 'number' ? Math.min(Math.max(video.episodeIndex, 0), detail.episodes.length - 1) : 0; const targetUrl = detail.episodes[episodeIndex]?.url || detail.episodes[0]?.url; - if (!targetUrl) return { id: video.id, source: video.source, episodeIndex, resolution: null }; + if (!targetUrl) { + return { id: video.id, source: video.source, episodeIndex, resolution: null, resolutionOrigin: 'manifest' }; + } + + const detailHint = extractResolutionHint(detail.vod_remarks, targetUrl); // 2. Fetch the m3u8 manifest let m3u8Content: string; try { - const res = await fetchWithTimeout(targetUrl, { - headers: { 'User-Agent': 'Mozilla/5.0' }, - }, 8000); - m3u8Content = await res.text(); + m3u8Content = await fetchManifestText(targetUrl, 8000); } catch { - // Try with proxy - try { - // Can't call our own proxy from edge easily, so just return null - return { id: video.id, source: video.source, episodeIndex, resolution: null }; - } catch { - return { id: video.id, source: video.source, episodeIndex, resolution: null }; - } + return { + id: video.id, + source: video.source, + episodeIndex, + resolution: detailHint, + resolutionOrigin: detailHint ? 'hint' : 'manifest', + }; } - // 3. Parse RESOLUTION from manifest - const res = parseResolutionFromM3u8(m3u8Content); - if (!res) { - // Simple playlist without RESOLUTION tags — try to follow sub-playlist - // Look for a URL in the content that might be a sub-playlist - const lines = m3u8Content.split('\n'); - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed && !trimmed.startsWith('#') && (trimmed.endsWith('.m3u8') || trimmed.includes('.m3u8?'))) { - try { - const subUrl = trimmed.startsWith('http') ? trimmed : new URL(trimmed, targetUrl).toString(); - const subRes = await fetchWithTimeout(subUrl, { - headers: { 'User-Agent': 'Mozilla/5.0' }, - }, 6000); - const subContent = await subRes.text(); - const subResolution = parseResolutionFromM3u8(subContent); - if (subResolution) { - const labelInfo = getResolutionLabel(subResolution.width, subResolution.height); - return { id: video.id, source: video.source, episodeIndex, resolution: { ...subResolution, ...labelInfo } }; - } - } catch { /* continue */ } - break; // Only try the first sub-playlist - } - } - return { id: video.id, source: video.source, episodeIndex, resolution: null }; - } - - const labelInfo = getResolutionLabel(res.width, res.height); - return { id: video.id, source: video.source, episodeIndex, resolution: { ...res, ...labelInfo } }; + const probed = await probeManifestResolution(targetUrl, m3u8Content, detailHint); + return { id: video.id, source: video.source, episodeIndex, resolution: probed.resolution, resolutionOrigin: probed.origin }; } catch { - return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null }; + return { + id: video.id, + source: video.source, + episodeIndex: video.episodeIndex, + resolution: null, + resolutionOrigin: 'manifest', + }; } } @@ -155,8 +163,7 @@ export async function POST(request: NextRequest) { }); } - // Limit batch size - const batch = videos.slice(0, 30); + const batch = videos.slice(0, 100); const encoder = new TextEncoder(); const stream = new ReadableStream({ @@ -173,7 +180,7 @@ export async function POST(request: NextRequest) { const line = `data: ${JSON.stringify(result)}\n\n`; controller.enqueue(encoder.encode(line)); } catch { - const fallback = { id: current.id, source: current.source, resolution: null }; + const fallback = { id: current.id, source: current.source, resolution: null, resolutionOrigin: 'manifest' }; controller.enqueue(encoder.encode(`data: ${JSON.stringify(fallback)}\n\n`)); } } diff --git a/lib/hooks/useResolutionProbe.ts b/lib/hooks/useResolutionProbe.ts index d1bb082..6e53e21 100644 --- a/lib/hooks/useResolutionProbe.ts +++ b/lib/hooks/useResolutionProbe.ts @@ -18,6 +18,15 @@ interface VideoToProbe { episodeIndex?: number; } +interface ResolutionProbeEvent { + done?: boolean; + id: string | number; + source: string; + episodeIndex?: number; + resolution?: ResolutionInfo | null; + resolutionOrigin?: 'manifest' | 'hint'; +} + function getSourceConfigsForProbe(videos: VideoToProbe[]): VideoSource[] { if (typeof window === 'undefined' || videos.length === 0) { return []; @@ -48,13 +57,17 @@ export function useResolutionProbe(videos: VideoToProbe[]): { const [resolutions, setResolutions] = useState>({}); const [isProbing, setIsProbing] = useState(false); const abortRef = useRef(null); - const probedKeysRef = useRef>(new Set()); + const completedKeysRef = useRef>(new Set()); + const inFlightKeysRef = useRef>(new Set()); useEffect(() => { if (!videos || videos.length === 0) return; + const inFlightKeys = inFlightKeysRef.current; + const completedKeys = completedKeysRef.current; const cached: Record = {}; const needProbe: VideoToProbe[] = []; + const batchRequestKeys: string[] = []; for (const video of videos) { const resultKey = `${video.source}:${video.id}`; @@ -63,9 +76,13 @@ export function useResolutionProbe(videos: VideoToProbe[]): { if (shouldReuseCachedResolution(cachedInfo, video.episodeIndex)) { cached[resultKey] = cachedInfo; - } else if (!probedKeysRef.current.has(requestKey)) { + } else if ( + !completedKeys.has(requestKey) && + !inFlightKeys.has(requestKey) + ) { needProbe.push(video); - probedKeysRef.current.add(requestKey); + batchRequestKeys.push(requestKey); + inFlightKeys.add(requestKey); } } @@ -110,15 +127,18 @@ export function useResolutionProbe(videos: VideoToProbe[]): { for (const line of lines) { if (!line.startsWith('data: ')) continue; try { - const data = JSON.parse(line.slice(6)); + const data = JSON.parse(line.slice(6)) as ResolutionProbeEvent; if (data.done) continue; const resultKey = `${data.source}:${data.id}`; + const requestKey = `${data.source}:${data.id}:${data.episodeIndex ?? 0}`; + inFlightKeys.delete(requestKey); + completedKeys.add(requestKey); if (data.resolution) { const resolution: ResolutionInfo = { ...data.resolution, - origin: 'probed', + origin: data.resolutionOrigin === 'hint' ? 'hint' : 'probed', episodeIndex: typeof data.episodeIndex === 'number' ? data.episodeIndex : undefined, }; setCachedResolution(data.source, data.id, resolution); @@ -136,12 +156,18 @@ export function useResolutionProbe(videos: VideoToProbe[]): { console.warn('[ResolutionProbe] Failed:', error); } } finally { + for (const requestKey of batchRequestKeys) { + inFlightKeys.delete(requestKey); + } setIsProbing(false); } })(); return () => { controller.abort(); + for (const requestKey of batchRequestKeys) { + inFlightKeys.delete(requestKey); + } }; }, [videos]); diff --git a/lib/player/resolution-cache.ts b/lib/player/resolution-cache.ts index 99feeaf..015cac4 100644 --- a/lib/player/resolution-cache.ts +++ b/lib/player/resolution-cache.ts @@ -1,9 +1,9 @@ export interface ResolutionCacheEntry { - width: number; - height: number; + width?: number; + height?: number; label: string; color: string; - origin?: 'probed' | 'played'; + origin?: 'probed' | 'played' | 'hint'; episodeIndex?: number; } @@ -45,5 +45,6 @@ export function shouldReuseCachedResolution( ): boolean { if (!entry) return false; if (entry.origin === 'played') return true; + if (entry.origin === 'hint') return false; return entry.episodeIndex === episodeIndex; } diff --git a/lib/player/resolution-probe-utils.ts b/lib/player/resolution-probe-utils.ts new file mode 100644 index 0000000..f409fe3 --- /dev/null +++ b/lib/player/resolution-probe-utils.ts @@ -0,0 +1,178 @@ +import { extractPlaybackQualityLabel } from '@/lib/utils/video'; + +export interface ResolutionProbeLabel { + label: string; + color: string; + width?: number; + height?: number; +} + +const QUALITY_RANK: Record = { + '4K': 700, + '2K': 620, + '1080P': 540, + '蓝光': 520, + 'HDR': 500, + '超清': 480, + '720P': 420, + 'WEB-DL': 380, + 'HDTV': 360, + '高清': 340, + '540P': 300, + 'DVD': 280, + '480P': 260, + '360P': 220, + 'TS': 120, + 'SD': 100, +}; + +const DIMENSION_PATTERN = /(\d{3,4})\s*[xX]\s*(\d{3,4})/g; +const HLS_RESOLUTION_PATTERN = /RESOLUTION=(\d+)x(\d+)/gi; +const TEXT_QUALITY_PATTERNS: Array<{ pattern: RegExp; width?: number; height?: number; label: string; color: string }> = [ + { pattern: /(?:^|[^\d])(2160p?|4k|uhd)(?:[^\d]|$)/i, width: 3840, height: 2160, label: '4K', color: 'bg-amber-500' }, + { pattern: /(?:^|[^\d])(1440p?|2k|qhd)(?:[^\d]|$)/i, width: 2560, height: 1440, label: '2K', color: 'bg-emerald-500' }, + { pattern: /(?:^|[^\d])(1080p?|1080i|fhd|fullhd|full-hd)(?:[^\d]|$)/i, width: 1920, height: 1080, label: '1080P', color: 'bg-green-500' }, + { pattern: /(?:^|[^\d])(720p?|hd720)(?:[^\d]|$)/i, width: 1280, height: 720, label: '720P', color: 'bg-teal-500' }, + { pattern: /(?:^|[^\d])540p?(?:[^\d]|$)/i, width: 960, height: 540, label: '540P', color: 'bg-cyan-500' }, + { pattern: /(?:^|[^\d])480p?(?:[^\d]|$)/i, width: 854, height: 480, label: '480P', color: 'bg-sky-500' }, + { pattern: /(?:^|[^\d])360p?(?:[^\d]|$)/i, width: 640, height: 360, label: '360P', color: 'bg-gray-500' }, +]; + +export function getResolutionLabel(width: number, height: number): ResolutionProbeLabel { + const normalizedWidth = Math.max(width, height); + const normalizedHeight = Math.min(width, height); + + if (normalizedHeight >= 2160) return { width: normalizedWidth, height: normalizedHeight, label: '4K', color: 'bg-amber-500' }; + if (normalizedHeight >= 1440) return { width: normalizedWidth, height: normalizedHeight, label: '2K', color: 'bg-emerald-500' }; + if (normalizedHeight >= 1080) return { width: normalizedWidth, height: normalizedHeight, label: '1080P', color: 'bg-green-500' }; + if (normalizedHeight >= 720) return { width: normalizedWidth, height: normalizedHeight, label: '720P', color: 'bg-teal-500' }; + if (normalizedHeight >= 540) return { width: normalizedWidth, height: normalizedHeight, label: '540P', color: 'bg-cyan-500' }; + if (normalizedHeight >= 480) return { width: normalizedWidth, height: normalizedHeight, label: '480P', color: 'bg-sky-500' }; + if (normalizedHeight >= 360) return { width: normalizedWidth, height: normalizedHeight, label: '360P', color: 'bg-gray-500' }; + return { width: normalizedWidth, height: normalizedHeight, label: `${normalizedHeight}P`, color: 'bg-gray-500' }; +} + +function getCandidateRank(candidate: ResolutionProbeLabel): number { + if (candidate.width && candidate.height) { + return candidate.width * candidate.height; + } + + return QUALITY_RANK[candidate.label] || 0; +} + +export function chooseHigherQuality( + current: ResolutionProbeLabel | null, + candidate: ResolutionProbeLabel | null +): ResolutionProbeLabel | null { + if (!candidate) return current; + if (!current) return candidate; + return getCandidateRank(candidate) > getCandidateRank(current) ? candidate : current; +} + +export function extractResolutionHint(...values: Array): ResolutionProbeLabel | null { + let best: ResolutionProbeLabel | null = null; + + for (const value of values) { + if (!value) continue; + + let match: RegExpExecArray | null; + DIMENSION_PATTERN.lastIndex = 0; + while ((match = DIMENSION_PATTERN.exec(value)) !== null) { + const width = Number.parseInt(match[1], 10); + const height = Number.parseInt(match[2], 10); + if (width > 0 && height > 0) { + best = chooseHigherQuality(best, getResolutionLabel(width, height)); + } + } + + for (const pattern of TEXT_QUALITY_PATTERNS) { + if (!pattern.pattern.test(value)) continue; + best = chooseHigherQuality(best, { + label: pattern.label, + color: pattern.color, + width: pattern.width, + height: pattern.height, + }); + } + + best = chooseHigherQuality(best, extractPlaybackQualityLabel(value) || null); + } + + return best; +} + +export function parseResolutionFromManifest(content: string, baseUrl?: string): ResolutionProbeLabel | null { + let best: ResolutionProbeLabel | null = null; + + let match: RegExpExecArray | null; + HLS_RESOLUTION_PATTERN.lastIndex = 0; + while ((match = HLS_RESOLUTION_PATTERN.exec(content)) !== null) { + const width = Number.parseInt(match[1], 10); + const height = Number.parseInt(match[2], 10); + if (width > 0 && height > 0) { + best = chooseHigherQuality(best, getResolutionLabel(width, height)); + } + } + + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (!line) continue; + + if (line.startsWith('#EXT-X-STREAM-INF') || line.startsWith('#EXT-X-I-FRAME-STREAM-INF')) { + best = chooseHigherQuality(best, extractResolutionHint(line)); + continue; + } + + if (line.startsWith('#')) continue; + + const resolvedLine = baseUrl + ? (() => { + try { + return new URL(line, baseUrl).toString(); + } catch { + return line; + } + })() + : line; + + best = chooseHigherQuality(best, extractResolutionHint(resolvedLine, line)); + } + + return best; +} + +export function extractVariantPlaylistUrls(content: string, baseUrl: string): string[] { + const urls = new Set(); + const lines = content.split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (!line) continue; + + if (line.startsWith('#EXT-X-I-FRAME-STREAM-INF') && line.includes('URI="')) { + const uriMatch = line.match(/URI="([^"]+)"/i); + if (uriMatch?.[1]) { + try { + urls.add(new URL(uriMatch[1], baseUrl).toString()); + } catch { + // Ignore invalid URIs. + } + } + continue; + } + + if (!line.startsWith('#EXT-X-STREAM-INF')) continue; + + const candidate = lines[index + 1]?.trim(); + if (!candidate || candidate.startsWith('#')) continue; + + try { + urls.add(new URL(candidate, baseUrl).toString()); + } catch { + // Ignore invalid URLs. + } + } + + return Array.from(urls); +} diff --git a/lib/player/source-list-utils.ts b/lib/player/source-list-utils.ts index 7a5133d..388b2e2 100644 --- a/lib/player/source-list-utils.ts +++ b/lib/player/source-list-utils.ts @@ -1,4 +1,4 @@ -import { extractQualityLabel } from '@/lib/utils/video'; +import { extractPlaybackQualityLabel } from '@/lib/utils/video'; export interface ResolutionBadge { label: string; @@ -8,7 +8,7 @@ export interface ResolutionBadge { export interface ResolutionLike extends ResolutionBadge { width?: number; height?: number; - origin?: 'probed' | 'played'; + origin?: 'probed' | 'played' | 'hint'; episodeIndex?: number; } @@ -42,5 +42,5 @@ export function getSourceResolutionBadge(options: { return { label: cachedResolution.label, color: cachedResolution.color }; } - return extractQualityLabel(remarks) || null; + return extractPlaybackQualityLabel(remarks) || null; } diff --git a/lib/utils/video.ts b/lib/utils/video.ts index 663dd81..6494387 100644 --- a/lib/utils/video.ts +++ b/lib/utils/video.ts @@ -29,7 +29,7 @@ export function parseVideoTitle(title: string): { cleanTitle: string, quality?: /** * Quality keywords and their display labels, ordered by priority (highest first). */ -const QUALITY_PATTERNS: { pattern: RegExp; label: string; color: string }[] = [ +const PLAYBACK_QUALITY_PATTERNS: { pattern: RegExp; label: string; color: string }[] = [ { pattern: /4k|2160p|uhd/i, label: '4K', color: 'bg-amber-500' }, { pattern: /2k|1440p|qhd/i, label: '2K', color: 'bg-emerald-500' }, { pattern: /蓝光|藍光|bluray|blu-ray|remux/i, label: '蓝光', color: 'bg-blue-500' }, @@ -44,23 +44,22 @@ const QUALITY_PATTERNS: { pattern: RegExp; label: string; color: string }[] = [ { pattern: /web-?dl|webrip/i, label: 'WEB-DL', color: 'bg-indigo-500' }, { pattern: /hdtv/i, label: 'HDTV', color: 'bg-teal-500' }, { pattern: /dvd|dvdrip/i, label: 'DVD', color: 'bg-purple-500' }, - { pattern: /抢先|枪版|ts版|ts\b|cam\b|hdts|预告/i, label: 'TS', color: 'bg-orange-500' }, - { pattern: /标清|sd\b/i, label: 'SD', color: 'bg-gray-500' }, - { pattern: /杜比|dolby|atmos/i, label: '杜比', color: 'bg-violet-500' }, - { pattern: /国语|普通话|mandarin/i, label: '国语', color: 'bg-sky-500' }, - { pattern: /粤语|cantonese/i, label: '粤语', color: 'bg-sky-500' }, - { pattern: /中[文字]字幕|中字|双语字幕/i, label: '中字', color: 'bg-cyan-500' }, + { pattern: /抢先|枪版|ts版|(?:^|[\s([【_-])(ts|cam|hdts)(?:$|[\s)\]】_-])|预告/i, label: 'TS', color: 'bg-orange-500' }, + { pattern: /标清|(?:^|[\s([【_-])sd(?:$|[\s)\]】_-])/i, label: 'SD', color: 'bg-gray-500' }, ]; /** - * Extracts quality label from video remarks or title. - * Returns the quality label and its associated color class. + * Extracts a playback quality label from video remarks or title. + * This intentionally excludes language, subtitle, and audio labels. */ -export function extractQualityLabel(remarks?: string, quality?: string): { label: string; color: string } | null { +export function extractPlaybackQualityLabel( + remarks?: string, + quality?: string +): { label: string; color: string } | null { const text = `${remarks || ''} ${quality || ''}`; if (!text.trim()) return null; - for (const { pattern, label, color } of QUALITY_PATTERNS) { + for (const { pattern, label, color } of PLAYBACK_QUALITY_PATTERNS) { if (pattern.test(text)) { return { label, color }; } @@ -68,3 +67,7 @@ export function extractQualityLabel(remarks?: string, quality?: string): { label return null; } + +export function extractQualityLabel(remarks?: string, quality?: string): { label: string; color: string } | null { + return extractPlaybackQualityLabel(remarks, quality); +} diff --git a/tests/player-source-list.test.ts b/tests/player-source-list.test.ts index 8f74d3f..28d76ab 100644 --- a/tests/player-source-list.test.ts +++ b/tests/player-source-list.test.ts @@ -5,6 +5,7 @@ import { shouldExpandForCurrentSource, } from '@/lib/player/source-list-utils'; import { shouldReuseCachedResolution } from '@/lib/player/resolution-cache'; +import { extractPlaybackQualityLabel } from '@/lib/utils/video'; test('shouldExpandForCurrentSource detects hidden active sources', () => { const sources = [ @@ -52,6 +53,16 @@ test('getSourceResolutionBadge prefers current actual resolution, then probed, t assert.deepEqual(remark, { label: '蓝光', color: 'bg-blue-500' }); }); +test('getSourceResolutionBadge does not treat language markers as playback quality', () => { + const remark = getSourceResolutionBadge({ + isCurrent: false, + remarks: '国语', + }); + assert.equal(remark, null); + assert.equal(extractPlaybackQualityLabel('中字'), null); + assert.equal(extractPlaybackQualityLabel('segment.ts'), null); +}); + test('shouldReuseCachedResolution keeps played results across episode changes but re-probes stale probed data', () => { assert.equal(shouldReuseCachedResolution({ width: 1920, @@ -79,4 +90,11 @@ test('shouldReuseCachedResolution keeps played results across episode changes bu origin: 'probed', episodeIndex: 2, }, 5), false); + + assert.equal(shouldReuseCachedResolution({ + label: '蓝光', + color: 'bg-blue-500', + origin: 'hint', + episodeIndex: 2, + }, 2), false); }); diff --git a/tests/resolution-probe-utils.test.ts b/tests/resolution-probe-utils.test.ts new file mode 100644 index 0000000..a1a028a --- /dev/null +++ b/tests/resolution-probe-utils.test.ts @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + extractResolutionHint, + extractVariantPlaylistUrls, + parseResolutionFromManifest, +} from '@/lib/player/resolution-probe-utils'; + +test('extractResolutionHint recognizes resolution hints from URLs and remarks', () => { + assert.deepEqual( + extractResolutionHint('https://cdn.example.com/show/2160/index.m3u8'), + { label: '4K', color: 'bg-amber-500', width: 3840, height: 2160 } + ); + + assert.deepEqual( + extractResolutionHint('蓝光原盘'), + { label: '蓝光', color: 'bg-blue-500' } + ); +}); + +test('parseResolutionFromManifest prefers the highest explicit resolution', () => { + const manifest = `#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=4800000,RESOLUTION=1280x720,NAME="720P" +mid/index.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=9200000,RESOLUTION=1920x1080,NAME="1080P" +high/index.m3u8 +`; + + assert.deepEqual( + parseResolutionFromManifest(manifest, 'https://media.example.com/master.m3u8'), + { label: '1080P', color: 'bg-green-500', width: 1920, height: 1080 } + ); +}); + +test('parseResolutionFromManifest falls back to variant URL hints when RESOLUTION is missing', () => { + const manifest = `#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=9200000,NAME="Ultra" +./video_4k/index.m3u8 +`; + + assert.deepEqual( + parseResolutionFromManifest(manifest, 'https://media.example.com/master.m3u8'), + { label: '4K', color: 'bg-amber-500', width: 3840, height: 2160 } + ); +}); + +test('extractVariantPlaylistUrls resolves stream and iframe variant URLs', () => { + const manifest = `#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=9200000,NAME="1080P" +./video_1080/index.m3u8 +#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=200000,URI="./iframes/720p.m3u8" +`; + + assert.deepEqual( + extractVariantPlaylistUrls(manifest, 'https://media.example.com/master.m3u8'), + [ + 'https://media.example.com/video_1080/index.m3u8', + 'https://media.example.com/iframes/720p.m3u8', + ] + ); +});