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
+31 -5
View File
@@ -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<Record<string, ResolutionInfo | null>>({});
const [isProbing, setIsProbing] = useState(false);
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(() => {
if (!videos || videos.length === 0) return;
const inFlightKeys = inFlightKeysRef.current;
const completedKeys = completedKeysRef.current;
const cached: Record<string, ResolutionInfo | null> = {};
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]);
+4 -3
View File
@@ -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;
}
+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 {
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;
}
+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).
*/
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);
}