S1 / src /lib /misc.ts
GUI-STUDIO
Add AniWatch and fMP4 HLS delivery support
b59895c
Raw History Blame Contribute Delete
8.05 kB
import { NS_playback_helper } from "@x/core/server/playback.ts";
import createError from "http-errors";
function isUrlEncoded(str: string) {
try {
return decodeURIComponent(str) !== str;
} catch (e) {
return false;
}
}
function isValidUrlFormat(url: string) {
try {
new URL(url);
return true;
} catch (e) {
return false;
}
}
async function F_test_connection(arg0: {
url: URL;
headers?: Record<string, any> | null;
}) {
const { url, headers } = arg0;
const controller = new AbortController();
const t = setTimeout(() => {
controller.abort();
}, 5_000);
try {
const response = await fetch(url.href, {
method: "GET",
headers: headers || {},
signal: controller.signal
});
clearTimeout(t);
if (!response.ok) {
console.error("✗ Network test failed:", response.statusText);
throw createError(
response.status,
`Ping failed: ${response.statusText} ${response.status}`
);
}
console.log("✓ Network connection OK:", response.status);
return true;
} catch (error: any) {
clearTimeout(t);
console.error(error);
// throw createError(`Cannot reach URL: ${error.message}`);
throw error;
}
}
function F_get_obj_kv(arg0: {
name: string;
obj?: Record<string, any> | null;
}) {
const { name, obj } = arg0;
if (!obj || typeof obj !== "object") return;
const key = Object.keys(obj).find(
(k) => k.toLowerCase() === name.toLowerCase()
);
return key ? obj[key] : null;
}
function is_plain_object(obj: any): obj is Record<string, any> {
return !!obj && obj.constructor === Object;
}
function cleanName(name: string): string {
// Replace any character that is not a letter, number, bracket, -, or _ with an underscore
return name.replace(/[^\w\-()[\]{}]/g, "_");
}
function cleanNameV2(name: string): string {
// Keep only letters, numbers, -, _, (, ), [, ], {, }, and .
// Replace everything else with _
return name.replace(/[^\w\-()[\]{}.]/g, "_");
}
function isValidUnixTimestamp(timestamp: number): boolean {
const date = new Date(timestamp);
return !isNaN(date.getTime());
}
/*
If given a master playlist, follow the highest-bandwidth variant.
`separate_audio` is true when that variant takes its audio from a separate
rendition playlist: a single-file passthrough would silently lose the audio.
*/
async function F_resolve_media_hls_playlist(arg0: {
url: string;
headers: Record<string, any>;
}): Promise<{ url: string; text: string; separate_audio: boolean }> {
const { url, headers } = arg0;
const text = await fetchText({ url, headers });
if (!text.includes("#EXT-X-STREAM-INF")) {
return { url, text, separate_audio: false };
}
const lines = text.split("\n").map((l) => l.trim());
let best = { bw: -1, uri: "", audio: undefined as string | undefined };
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line?.startsWith("#EXT-X-STREAM-INF")) continue;
const bw = Number(/BANDWIDTH=(\d+)/.exec(line)?.[1] ?? 0);
const audio = /AUDIO="([^"]+)"/.exec(line)?.[1];
const uri = lines[i + 1];
if (uri && !uri.startsWith("#") && bw > best.bw) {
best = { bw, uri, audio };
}
}
// without this an empty uri resolves to the same url and recurses forever
if (!best.uri) throw new Error("No playable variant in master playlist");
const separate_audio =
!!best.audio &&
lines.some(
(l) =>
l.startsWith("#EXT-X-MEDIA") &&
/TYPE=AUDIO/.test(l) &&
l.includes(`GROUP-ID="${best.audio}"`) &&
/URI="/.test(l)
);
const next = await F_resolve_media_hls_playlist({
url: new URL(best.uri, url).href,
headers
});
return { ...next, separate_audio: next.separate_audio || separate_audio };
async function fetchText(arg0: {
url: string;
headers: Record<string, any>;
}) {
const { url, headers } = arg0;
const res = await fetch(url, {
headers: headers
});
if (!res.ok)
throw new Error(`Fetch failed: status: (${res.status}): ${url}`);
const content_type = res.headers.get("content-type");
const is_m3u8 =
content_type?.includes("application/vnd.apple.mpegurl") ||
content_type?.includes("application/x-mpegurl") ||
(await NS_playback_helper.F_is_M3U8_response({
response: res.clone() as any
}));
if (!is_m3u8) {
throw new Error(`Content type not supported`);
}
return res.text();
}
}
type Range = { offset: number; length: number };
type Init = { url: string; key: Key; range?: Range };
type Key = { uri: string; iv?: string } | null;
type Segment = { url: string; seq: number; key: Key; range?: Range };
type Media = {
segments: Segment[];
initUrl?: string;
init?: Init;
init_changes: boolean;
ended: boolean;
};
/*
parse an HLS media playlist (.m3u8) into segments, encryption keys, the
initialization segment (fMP4/CMAF), byte ranges and playlist status.
*/
function F_parse_hls_media(arg0: { text: string; base: string }): Media {
const { text, base } = arg0;
let seq = Number(/#EXT-X-MEDIA-SEQUENCE:(\d+)/.exec(text)?.[1] ?? 0);
let key: Key = null;
let init: Init | undefined;
let init_changes = false;
let pending_range: Range | undefined;
let prev_end: number | undefined;
const segments: Segment[] = [];
for (const raw of text.split("\n")) {
const line = raw.trim();
if (!line) continue;
if (line.startsWith("#EXT-X-KEY")) {
const method = /METHOD=([^,]+)/.exec(line)?.[1];
if (method === "NONE") {
key = null;
} else if (method === "AES-128") {
const uri = /URI="([^"]+)"/.exec(line)![1]!;
key = {
uri: new URL(uri, base).href,
iv: /IV=(0x[0-9a-fA-F]+)/i.exec(line)?.[1]
};
} else {
throw new Error(`Unsupported encryption: ${method}`);
}
} else if (line.startsWith("#EXT-X-MAP")) {
const uri = /URI="([^"]+)"/.exec(line)?.[1];
if (!uri) throw new Error("EXT-X-MAP without a URI");
const br = /BYTERANGE="([^"]+)"/.exec(line)?.[1];
const next: Init = {
url: new URL(uri, base).href,
key,
range: br ? parseByteRange(br, undefined) : undefined
};
if (!init) {
init = next;
} else if (
init.url !== next.url ||
init.range?.offset !== next.range?.offset ||
init.range?.length !== next.range?.length
) {
init_changes = true;
}
} else if (line.startsWith("#EXT-X-BYTERANGE")) {
pending_range = parseByteRange(
line.slice(line.indexOf(":") + 1).trim(),
prev_end
);
} else if (!line.startsWith("#")) {
segments.push({
url: new URL(line, base).href,
seq: seq++,
key,
...(pending_range ? { range: pending_range } : {})
});
prev_end = pending_range
? pending_range.offset + pending_range.length
: undefined;
pending_range = undefined;
}
}
return {
segments,
init,
init_changes,
// a VOD playlist is complete even when a host forgot the ENDLIST tag
ended:
text.includes("#EXT-X-ENDLIST") ||
/#EXT-X-PLAYLIST-TYPE:\s*VOD/.test(text)
};
/*
"len@offset" or "len". Without an offset the range continues right after the
previous segment's range (RFC 8216 4.3.2.2).
*/
function parseByteRange(value: string, prev_end: number | undefined): Range {
const [len, off] = value.split("@");
const length = Number(len);
const offset = off !== undefined ? Number(off) : (prev_end ?? 0);
if (!Number.isFinite(length) || !Number.isFinite(offset) || length <= 0) {
throw new Error(`Invalid BYTERANGE: ${value}`);
}
return { length, offset };
}
}
export const NS_misc = {
isUrlEncoded,
isValidUrlFormat,
F_test_connection,
F_get_obj_kv,
is_plain_object,
cleanName,
cleanNameV2,
isValidUnixTimestamp,
F_resolve_media_hls_playlist,
F_parse_hls_media
};