2026-05-15 17:05:37 +07:00
|
|
|
import { existsSync, statSync } from "node:fs";
|
|
|
|
|
import path from "node:path";
|
|
|
|
|
import { AppError } from "../errors";
|
|
|
|
|
import type { ResolvedMediaSource } from "./mediaTypes";
|
2026-05-15 19:49:01 +07:00
|
|
|
import { createPlayDlResolver } from "./playDlResolver";
|
2026-05-15 19:46:43 +07:00
|
|
|
import { createYtDlp, type YtDlpClient } from "./ytdlp";
|
2026-05-15 17:05:37 +07:00
|
|
|
|
2026-05-15 19:46:43 +07:00
|
|
|
type PlayDlResolver = ReturnType<typeof createPlayDlResolver>;
|
2026-05-15 17:05:37 +07:00
|
|
|
|
2026-05-15 19:46:43 +07:00
|
|
|
export interface MediaResolverDependencies {
|
|
|
|
|
ytdlp?: YtDlpClient;
|
|
|
|
|
playDlResolver?: PlayDlResolver;
|
|
|
|
|
}
|
2026-05-15 17:05:37 +07:00
|
|
|
|
2026-05-15 19:46:43 +07:00
|
|
|
export function createMediaResolver(
|
|
|
|
|
dependencies: MediaResolverDependencies = {},
|
|
|
|
|
) {
|
|
|
|
|
const ytdlp = dependencies.ytdlp ?? createYtDlp();
|
|
|
|
|
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
|
2026-05-15 17:05:37 +07:00
|
|
|
|
2026-05-15 19:46:43 +07:00
|
|
|
return async function resolve(input: string): Promise<ResolvedMediaSource> {
|
|
|
|
|
const source = input.trim();
|
|
|
|
|
if (!source) {
|
2026-05-15 19:49:01 +07:00
|
|
|
throw new AppError(
|
|
|
|
|
"Media source is required",
|
|
|
|
|
"MISSING_MEDIA_SOURCE",
|
|
|
|
|
400,
|
|
|
|
|
);
|
2026-05-15 19:46:43 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = parseUrl(source);
|
|
|
|
|
if (url && isYouTubeUrl(url)) {
|
|
|
|
|
const metadata = await ytdlp.getMetadata(source);
|
|
|
|
|
const directUrl = await ytdlp.getDirectAudioUrl(source);
|
|
|
|
|
return { source: directUrl, title: metadata.title, kind: "youtube" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (url && isSpotifyTrackUrl(url)) {
|
|
|
|
|
const result = await playDlResolver.resolveSpotifyTrack(source);
|
|
|
|
|
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
|
|
|
|
|
return { source: directUrl, title: result.title, kind: "spotify" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const urlSource = resolveUrlSource(source);
|
|
|
|
|
if (urlSource) return urlSource;
|
|
|
|
|
|
|
|
|
|
const localPath = path.resolve(source);
|
|
|
|
|
if (existsSync(localPath) && statSync(localPath).isFile()) {
|
|
|
|
|
return {
|
|
|
|
|
source: localPath,
|
|
|
|
|
title: path.basename(localPath),
|
|
|
|
|
kind: "local",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!url && !looksLikeUrl(source)) {
|
|
|
|
|
const result = await playDlResolver.searchYouTube(source);
|
|
|
|
|
const directUrl = await ytdlp.getDirectAudioUrl(result.url);
|
|
|
|
|
return { source: directUrl, title: result.title, kind: "search" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new AppError(
|
|
|
|
|
"Media source must be an HTTP(S) URL, YouTube URL, Spotify track URL, search query, or existing local file",
|
|
|
|
|
"UNSUPPORTED_MEDIA_SOURCE",
|
|
|
|
|
400,
|
|
|
|
|
);
|
|
|
|
|
};
|
2026-05-15 17:05:37 +07:00
|
|
|
}
|
|
|
|
|
|
2026-05-15 19:46:43 +07:00
|
|
|
export const resolveMediaSource = createMediaResolver();
|
|
|
|
|
|
|
|
|
|
function parseUrl(source: string): URL | null {
|
2026-05-15 17:11:26 +07:00
|
|
|
try {
|
2026-05-15 19:46:43 +07:00
|
|
|
return new URL(source);
|
2026-05-15 17:11:26 +07:00
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-05-15 19:46:43 +07:00
|
|
|
}
|
2026-05-15 17:11:26 +07:00
|
|
|
|
2026-05-15 19:46:43 +07:00
|
|
|
function looksLikeUrl(source: string): boolean {
|
|
|
|
|
return /^[a-z][a-z\d+.-]*:/i.test(source);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isYouTubeUrl(url: URL): boolean {
|
2026-05-15 19:49:01 +07:00
|
|
|
return [
|
|
|
|
|
"youtube.com",
|
|
|
|
|
"www.youtube.com",
|
|
|
|
|
"m.youtube.com",
|
|
|
|
|
"youtu.be",
|
|
|
|
|
].includes(url.hostname);
|
2026-05-15 19:46:43 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isSpotifyTrackUrl(url: URL): boolean {
|
2026-05-15 19:49:01 +07:00
|
|
|
return (
|
|
|
|
|
url.hostname === "open.spotify.com" && url.pathname.startsWith("/track/")
|
|
|
|
|
);
|
2026-05-15 19:46:43 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveUrlSource(source: string): ResolvedMediaSource | null {
|
|
|
|
|
const url = parseUrl(source);
|
|
|
|
|
if (!url) return null;
|
2026-05-15 17:11:26 +07:00
|
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
source,
|
|
|
|
|
title: titleFromUrl(url),
|
|
|
|
|
kind: "url",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function titleFromUrl(url: URL): string {
|
2026-05-15 17:05:37 +07:00
|
|
|
const filename = decodeURIComponent(url.pathname.split("/").pop() || "");
|
2026-05-15 17:11:26 +07:00
|
|
|
return path.basename(filename) || url.hostname;
|
|
|
|
|
}
|