diff --git a/app/api/probe-resolution/route.ts b/app/api/probe-resolution/route.ts index 0d11a12..3c98353 100644 --- a/app/api/probe-resolution/route.ts +++ b/app/api/probe-resolution/route.ts @@ -8,6 +8,7 @@ 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 type { VideoSource } from '@/lib/types'; export const runtime = 'edge'; @@ -16,6 +17,34 @@ interface ProbeRequest { source: string; } +function isValidSourceConfig(value: unknown): value is VideoSource { + if (!value || typeof value !== 'object') { + return false; + } + + const source = value as Partial; + return typeof source.id === 'string' && + typeof source.name === 'string' && + typeof source.baseUrl === 'string' && + typeof source.searchPath === 'string' && + typeof source.detailPath === 'string'; +} + +function buildSourceConfigMap(rawConfigs: unknown): Map { + const configs = new Map(); + if (!Array.isArray(rawConfigs)) { + return configs; + } + + for (const config of rawConfigs) { + if (isValidSourceConfig(config)) { + configs.set(config.id, config); + } + } + + 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' }; @@ -39,13 +68,13 @@ function parseResolutionFromM3u8(content: string): { width: number; height: numb return resolutions.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0]; } -async function probeOne(video: ProbeRequest): Promise<{ +async function probeOne(video: ProbeRequest, providedConfigs: Map): Promise<{ id: string | number; source: string; resolution: { width: number; height: number; label: string; color: string } | null; }> { try { - const sourceConfig = getSourceById(video.source); + const sourceConfig = providedConfigs.get(video.source) || getSourceById(video.source); if (!sourceConfig) return { id: video.id, source: video.source, resolution: null }; // 1. Get detail to find first episode URL @@ -114,6 +143,7 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); const videos: ProbeRequest[] = body.videos; + const sourceConfigs = buildSourceConfigMap(body.sourceConfigs); if (!Array.isArray(videos) || videos.length === 0) { return new Response(JSON.stringify({ error: 'Missing videos array' }), { @@ -136,7 +166,7 @@ export async function POST(request: NextRequest) { while (index < batch.length) { const current = batch[index++]; try { - const result = await probeOne(current); + const result = await probeOne(current, sourceConfigs); const line = `data: ${JSON.stringify(result)}\n\n`; controller.enqueue(encoder.encode(line)); } catch { @@ -160,7 +190,7 @@ export async function POST(request: NextRequest) { 'Connection': 'keep-alive', }, }); - } catch (error) { + } catch { return new Response(JSON.stringify({ error: 'Internal error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, diff --git a/app/page.tsx b/app/page.tsx index 5836ee2..12bdca5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,7 +4,6 @@ import { Suspense, useMemo } from 'react'; import { SearchForm } from '@/components/search/SearchForm'; import { NoResults } from '@/components/search/NoResults'; import { PopularFeatures } from '@/components/home/PopularFeatures'; -import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar'; import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar'; import { Navbar } from '@/components/layout/Navbar'; import { SearchResults } from '@/components/home/SearchResults'; @@ -85,9 +84,6 @@ function HomePage() { {/* Favorites Sidebar - Left */} - - {/* Watch History Sidebar - Right */} - ); } diff --git a/components/iptv/IPTVPlayer.tsx b/components/iptv/IPTVPlayer.tsx index 6895810..5c508f0 100644 --- a/components/iptv/IPTVPlayer.tsx +++ b/components/iptv/IPTVPlayer.tsx @@ -91,6 +91,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe const [isLive, setIsLive] = useState(true); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); + const [seekWindow, setSeekWindow] = useState<{ start: number; end: number; duration: number } | null>(null); const [volume, setVolume] = useState(1); const [isMuted, setIsMuted] = useState(false); const [showVolumeSlider, setShowVolumeSlider] = useState(false); @@ -105,6 +106,12 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe // Whether we have multi-source data const hasMultiSource = channelsBySource && sources && sources.length > 0; + const activeSourceId = channel.sourceId || null; + const activeGroupKey = activeSourceId && channel.group ? `${activeSourceId}::${channel.group}` : null; + const activeSource = useMemo( + () => (activeSourceId && sources ? sources.find((source) => source.id === activeSourceId) || null : null), + [activeSourceId, sources] + ); // Get current route URL const routes = channel.routes || [channel.url]; @@ -167,20 +174,31 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe const onPlay = () => setIsPlaying(true); const onPause = () => setIsPlaying(false); const onTimeUpdate = () => { + const range = getSeekRange(video); setCurrentTime(video.currentTime); - const dur = video.duration; - if (isFinite(dur) && dur > 0) { - setDuration(dur); + setSeekWindow(range); + if (range) { + setDuration(range.duration); setIsLive(false); } else { + const dur = video.duration; + if (isFinite(dur) && dur > 0) { + setDuration(dur); + } setIsLive(true); } }; const onDurationChange = () => { - const dur = video.duration; - if (isFinite(dur) && dur > 0) { - setDuration(dur); + const range = getSeekRange(video); + setSeekWindow(range); + if (range) { + setDuration(range.duration); setIsLive(false); + } else { + const dur = video.duration; + if (isFinite(dur) && dur > 0) { + setDuration(dur); + } } }; const onVolumeChange = () => { @@ -212,6 +230,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe setIsLive(true); setCurrentTime(0); setDuration(0); + setSeekWindow(null); // Clean up previous if (hlsRef.current) { @@ -310,7 +329,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe hlsProxy.attachMedia(video); // Filter HEVC levels for proxy attempt too - hlsProxy.on(Hls.Events.MANIFEST_PARSED, (_, data) => { + hlsProxy.on(Hls.Events.MANIFEST_PARSED, () => { filterHEVCLevels(hlsProxy); markLoaded(); video.play().catch(() => {}); @@ -349,7 +368,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe // First try initial URL (direct or proxied based on custom headers) hls.loadSource(initialUrl); hls.attachMedia(video); - hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => { + hls.on(Hls.Events.MANIFEST_PARSED, () => { // Filter HEVC levels to prevent audio-only playback filterHEVCLevels(hls); markLoaded(); @@ -469,14 +488,12 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe }; const progressPercent = useMemo(() => { - const video = videoRef.current; - const seekRange = video ? getSeekRange(video) : null; - if (seekRange) { - return Math.max(0, Math.min(100, ((currentTime - seekRange.start) / seekRange.duration) * 100)); + if (seekWindow) { + return Math.max(0, Math.min(100, ((currentTime - seekWindow.start) / seekWindow.duration) * 100)); } if (!duration) return 0; return Math.max(0, Math.min(100, (currentTime / duration) * 100)); - }, [currentTime, duration]); + }, [currentTime, duration, seekWindow]); const toggleFullscreen = async () => { if (!containerRef.current) return; @@ -567,24 +584,35 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe const isSearchMode = sidebarSearch.trim().length > 0; // Toggle source expansion - const toggleSource = (sourceId: string) => { + const toggleSource = useCallback((sourceId: string) => { setExpandedSources(prev => { const next = new Set(prev); if (next.has(sourceId)) next.delete(sourceId); else next.add(sourceId); return next; }); - }; + }, []); // Toggle group expansion - const toggleGroup = (key: string) => { + const toggleGroup = useCallback((key: string) => { setExpandedGroups(prev => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); - }; + }, []); + + const toggleActiveSource = useCallback(() => { + if (!activeSourceId) return; + toggleSource(activeSourceId); + }, [activeSourceId, toggleSource]); + + const toggleActiveGroup = useCallback(() => { + if (!activeSourceId || !channel.group) return; + setExpandedSources(prev => new Set(prev).add(activeSourceId)); + toggleGroup(`${activeSourceId}::${channel.group}`); + }, [activeSourceId, channel.group, toggleGroup]); // Render a channel button const renderChannelButton = (ch: M3UChannel, i: number) => { @@ -623,21 +651,35 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe // Render multi-level sidebar content const renderMultiLevelSidebar = () => { if (!channelsBySource || !sources) return null; + const orderedSources = activeSourceId + ? [ + ...sources.filter((source) => source.id === activeSourceId), + ...sources.filter((source) => source.id !== activeSourceId), + ] + : sources; return (
- {sources.map(source => { + {orderedSources.map(source => { const sourceData = channelsBySource[source.id]; if (!sourceData || sourceData.channels.length === 0) return null; const isExpanded = expandedSources.has(source.id); + const isActiveSource = source.id === activeSourceId; + const orderedGroups = isActiveSource && channel.group + ? [channel.group, ...sourceData.groups.filter((group) => group !== channel.group)] + : sourceData.groups; return (
{/* Source Header */}
+ {(activeSource || channel.group) && ( +
+
+ 当前 + {activeSource && ( + + )} + {channel.group && activeGroupKey && ( + + )} +
+
+ )}
{/* Sidebar Content */} diff --git a/lib/hooks/useResolutionProbe.ts b/lib/hooks/useResolutionProbe.ts index 4a958e8..28c4174 100644 --- a/lib/hooks/useResolutionProbe.ts +++ b/lib/hooks/useResolutionProbe.ts @@ -1,6 +1,8 @@ 'use client'; import { useState, useEffect, useRef } from 'react'; +import type { VideoSource } from '@/lib/types'; +import { settingsStore } from '@/lib/store/settings-store'; export interface ResolutionInfo { width: number; @@ -32,6 +34,25 @@ interface VideoToProbe { source: string; } +function getSourceConfigsForProbe(videos: VideoToProbe[]): VideoSource[] { + if (typeof window === 'undefined' || videos.length === 0) { + return []; + } + + const configuredSources = new Map(); + const { sources, premiumSources } = settingsStore.getSettings(); + + [...sources, ...premiumSources].forEach((source) => { + if (source?.id) { + configuredSources.set(source.id, source); + } + }); + + return Array.from(new Set(videos.map((video) => video.source))) + .map((sourceId) => configuredSources.get(sourceId)) + .filter((source): source is VideoSource => !!source); +} + /** * Hook that probes actual video resolutions via m3u8 manifests. * Returns a map of "source:id" -> ResolutionInfo. @@ -81,10 +102,11 @@ export function useResolutionProbe(videos: VideoToProbe[]): { (async () => { try { + const sourceConfigs = getSourceConfigsForProbe(needProbe); const response = await fetch('/api/probe-resolution', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ videos: needProbe }), + body: JSON.stringify({ videos: needProbe, sourceConfigs }), signal: controller.signal, }); @@ -119,9 +141,9 @@ export function useResolutionProbe(videos: VideoToProbe[]): { } catch { /* ignore */ } } } - } catch (e: any) { - if (e?.name !== 'AbortError') { - console.warn('[ResolutionProbe] Failed:', e); + } catch (error: unknown) { + if (!(error instanceof DOMException && error.name === 'AbortError')) { + console.warn('[ResolutionProbe] Failed:', error); } } finally { setIsProbing(false); diff --git a/lib/store/iptv-store.ts b/lib/store/iptv-store.ts index 9dbd7dc..be256df 100644 --- a/lib/store/iptv-store.ts +++ b/lib/store/iptv-store.ts @@ -83,7 +83,7 @@ async function loadPlaylistChannels( } const text = await res.text(); - const playlist = parseM3U(text); + const playlist = parseM3U(text, target.url); const directChannels = playlist.channels.map((channel) => ({ ...channel, group: channel.group || (depth > 0 ? target.name : channel.group), diff --git a/lib/utils/m3u-parser.ts b/lib/utils/m3u-parser.ts index 2827a30..391e0b4 100644 --- a/lib/utils/m3u-parser.ts +++ b/lib/utils/m3u-parser.ts @@ -30,43 +30,247 @@ export interface PlaylistReference { httpReferrer?: string; } -function resolveReferenceUrl(baseUrl: string | undefined, target: string): string { - if (!baseUrl) return target; - try { - return new URL(target, baseUrl).toString(); - } catch { - return target; +const STREAM_URL_RE = /^(https?:\/\/|rtmp:\/\/|rtsp:\/\/|udp:\/\/|rtp:\/\/|mms:\/\/|ftp:\/\/|file:\/\/|\/\/)/i; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed) return trimmed; + } } + return undefined; +} + +function unwrapProxyUrl(target: string): string { + const trimmed = target.trim(); + if (!trimmed.startsWith('proxy://')) { + return trimmed; + } + + const match = trimmed.match(/(?:^|[?&])(?:ext|url)=([^&]+)/i); + if (!match) { + return trimmed; + } + + try { + return decodeURIComponent(match[1]); + } catch { + return match[1]; + } +} + +function looksLikeRelativeMediaPath(value: string): boolean { + return value.startsWith('/') || + value.startsWith('./') || + value.startsWith('../') || + value.endsWith('.m3u8') || + value.endsWith('.m3u') || + value.includes('.m3u8?') || + value.includes('.mp4'); +} + +function resolveReferenceUrl(baseUrl: string | undefined, target: string): string { + const normalizedTarget = unwrapProxyUrl(target); + if (!baseUrl) return normalizedTarget; + try { + return new URL(normalizedTarget, baseUrl).toString(); + } catch { + return normalizedTarget; + } +} + +function countStreamMatches(value: string): number { + const matches = value.match(/(?:https?:\/\/|rtmp:\/\/|rtsp:\/\/|udp:\/\/|rtp:\/\/|mms:\/\/|ftp:\/\/|file:\/\/|\/\/)/gi); + return matches ? matches.length : 0; +} + +function normalizeRouteCandidate(rawValue: string): string | null { + const trimmed = unwrapProxyUrl(rawValue); + if (!trimmed) { + return null; + } + + const fromDollar = trimmed.includes('$') ? trimmed.slice(trimmed.lastIndexOf('$') + 1).trim() : trimmed; + if (STREAM_URL_RE.test(fromDollar) || looksLikeRelativeMediaPath(fromDollar)) { + return fromDollar; + } + + const fromComma = fromDollar.includes(',') ? fromDollar.slice(fromDollar.lastIndexOf(',') + 1).trim() : fromDollar; + if (STREAM_URL_RE.test(fromComma) || looksLikeRelativeMediaPath(fromComma)) { + return fromComma; + } + + return STREAM_URL_RE.test(trimmed) || looksLikeRelativeMediaPath(trimmed) ? trimmed : null; +} + +function extractRoutes(value: unknown, baseUrl?: string): string[] { + if (Array.isArray(value)) { + return Array.from(new Set( + value.flatMap((item) => extractRoutes(item, baseUrl)) + )); + } + + if (typeof value !== 'string') { + if (isRecord(value)) { + return extractRoutes(firstString(value.url, value.src, value.link), baseUrl); + } + return []; + } + + const segments = value + .split(/\r?\n/) + .map((part) => part.trim()) + .filter(Boolean) + .flatMap((segment) => ( + segment.includes('#') && countStreamMatches(segment) > 1 + ? segment.split('#').map((part) => part.trim()).filter(Boolean) + : [segment] + )); + + return Array.from(new Set( + segments + .map(normalizeRouteCandidate) + .filter((route): route is string => !!route) + .map((route) => resolveReferenceUrl(baseUrl, route)) + )); +} + +function buildChannel(entry: Record, baseUrl?: string, inheritedGroup?: string): M3UChannel | null { + const name = firstString( + entry.name, + entry.title, + entry.channel_name, + entry.channel, + entry.tvg_name, + entry.tvgName + ); + const routes = extractRoutes( + entry.urls ?? entry.url ?? entry.stream_url ?? entry.src ?? entry.link ?? entry.stream ?? entry.playUrl ?? entry.play_url, + baseUrl + ); + + if (!name || routes.length === 0) { + return null; + } + + const group = firstString(entry.group, entry.group_title, entry.groupName, entry.category, inheritedGroup); + const channel: M3UChannel = { + name, + url: routes[0], + logo: firstString(entry.logo, entry.icon, entry.tvg_logo), + group, + tvgId: firstString(entry.tvg_id, entry.tvgId), + tvgName: firstString(entry.tvg_name, entry.tvgName), + httpUserAgent: firstString(entry.http_user_agent, entry.httpUserAgent, entry.user_agent, entry.userAgent, entry.ua), + httpReferrer: firstString(entry.http_referrer, entry.httpReferrer, entry.referer, entry.referrer), + }; + + if (routes.length > 1) { + channel.routes = routes; + } + + return channel; +} + +function getJsonChannelEntries(data: unknown): Array<{ entry: Record; inheritedGroup?: string }> { + if (Array.isArray(data)) { + return data.filter(isRecord).map((entry) => ({ entry })); + } + + if (!isRecord(data)) { + return []; + } + + for (const candidate of [data.channels, data.list, data.items, data.data]) { + if (Array.isArray(candidate)) { + return candidate.filter(isRecord).map((entry) => ({ entry })); + } + } + + if (Array.isArray(data.lives)) { + const nestedChannels: Array<{ entry: Record; inheritedGroup?: string }> = []; + + for (const liveEntry of data.lives) { + if (!isRecord(liveEntry) || !Array.isArray(liveEntry.channels)) { + continue; + } + + const inheritedGroup = firstString( + liveEntry.group, + liveEntry.group_title, + liveEntry.groupName, + liveEntry.name, + liveEntry.title + ); + + for (const channelEntry of liveEntry.channels) { + if (isRecord(channelEntry)) { + nestedChannels.push({ entry: channelEntry, inheritedGroup }); + } + } + } + + if (nestedChannels.length > 0) { + return nestedChannels; + } + } + + return []; +} + +function getReferenceTargets(entry: Record): string[] { + const directUrl = firstString(entry.url); + if (directUrl) { + return [directUrl]; + } + + if (Array.isArray(entry.urls)) { + return entry.urls.filter((value): value is string => typeof value === 'string' && value.trim().length > 0); + } + + return []; } export function extractPlaylistReferences(content: string, baseUrl?: string): PlaylistReference[] { try { const data = JSON.parse(content); - if (!data || typeof data !== 'object') return []; + if (!isRecord(data)) return []; + const parsedData = data as Record; const references: PlaylistReference[] = []; - if (Array.isArray((data as any).lives)) { - for (const entry of (data as any).lives) { - if (!entry || typeof entry.url !== 'string') continue; - references.push({ - kind: 'playlist', - name: entry.name || entry.title || '直播源', - url: resolveReferenceUrl(baseUrl, entry.url), - httpUserAgent: entry.ua || entry.userAgent || entry.http_user_agent || entry.httpUserAgent, - httpReferrer: entry.referer || entry.referrer || entry.http_referrer || entry.httpReferrer, - }); + if (Array.isArray(parsedData.lives)) { + for (const entry of parsedData.lives) { + if (!isRecord(entry) || Array.isArray(entry.channels)) continue; + const targets = getReferenceTargets(entry); + for (const target of targets) { + references.push({ + kind: 'playlist', + name: firstString(entry.name, entry.title) || '直播源', + url: resolveReferenceUrl(baseUrl, target), + httpUserAgent: firstString(entry.ua, entry.userAgent, entry.http_user_agent, entry.httpUserAgent), + httpReferrer: firstString(entry.referer, entry.referrer, entry.http_referrer, entry.httpReferrer), + }); + } } } - if (Array.isArray((data as any).urls)) { - for (const entry of (data as any).urls) { - if (!entry || typeof entry.url !== 'string') continue; - references.push({ - kind: 'config', - name: entry.name || entry.title || '配置源', - url: resolveReferenceUrl(baseUrl, entry.url), - }); + if (Array.isArray(parsedData.urls)) { + for (const entry of parsedData.urls) { + if (!isRecord(entry)) continue; + const targets = getReferenceTargets(entry); + for (const target of targets) { + references.push({ + kind: 'config', + name: firstString(entry.name, entry.title) || '配置源', + url: resolveReferenceUrl(baseUrl, target), + }); + } } } @@ -81,51 +285,25 @@ export function extractPlaylistReferences(content: string, baseUrl?: string): Pl * Supports formats: * - Array of channel objects: [{ name, url, group?, logo?, ... }] * - Object with channels/list field: { channels: [...] } or { list: [...] } + * - TVBox/OK-style lives groups with nested channels arrays */ -function tryParseJSON(content: string): M3UPlaylist | null { +function tryParseJSON(content: string, baseUrl?: string): M3UPlaylist | null { try { const data = JSON.parse(content); - let channels: any[] = []; - - if (Array.isArray(data)) { - channels = data; - } else if (data && typeof data === 'object') { - channels = data.channels || data.list || data.items || data.data || []; - if (!Array.isArray(channels)) return null; - } else { - return null; - } - - if (channels.length === 0) return null; - - // Validate that items look like channel data - const first = channels[0]; - if (!first || typeof first !== 'object') return null; - // Must have at least a name and url - if (!first.name && !first.title && !first.channel_name && !first.channel) return null; - if (!first.url && !first.stream_url && !first.src && !first.link && !first.stream) return null; + const entries = getJsonChannelEntries(data); const groupSet = new Set(); const parsed: M3UChannel[] = []; - for (const ch of channels) { - const name = ch.name || ch.title || ch.channel_name || ch.channel || ''; - const url = ch.url || ch.stream_url || ch.src || ch.link || ch.stream || ''; - if (!name || !url) continue; + for (const { entry, inheritedGroup } of entries) { + const channel = buildChannel(entry, baseUrl, inheritedGroup); + if (!channel) continue; - const group = ch.group || ch.group_title || ch.groupName || ch.category || ''; - if (group) groupSet.add(group); + if (channel.group) { + groupSet.add(channel.group); + } - parsed.push({ - name, - url, - logo: ch.logo || ch.icon || ch.tvg_logo || undefined, - group: group || undefined, - tvgId: ch.tvg_id || ch.tvgId || undefined, - tvgName: ch.tvg_name || ch.tvgName || undefined, - httpUserAgent: ch.http_user_agent || ch.httpUserAgent || ch.user_agent || undefined, - httpReferrer: ch.http_referrer || ch.httpReferrer || ch.referer || ch.referrer || undefined, - }); + parsed.push(channel); } if (parsed.length === 0) return null; @@ -143,12 +321,12 @@ function tryParseJSON(content: string): M3UPlaylist | null { * Parse M3U playlist content into structured data. * Also supports JSON format channel lists. */ -export function parseM3U(content: string): M3UPlaylist { +export function parseM3U(content: string, baseUrl?: string): M3UPlaylist { const trimmed = content.trim(); // Try JSON first if it looks like JSON if (trimmed.startsWith('{') || trimmed.startsWith('[')) { - const jsonResult = tryParseJSON(trimmed); + const jsonResult = tryParseJSON(trimmed, baseUrl); if (jsonResult) return jsonResult; } @@ -190,7 +368,7 @@ export function parseM3U(content: string): M3UPlaylist { // Next non-comment line should be the URL for (let j = i + 1; j < lines.length; j++) { if (!lines[j].startsWith('#')) { - channel.url = lines[j]; + channel.url = resolveReferenceUrl(baseUrl, lines[j]); i = j; // Skip to after URL break; } @@ -208,7 +386,7 @@ export function parseM3U(content: string): M3UPlaylist { // If no EXTINF entries were found, also try JSON as a fallback if (channels.length === 0) { - const jsonResult = tryParseJSON(content); + const jsonResult = tryParseJSON(content, baseUrl); if (jsonResult) return jsonResult; }