mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: implement Probe Resolution API and add useResolutionProbe hook for video resolution detection
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Probe Resolution API
|
||||
* Fetches actual video resolution by parsing m3u8 manifests.
|
||||
* Accepts a batch of videos and streams results back via SSE.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
interface ProbeRequest {
|
||||
id: string | number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
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]) });
|
||||
}
|
||||
if (resolutions.length === 0) return null;
|
||||
// Return highest resolution
|
||||
return resolutions.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0];
|
||||
}
|
||||
|
||||
async function probeOne(video: ProbeRequest): Promise<{
|
||||
id: string | number;
|
||||
source: string;
|
||||
resolution: { width: number; height: number; label: string; color: string } | null;
|
||||
}> {
|
||||
try {
|
||||
const sourceConfig = getSourceById(video.source);
|
||||
if (!sourceConfig) return { id: video.id, source: video.source, resolution: null };
|
||||
|
||||
// 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, resolution: null };
|
||||
}
|
||||
|
||||
const firstUrl = detail.episodes[0].url;
|
||||
if (!firstUrl) return { id: video.id, source: video.source, resolution: null };
|
||||
|
||||
// 2. Fetch the m3u8 manifest
|
||||
let m3u8Content: string;
|
||||
try {
|
||||
const res = await fetchWithTimeout(firstUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
}, 8000);
|
||||
m3u8Content = await res.text();
|
||||
} catch {
|
||||
// Try with proxy
|
||||
try {
|
||||
const proxyUrl = new URL('/api/proxy', 'http://localhost');
|
||||
proxyUrl.searchParams.set('url', firstUrl);
|
||||
// Can't call our own proxy from edge easily, so just return null
|
||||
return { id: video.id, source: video.source, resolution: null };
|
||||
} catch {
|
||||
return { id: video.id, source: video.source, resolution: null };
|
||||
}
|
||||
}
|
||||
|
||||
// 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, firstUrl).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, resolution: { ...subResolution, ...labelInfo } };
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
break; // Only try the first sub-playlist
|
||||
}
|
||||
}
|
||||
return { id: video.id, source: video.source, resolution: null };
|
||||
}
|
||||
|
||||
const labelInfo = getResolutionLabel(res.width, res.height);
|
||||
return { id: video.id, source: video.source, resolution: { ...res, ...labelInfo } };
|
||||
} catch {
|
||||
return { id: video.id, source: video.source, resolution: null };
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const videos: ProbeRequest[] = body.videos;
|
||||
|
||||
if (!Array.isArray(videos) || videos.length === 0) {
|
||||
return new Response(JSON.stringify({ error: 'Missing videos array' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Limit batch size
|
||||
const batch = videos.slice(0, 30);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Process in parallel with concurrency limit
|
||||
const CONCURRENCY = 6;
|
||||
let index = 0;
|
||||
|
||||
async function processNext(): Promise<void> {
|
||||
while (index < batch.length) {
|
||||
const current = batch[index++];
|
||||
try {
|
||||
const result = await probeOne(current);
|
||||
const line = `data: ${JSON.stringify(result)}\n\n`;
|
||||
controller.enqueue(encoder.encode(line));
|
||||
} catch {
|
||||
const fallback = { id: current.id, source: current.source, resolution: null };
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(fallback)}\n\n`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(CONCURRENCY, batch.length) }, () => processNext());
|
||||
await Promise.all(workers);
|
||||
controller.enqueue(encoder.encode('data: {"done":true}\n\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Internal error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export interface ResolutionInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const CACHE_PREFIX = 'res:';
|
||||
|
||||
function getCached(source: string, id: string | number): ResolutionInfo | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(`${CACHE_PREFIX}${source}:${id}`);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCache(source: string, id: string | number, info: ResolutionInfo) {
|
||||
try {
|
||||
sessionStorage.setItem(`${CACHE_PREFIX}${source}:${id}`, JSON.stringify(info));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
interface VideoToProbe {
|
||||
id: string | number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that probes actual video resolutions via m3u8 manifests.
|
||||
* Returns a map of "source:id" -> ResolutionInfo.
|
||||
* Results are cached in sessionStorage.
|
||||
*/
|
||||
export function useResolutionProbe(videos: VideoToProbe[]): {
|
||||
resolutions: Record<string, ResolutionInfo | null>;
|
||||
isProbing: boolean;
|
||||
} {
|
||||
const [resolutions, setResolutions] = useState<Record<string, ResolutionInfo | null>>({});
|
||||
const [isProbing, setIsProbing] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// Track which videos we've already started probing to avoid duplicates
|
||||
const probedKeysRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!videos || videos.length === 0) return;
|
||||
|
||||
// Check cache first, find which ones need probing
|
||||
const cached: Record<string, ResolutionInfo | null> = {};
|
||||
const needProbe: VideoToProbe[] = [];
|
||||
|
||||
for (const v of videos) {
|
||||
const key = `${v.source}:${v.id}`;
|
||||
const cachedInfo = getCached(v.source, v.id);
|
||||
if (cachedInfo) {
|
||||
cached[key] = cachedInfo;
|
||||
} else if (!probedKeysRef.current.has(key)) {
|
||||
needProbe.push(v);
|
||||
probedKeysRef.current.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Set cached results immediately
|
||||
if (Object.keys(cached).length > 0) {
|
||||
setResolutions(prev => ({ ...prev, ...cached }));
|
||||
}
|
||||
|
||||
if (needProbe.length === 0) return;
|
||||
|
||||
// Abort previous request
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setIsProbing(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/probe-resolution', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videos: needProbe }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
setIsProbing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.done) continue;
|
||||
const key = `${data.source}:${data.id}`;
|
||||
if (data.resolution) {
|
||||
setCache(data.source, data.id, data.resolution);
|
||||
setResolutions(prev => ({ ...prev, [key]: data.resolution }));
|
||||
} else {
|
||||
setResolutions(prev => ({ ...prev, [key]: null }));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
console.warn('[ResolutionProbe] Failed:', e);
|
||||
}
|
||||
} finally {
|
||||
setIsProbing(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [videos]);
|
||||
|
||||
return { resolutions, isProbing };
|
||||
}
|
||||
Reference in New Issue
Block a user