Files
native-app/packages/provider-utils/src/video.ts
2024-02-20 16:28:51 +01:00

210 lines
4.1 KiB
TypeScript

import type { Item } from "parse-hls";
import hls from "parse-hls";
import { default as toWebVTT } from "srt-webvtt";
import type {
EmbedOutput,
EmbedRunnerOptions,
FileBasedStream,
FullScraperEvents,
Qualities,
RunnerOptions,
RunOutput,
ScrapeMedia,
SourcererOutput,
SourceRunnerOptions,
Stream,
} from "@movie-web/providers";
import {
makeProviders,
makeStandardFetcher,
targets,
} from "@movie-web/providers";
export interface InitEvent {
sourceIds: string[];
}
export interface UpdateEvent {
id: string;
percentage: number;
status: UpdateEventStatus;
error?: unknown;
reason?: string;
}
export type UpdateEventStatus = "success" | "failure" | "notfound" | "pending";
export interface DiscoverEmbedsEvent {
sourceId: string;
embeds: {
id: string;
embedScraperId: string;
}[];
}
export type RunnerEvent =
| string
| InitEvent
| UpdateEvent
| DiscoverEmbedsEvent;
export const providers = makeProviders({
fetcher: makeStandardFetcher(fetch),
target: targets.NATIVE,
consistentIpForRequests: true,
});
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> {
let timeoutHandle: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error("Timeout")), timeoutMs);
});
return Promise.race([promise, timeoutPromise]).finally(() =>
clearTimeout(timeoutHandle),
);
}
export async function getVideoStream({
media,
forceVTT,
events,
}: {
media: ScrapeMedia;
forceVTT?: boolean;
events?: FullScraperEvents;
}): Promise<RunOutput | null> {
try {
const options: RunnerOptions = {
media,
events,
};
const stream = await withTimeout(providers.runAll(options), 10000);
if (!stream) return null;
if (forceVTT) {
const streamResult = await convertStreamCaptionsToWebVTT(stream.stream);
return { ...stream, stream: streamResult };
}
return stream;
} catch (error) {
return null;
}
}
export async function getVideoStreamFromSource({
sourceId,
media,
events,
}: {
sourceId: string;
media: ScrapeMedia;
events?: SourceRunnerOptions["events"];
}): Promise<SourcererOutput | null> {
try {
const sourceResult = await withTimeout(
providers.runSourceScraper({
id: sourceId,
media,
events,
}),
10000,
);
return sourceResult;
} catch (error) {
return null;
}
}
export async function getVideoStreamFromEmbed({
embedId,
url,
events,
}: {
embedId: string;
url: string;
events?: EmbedRunnerOptions["events"];
}): Promise<EmbedOutput | null> {
try {
const embedResult = await withTimeout(
providers.runEmbedScraper({
id: embedId,
url,
events,
}),
10000,
);
return embedResult;
} catch (error) {
return null;
}
}
export function findHighestQuality(
stream: FileBasedStream,
): Qualities | undefined {
const qualityOrder: Qualities[] = [
"4k",
"1080",
"720",
"480",
"360",
"unknown",
];
for (const quality of qualityOrder) {
if (stream.qualities[quality]) {
return quality;
}
}
return undefined;
}
export interface HLSTracks {
video: Item[];
audio: Item[];
subtitles: Item[];
}
export async function extractTracksFromHLS(
playlistUrl: string,
headers: Record<string, string>,
): Promise<HLSTracks | null> {
try {
const response = await fetch(playlistUrl, { headers }).then((res) =>
res.text(),
);
const playlist = hls.parse(response);
return {
video: playlist.streamRenditions,
audio: playlist.audioRenditions,
subtitles: playlist.subtitlesRenditions,
};
} catch (e) {
return null;
}
}
export async function convertStreamCaptionsToWebVTT(
stream: Stream,
): Promise<Stream> {
if (!stream.captions) return stream;
for (const caption of stream.captions) {
if (caption.type === "srt") {
const response = await fetch(caption.url);
const srt = await response.blob();
caption.url = await toWebVTT(srt);
caption.type = "vtt";
}
}
return stream;
}