mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
fix remaining issue 72 ui and iptv regressions
This commit is contained in:
@@ -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<VideoSource>;
|
||||
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<string, VideoSource> {
|
||||
const configs = new Map<string, VideoSource>();
|
||||
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<string, VideoSource>): 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' },
|
||||
|
||||
@@ -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 */}
|
||||
<FavoritesSidebar />
|
||||
|
||||
{/* Watch History Sidebar - Right */}
|
||||
<WatchHistorySidebar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+103
-19
@@ -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);
|
||||
setSeekWindow(range);
|
||||
if (range) {
|
||||
setDuration(range.duration);
|
||||
setIsLive(false);
|
||||
} else {
|
||||
const dur = video.duration;
|
||||
if (isFinite(dur) && dur > 0) {
|
||||
setDuration(dur);
|
||||
setIsLive(false);
|
||||
} else {
|
||||
}
|
||||
setIsLive(true);
|
||||
}
|
||||
};
|
||||
const onDurationChange = () => {
|
||||
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);
|
||||
setIsLive(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
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 (
|
||||
<div className="p-1">
|
||||
{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 (
|
||||
<div key={source.id} className="mb-1">
|
||||
{/* Source Header */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleSource(source.id); }}
|
||||
className="w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium text-white/90 hover:bg-white/10 transition-colors cursor-pointer"
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
||||
isActiveSource
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/90 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icons.TV size={14} className="flex-shrink-0 text-[var(--accent-color)]" />
|
||||
@@ -653,18 +695,23 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
{/* Source Content */}
|
||||
{isExpanded && (
|
||||
<div className="ml-2 border-l border-white/10 pl-1">
|
||||
{sourceData.groups.length > 0 ? (
|
||||
{orderedGroups.length > 0 ? (
|
||||
// Has groups — show group-level
|
||||
sourceData.groups.map(group => {
|
||||
orderedGroups.map(group => {
|
||||
const groupKey = `${source.id}::${group}`;
|
||||
const groupExpanded = expandedGroups.has(groupKey);
|
||||
const groupChannels = sourceData.channels.filter(ch => ch.group === group);
|
||||
const isActiveGroup = groupKey === activeGroupKey;
|
||||
|
||||
return (
|
||||
<div key={groupKey} className="mb-0.5">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleGroup(groupKey); }}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 rounded text-xs text-white/60 hover:bg-white/5 transition-colors cursor-pointer"
|
||||
className={`w-full flex items-center justify-between px-2 py-1.5 rounded text-xs transition-colors cursor-pointer ${
|
||||
isActiveGroup
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/60 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Icons.Tag size={12} className="flex-shrink-0" />
|
||||
@@ -958,6 +1005,43 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(activeSource || channel.group) && (
|
||||
<div className="px-3 py-2 border-b border-white/10">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] uppercase tracking-[0.18em] text-white/30">当前</span>
|
||||
{activeSource && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleActiveSource();
|
||||
}}
|
||||
className={`px-2 py-1 rounded-full text-[11px] border transition-colors cursor-pointer ${
|
||||
activeSourceId && expandedSources.has(activeSourceId)
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-white/5 border-white/10 text-white/80 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
源: {activeSource.name}
|
||||
</button>
|
||||
)}
|
||||
{channel.group && activeGroupKey && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleActiveGroup();
|
||||
}}
|
||||
className={`px-2 py-1 rounded-full text-[11px] border transition-colors cursor-pointer ${
|
||||
expandedGroups.has(activeGroupKey)
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-white/5 border-white/10 text-white/80 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
标签: {channel.group}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
|
||||
@@ -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<string, VideoSource>();
|
||||
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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
+237
-59
@@ -30,45 +30,249 @@ 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<string, unknown> {
|
||||
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<string, unknown>, 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<string, unknown>; 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<string, unknown>; 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, unknown>): 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<string, unknown>;
|
||||
|
||||
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;
|
||||
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: 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,
|
||||
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;
|
||||
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: entry.name || entry.title || '配置源',
|
||||
url: resolveReferenceUrl(baseUrl, entry.url),
|
||||
name: firstString(entry.name, entry.title) || '配置源',
|
||||
url: resolveReferenceUrl(baseUrl, target),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
} catch {
|
||||
@@ -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<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user