import { existsSync, statSync } from "node:fs"; import path from "node:path"; import { AppError } from "../errors"; import { createPlayDlResolver } from "./playDlResolver"; import type { ResolvedMediaSource } from "./mediaTypes"; import { createYtDlp, type YtDlpClient } from "./ytdlp"; type PlayDlResolver = ReturnType; export interface MediaResolverDependencies { ytdlp?: YtDlpClient; playDlResolver?: PlayDlResolver; } export function createMediaResolver( dependencies: MediaResolverDependencies = {}, ) { const ytdlp = dependencies.ytdlp ?? createYtDlp(); const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver(); return async function resolve(input: string): Promise { const source = input.trim(); if (!source) { throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400); } 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, ); }; } export const resolveMediaSource = createMediaResolver(); function parseUrl(source: string): URL | null { try { return new URL(source); } catch { return null; } } function looksLikeUrl(source: string): boolean { return /^[a-z][a-z\d+.-]*:/i.test(source); } function isYouTubeUrl(url: URL): boolean { return ["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"].includes( url.hostname, ); } function isSpotifyTrackUrl(url: URL): boolean { return url.hostname === "open.spotify.com" && url.pathname.startsWith("/track/"); } function resolveUrlSource(source: string): ResolvedMediaSource | null { const url = parseUrl(source); if (!url) return null; if (url.protocol !== "http:" && url.protocol !== "https:") return null; return { source, title: titleFromUrl(url), kind: "url", }; } function titleFromUrl(url: URL): string { const filename = decodeURIComponent(url.pathname.split("/").pop() || ""); return path.basename(filename) || url.hostname; }