diff --git a/app/api/probe-resolution/route.ts b/app/api/probe-resolution/route.ts new file mode 100644 index 0000000..0d11a12 --- /dev/null +++ b/app/api/probe-resolution/route.ts @@ -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 { + 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' }, + }); + } +} diff --git a/lib/hooks/useResolutionProbe.ts b/lib/hooks/useResolutionProbe.ts new file mode 100644 index 0000000..4a958e8 --- /dev/null +++ b/lib/hooks/useResolutionProbe.ts @@ -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; + isProbing: boolean; +} { + const [resolutions, setResolutions] = useState>({}); + const [isProbing, setIsProbing] = useState(false); + const abortRef = useRef(null); + // Track which videos we've already started probing to avoid duplicates + const probedKeysRef = useRef>(new Set()); + + useEffect(() => { + if (!videos || videos.length === 0) return; + + // Check cache first, find which ones need probing + const cached: Record = {}; + 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 }; +}