mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
Fix source resolution badge probing
This commit is contained in:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user