fix remaining issue 72 ui and iptv regressions

This commit is contained in:
kuekhaoyang
2026-04-02 22:14:46 +08:00
parent 5829a79ee3
commit 37b6e160d7
6 changed files with 410 additions and 100 deletions
+243 -65
View File
@@ -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<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;
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<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;
}