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:
@@ -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<string, VideoSource> {
|
||||
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<string> {
|
||||
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<string, VideoSource>): 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`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user