Fix source resolution badge probing

This commit is contained in:
kuekhaoyang
2026-04-16 16:31:17 +08:00
parent 0f6060886c
commit 49951c67ef
8 changed files with 382 additions and 88 deletions
+73 -66
View File
@@ -8,6 +8,12 @@ import { NextRequest } from 'next/server';
import { getSourceById } from '@/lib/api/video-sources'; import { getSourceById } from '@/lib/api/video-sources';
import { getVideoDetail } from '@/lib/api/detail-api'; import { getVideoDetail } from '@/lib/api/detail-api';
import { fetchWithTimeout } from '@/lib/api/http-utils'; 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'; import type { VideoSource } from '@/lib/types';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -46,99 +52,101 @@ function buildSourceConfigMap(rawConfigs: unknown): Map<string, VideoSource> {
return configs; return configs;
} }
function getResolutionLabel(width: number, height: number): { label: string; color: string } { async function fetchManifestText(url: string, timeoutMs: number): Promise<string> {
const h = Math.min(width, height); // height is the shorter side const response = await fetchWithTimeout(url, {
if (h >= 2160) return { label: '4K', color: 'bg-amber-500' }; headers: { 'User-Agent': 'Mozilla/5.0' },
if (h >= 1440) return { label: '2K', color: 'bg-emerald-500' }; }, timeoutMs);
if (h >= 1080) return { label: '1080P', color: 'bg-green-500' }; return response.text();
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' };
} }
function parseResolutionFromM3u8(content: string): { width: number; height: number } | null { async function probeManifestResolution(
const resolutions: { width: number; height: number }[] = []; targetUrl: string,
const regex = /RESOLUTION=(\d+)x(\d+)/gi; m3u8Content: string,
let match; detailHint: ResolutionProbeLabel | null
while ((match = regex.exec(content)) !== null) { ): Promise<{ resolution: ResolutionProbeLabel | null; origin: 'manifest' | 'hint' }> {
resolutions.push({ width: parseInt(match[1]), height: parseInt(match[2]) }); const directResolution = parseResolutionFromManifest(m3u8Content, targetUrl);
if (directResolution) {
return { resolution: directResolution, origin: 'manifest' };
} }
if (resolutions.length === 0) return null;
// Return highest resolution const variantUrls = extractVariantPlaylistUrls(m3u8Content, targetUrl).slice(0, 4);
return resolutions.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0]; 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<string, VideoSource>): Promise<{ async function probeOne(video: ProbeRequest, providedConfigs: Map<string, VideoSource>): Promise<{
id: string | number; id: string | number;
source: string; source: string;
episodeIndex?: number; episodeIndex?: number;
resolution: { width: number; height: number; label: string; color: string } | null; resolution: ResolutionProbeLabel | null;
resolutionOrigin: 'manifest' | 'hint';
}> { }> {
try { try {
const sourceConfig = providedConfigs.get(video.source) || getSourceById(video.source); 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 // 1. Get detail to find first episode URL
const detail = await getVideoDetail(video.id, sourceConfig); const detail = await getVideoDetail(video.id, sourceConfig);
if (!detail.episodes || detail.episodes.length === 0) { 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' const episodeIndex = typeof video.episodeIndex === 'number'
? Math.min(Math.max(video.episodeIndex, 0), detail.episodes.length - 1) ? Math.min(Math.max(video.episodeIndex, 0), detail.episodes.length - 1)
: 0; : 0;
const targetUrl = detail.episodes[episodeIndex]?.url || detail.episodes[0]?.url; 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 // 2. Fetch the m3u8 manifest
let m3u8Content: string; let m3u8Content: string;
try { try {
const res = await fetchWithTimeout(targetUrl, { m3u8Content = await fetchManifestText(targetUrl, 8000);
headers: { 'User-Agent': 'Mozilla/5.0' },
}, 8000);
m3u8Content = await res.text();
} catch { } catch {
// Try with proxy return {
try { id: video.id,
// Can't call our own proxy from edge easily, so just return null source: video.source,
return { id: video.id, source: video.source, episodeIndex, resolution: null }; episodeIndex,
} catch { resolution: detailHint,
return { id: video.id, source: video.source, episodeIndex, resolution: null }; resolutionOrigin: detailHint ? 'hint' : 'manifest',
} };
} }
// 3. Parse RESOLUTION from manifest const probed = await probeManifestResolution(targetUrl, m3u8Content, detailHint);
const res = parseResolutionFromM3u8(m3u8Content); return { id: video.id, source: video.source, episodeIndex, resolution: probed.resolution, resolutionOrigin: probed.origin };
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 } };
} catch { } 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, 100);
const batch = videos.slice(0, 30);
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const stream = new ReadableStream({ const stream = new ReadableStream({
@@ -173,7 +180,7 @@ export async function POST(request: NextRequest) {
const line = `data: ${JSON.stringify(result)}\n\n`; const line = `data: ${JSON.stringify(result)}\n\n`;
controller.enqueue(encoder.encode(line)); controller.enqueue(encoder.encode(line));
} catch { } 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`)); controller.enqueue(encoder.encode(`data: ${JSON.stringify(fallback)}\n\n`));
} }
} }
+31 -5
View File
@@ -18,6 +18,15 @@ interface VideoToProbe {
episodeIndex?: number; episodeIndex?: number;
} }
interface ResolutionProbeEvent {
done?: boolean;
id: string | number;
source: string;
episodeIndex?: number;
resolution?: ResolutionInfo | null;
resolutionOrigin?: 'manifest' | 'hint';
}
function getSourceConfigsForProbe(videos: VideoToProbe[]): VideoSource[] { function getSourceConfigsForProbe(videos: VideoToProbe[]): VideoSource[] {
if (typeof window === 'undefined' || videos.length === 0) { if (typeof window === 'undefined' || videos.length === 0) {
return []; return [];
@@ -48,13 +57,17 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
const [resolutions, setResolutions] = useState<Record<string, ResolutionInfo | null>>({}); const [resolutions, setResolutions] = useState<Record<string, ResolutionInfo | null>>({});
const [isProbing, setIsProbing] = useState(false); const [isProbing, setIsProbing] = useState(false);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const probedKeysRef = useRef<Set<string>>(new Set()); const completedKeysRef = useRef<Set<string>>(new Set());
const inFlightKeysRef = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
if (!videos || videos.length === 0) return; if (!videos || videos.length === 0) return;
const inFlightKeys = inFlightKeysRef.current;
const completedKeys = completedKeysRef.current;
const cached: Record<string, ResolutionInfo | null> = {}; const cached: Record<string, ResolutionInfo | null> = {};
const needProbe: VideoToProbe[] = []; const needProbe: VideoToProbe[] = [];
const batchRequestKeys: string[] = [];
for (const video of videos) { for (const video of videos) {
const resultKey = `${video.source}:${video.id}`; const resultKey = `${video.source}:${video.id}`;
@@ -63,9 +76,13 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
if (shouldReuseCachedResolution(cachedInfo, video.episodeIndex)) { if (shouldReuseCachedResolution(cachedInfo, video.episodeIndex)) {
cached[resultKey] = cachedInfo; cached[resultKey] = cachedInfo;
} else if (!probedKeysRef.current.has(requestKey)) { } else if (
!completedKeys.has(requestKey) &&
!inFlightKeys.has(requestKey)
) {
needProbe.push(video); 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) { for (const line of lines) {
if (!line.startsWith('data: ')) continue; if (!line.startsWith('data: ')) continue;
try { try {
const data = JSON.parse(line.slice(6)); const data = JSON.parse(line.slice(6)) as ResolutionProbeEvent;
if (data.done) continue; if (data.done) continue;
const resultKey = `${data.source}:${data.id}`; const resultKey = `${data.source}:${data.id}`;
const requestKey = `${data.source}:${data.id}:${data.episodeIndex ?? 0}`;
inFlightKeys.delete(requestKey);
completedKeys.add(requestKey);
if (data.resolution) { if (data.resolution) {
const resolution: ResolutionInfo = { const resolution: ResolutionInfo = {
...data.resolution, ...data.resolution,
origin: 'probed', origin: data.resolutionOrigin === 'hint' ? 'hint' : 'probed',
episodeIndex: typeof data.episodeIndex === 'number' ? data.episodeIndex : undefined, episodeIndex: typeof data.episodeIndex === 'number' ? data.episodeIndex : undefined,
}; };
setCachedResolution(data.source, data.id, resolution); setCachedResolution(data.source, data.id, resolution);
@@ -136,12 +156,18 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
console.warn('[ResolutionProbe] Failed:', error); console.warn('[ResolutionProbe] Failed:', error);
} }
} finally { } finally {
for (const requestKey of batchRequestKeys) {
inFlightKeys.delete(requestKey);
}
setIsProbing(false); setIsProbing(false);
} }
})(); })();
return () => { return () => {
controller.abort(); controller.abort();
for (const requestKey of batchRequestKeys) {
inFlightKeys.delete(requestKey);
}
}; };
}, [videos]); }, [videos]);
+4 -3
View File
@@ -1,9 +1,9 @@
export interface ResolutionCacheEntry { export interface ResolutionCacheEntry {
width: number; width?: number;
height: number; height?: number;
label: string; label: string;
color: string; color: string;
origin?: 'probed' | 'played'; origin?: 'probed' | 'played' | 'hint';
episodeIndex?: number; episodeIndex?: number;
} }
@@ -45,5 +45,6 @@ export function shouldReuseCachedResolution(
): boolean { ): boolean {
if (!entry) return false; if (!entry) return false;
if (entry.origin === 'played') return true; if (entry.origin === 'played') return true;
if (entry.origin === 'hint') return false;
return entry.episodeIndex === episodeIndex; return entry.episodeIndex === episodeIndex;
} }
+178
View File
@@ -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<string, number> = {
'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<string | undefined>): 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<string>();
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);
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { extractQualityLabel } from '@/lib/utils/video'; import { extractPlaybackQualityLabel } from '@/lib/utils/video';
export interface ResolutionBadge { export interface ResolutionBadge {
label: string; label: string;
@@ -8,7 +8,7 @@ export interface ResolutionBadge {
export interface ResolutionLike extends ResolutionBadge { export interface ResolutionLike extends ResolutionBadge {
width?: number; width?: number;
height?: number; height?: number;
origin?: 'probed' | 'played'; origin?: 'probed' | 'played' | 'hint';
episodeIndex?: number; episodeIndex?: number;
} }
@@ -42,5 +42,5 @@ export function getSourceResolutionBadge(options: {
return { label: cachedResolution.label, color: cachedResolution.color }; return { label: cachedResolution.label, color: cachedResolution.color };
} }
return extractQualityLabel(remarks) || null; return extractPlaybackQualityLabel(remarks) || null;
} }
+14 -11
View File
@@ -29,7 +29,7 @@ export function parseVideoTitle(title: string): { cleanTitle: string, quality?:
/** /**
* Quality keywords and their display labels, ordered by priority (highest first). * 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: /4k|2160p|uhd/i, label: '4K', color: 'bg-amber-500' },
{ pattern: /2k|1440p|qhd/i, label: '2K', color: 'bg-emerald-500' }, { pattern: /2k|1440p|qhd/i, label: '2K', color: 'bg-emerald-500' },
{ pattern: /蓝光|藍光|bluray|blu-ray|remux/i, label: '蓝光', color: 'bg-blue-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: /web-?dl|webrip/i, label: 'WEB-DL', color: 'bg-indigo-500' },
{ pattern: /hdtv/i, label: 'HDTV', color: 'bg-teal-500' }, { pattern: /hdtv/i, label: 'HDTV', color: 'bg-teal-500' },
{ pattern: /dvd|dvdrip/i, label: 'DVD', color: 'bg-purple-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: /抢先|枪版|ts版|(?:^|[\s([【_-])(ts|cam|hdts)(?:$|[\s)\]】_-])|预告/i, label: 'TS', color: 'bg-orange-500' },
{ pattern: /标清|sd\b/i, label: 'SD', color: 'bg-gray-500' }, { pattern: /标清|(?:^|[\s([【_-])sd(?:$|[\s)\]】_-])/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' },
]; ];
/** /**
* Extracts quality label from video remarks or title. * Extracts a playback quality label from video remarks or title.
* Returns the quality label and its associated color class. * 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 || ''}`; const text = `${remarks || ''} ${quality || ''}`;
if (!text.trim()) return null; 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)) { if (pattern.test(text)) {
return { label, color }; return { label, color };
} }
@@ -68,3 +67,7 @@ export function extractQualityLabel(remarks?: string, quality?: string): { label
return null; return null;
} }
export function extractQualityLabel(remarks?: string, quality?: string): { label: string; color: string } | null {
return extractPlaybackQualityLabel(remarks, quality);
}
+18
View File
@@ -5,6 +5,7 @@ import {
shouldExpandForCurrentSource, shouldExpandForCurrentSource,
} from '@/lib/player/source-list-utils'; } from '@/lib/player/source-list-utils';
import { shouldReuseCachedResolution } from '@/lib/player/resolution-cache'; import { shouldReuseCachedResolution } from '@/lib/player/resolution-cache';
import { extractPlaybackQualityLabel } from '@/lib/utils/video';
test('shouldExpandForCurrentSource detects hidden active sources', () => { test('shouldExpandForCurrentSource detects hidden active sources', () => {
const sources = [ const sources = [
@@ -52,6 +53,16 @@ test('getSourceResolutionBadge prefers current actual resolution, then probed, t
assert.deepEqual(remark, { label: '蓝光', color: 'bg-blue-500' }); 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', () => { test('shouldReuseCachedResolution keeps played results across episode changes but re-probes stale probed data', () => {
assert.equal(shouldReuseCachedResolution({ assert.equal(shouldReuseCachedResolution({
width: 1920, width: 1920,
@@ -79,4 +90,11 @@ test('shouldReuseCachedResolution keeps played results across episode changes bu
origin: 'probed', origin: 'probed',
episodeIndex: 2, episodeIndex: 2,
}, 5), false); }, 5), false);
assert.equal(shouldReuseCachedResolution({
label: '蓝光',
color: 'bg-blue-500',
origin: 'hint',
episodeIndex: 2,
}, 2), false);
}); });
+61
View File
@@ -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',
]
);
});