Use XUI /play/token stream URLs from m3u playlist for live TV.
Classic /live/user/pass/id hangs on real live channels on XUI 1.5; created_live (24/7) still worked, which matched the reported symptom.
This commit is contained in:
parent
b9900e1c3a
commit
2df8a6a83c
3 changed files with 111 additions and 20 deletions
|
|
@ -433,27 +433,32 @@ export async function getIptvStreams(
|
||||||
const parsed = parseIptvId(id);
|
const parsed = parseIptvId(id);
|
||||||
if (!parsed) return [];
|
if (!parsed) return [];
|
||||||
|
|
||||||
// Direct Xtream/XUI URLs only (client → IPTV server). No Master proxy.
|
// Direct client → XUI URLs. Prefer XUI /play/{token} from m3u playlist
|
||||||
// Do NOT set notWebReady — that skips playback and XUI never sees a connection.
|
// (classic /live/user/pass/id hangs on real live; works for created_live 24/7).
|
||||||
if (type === "tv" && parsed.kind === "live") {
|
if (type === "tv" && parsed.kind === "live") {
|
||||||
const sid = parsed.streamId;
|
const sid = parsed.streamId;
|
||||||
return [
|
const resolved = await xt.client.resolveLivePlayUrls(sid);
|
||||||
{
|
const out: Array<{ name: string; title: string; url: string }> = [];
|
||||||
name: "Live · HLS",
|
|
||||||
title: "m3u8",
|
if (resolved.hls) {
|
||||||
url: xt.client.liveStreamUrl(sid, "m3u8"),
|
out.push({ name: "Live · HLS", title: "XUI play m3u8", url: resolved.hls });
|
||||||
},
|
}
|
||||||
{
|
if (resolved.ts && resolved.ts !== resolved.hls) {
|
||||||
name: "Live · MPEG-TS",
|
out.push({ name: "Live · MPEG-TS", title: "XUI play ts", url: resolved.ts });
|
||||||
title: "ts",
|
}
|
||||||
url: xt.client.liveStreamUrl(sid, "ts"),
|
|
||||||
},
|
// Fallback for panels without /play/ playlist (or created_live)
|
||||||
{
|
if (out.length === 0) {
|
||||||
name: "Live · raw",
|
for (const url of resolved.classic) {
|
||||||
title: "no extension",
|
out.push({
|
||||||
url: xt.client.liveStreamUrl(sid, ""),
|
name: url.endsWith(".ts") ? "Live · MPEG-TS" : "Live · HLS",
|
||||||
},
|
title: "classic Xtream",
|
||||||
];
|
url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "movie" && parsed.kind === "vod") {
|
if (type === "movie" && parsed.kind === "vod") {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export type XtreamLiveStream = {
|
||||||
category_id?: string;
|
category_id?: string;
|
||||||
tv_archive?: number;
|
tv_archive?: number;
|
||||||
tv_archive_duration?: number;
|
tv_archive_duration?: number;
|
||||||
|
direct_source?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type XtreamVodStream = {
|
export type XtreamVodStream = {
|
||||||
|
|
@ -253,6 +254,62 @@ export class XtreamClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XUI 1.5+ playlists use /play/{token}/m3u8 instead of classic /live/user/pass/id.
|
||||||
|
* Real live channels often hang on classic URLs; created_live (24/7) still works classically.
|
||||||
|
*/
|
||||||
|
async getLivePlayUrlMap(output: "hls" | "ts" = "hls"): Promise<Map<number, string>> {
|
||||||
|
const cacheKey = `playlist:${output}`;
|
||||||
|
const cached = this.cacheGet<Map<number, string>>(cacheKey, 5 * 60_000);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const user = encodeURIComponent(this.username);
|
||||||
|
const pass = encodeURIComponent(this.password);
|
||||||
|
const url = `${this.baseUrl}/playlist/${user}/${pass}/m3u_plus?output=${output}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "MediaCluster/1.0" },
|
||||||
|
signal: AbortSignal.timeout(45000),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`XUI playlist HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
const map = parseXuiPlaylistPlayUrls(text);
|
||||||
|
this.cacheSet(cacheKey, map);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer XUI /play/token URL; fall back to classic Xtream path. */
|
||||||
|
async resolveLivePlayUrls(streamId: number): Promise<{ hls?: string; ts?: string; classic: string[] }> {
|
||||||
|
let hls: string | undefined;
|
||||||
|
try {
|
||||||
|
const hlsMap = await this.getLivePlayUrlMap("hls");
|
||||||
|
hls = hlsMap.get(streamId);
|
||||||
|
} catch {
|
||||||
|
// ignore — fall back
|
||||||
|
}
|
||||||
|
|
||||||
|
let ts: string | undefined;
|
||||||
|
if (hls) {
|
||||||
|
if (/\/m3u8$/i.test(hls)) {
|
||||||
|
ts = hls.replace(/\/m3u8$/i, "/ts");
|
||||||
|
} else if (/\/play\/[^/]+$/i.test(hls)) {
|
||||||
|
const base = hls.replace(/\/$/, "");
|
||||||
|
hls = `${base}/m3u8`;
|
||||||
|
ts = `${base}/ts`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hls,
|
||||||
|
ts,
|
||||||
|
classic: [
|
||||||
|
this.liveStreamUrl(streamId, "m3u8"),
|
||||||
|
this.liveStreamUrl(streamId, "ts"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
liveStreamUrl(streamId: number, ext: "m3u8" | "ts" | "" = "m3u8"): string {
|
liveStreamUrl(streamId: number, ext: "m3u8" | "ts" | "" = "m3u8"): string {
|
||||||
const user = encodeURIComponent(this.username);
|
const user = encodeURIComponent(this.username);
|
||||||
const pass = encodeURIComponent(this.password);
|
const pass = encodeURIComponent(this.password);
|
||||||
|
|
@ -273,6 +330,35 @@ export class XtreamClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse XUI/Xtream m3u_plus: map xui-id / tvg channel stream id → play URL. */
|
||||||
|
export function parseXuiPlaylistPlayUrls(m3u: string): Map<number, string> {
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
const lines = m3u.split(/\r?\n/);
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line.startsWith("#EXTINF")) continue;
|
||||||
|
const idMatch =
|
||||||
|
line.match(/\bxui-id="(\d+)"/i) ||
|
||||||
|
line.match(/\btvg-ID="(\d+)"/i) ||
|
||||||
|
line.match(/\bstreamId="(\d+)"/i);
|
||||||
|
if (!idMatch) continue;
|
||||||
|
const streamId = parseInt(idMatch[1], 10);
|
||||||
|
if (!Number.isFinite(streamId)) continue;
|
||||||
|
let url = "";
|
||||||
|
for (let j = i + 1; j < lines.length; j++) {
|
||||||
|
const next = lines[j]?.trim();
|
||||||
|
if (!next) continue;
|
||||||
|
if (next.startsWith("#")) break;
|
||||||
|
url = next;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (url && /^https?:\/\//i.test(url)) {
|
||||||
|
map.set(streamId, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
export function decodeEpgText(raw?: string): string {
|
export function decodeEpgText(raw?: string): string {
|
||||||
if (!raw) return "";
|
if (!raw) return "";
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ async function buildManifest(tokenId: string | null, config: Config) {
|
||||||
const types = new Set(["movie", "series", ...iptv.types]);
|
const types = new Set(["movie", "series", ...iptv.types]);
|
||||||
return {
|
return {
|
||||||
id: "com.mediacluster.library",
|
id: "com.mediacluster.library",
|
||||||
version: "1.5.2",
|
version: "1.5.3",
|
||||||
name: "Media Cluster",
|
name: "Media Cluster",
|
||||||
description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG",
|
description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG",
|
||||||
catalogs: [...BASE_CATALOGS, ...iptv.catalogs],
|
catalogs: [...BASE_CATALOGS, ...iptv.catalogs],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue