diff --git a/src/media/mediaResolver.ts b/src/media/mediaResolver.ts index 14adec3..ab4b680 100644 --- a/src/media/mediaResolver.ts +++ b/src/media/mediaResolver.ts @@ -1,43 +1,95 @@ 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"; -export async function resolveMediaSource( - input: string, -): Promise { - const source = input.trim(); - if (!source) { - throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400); - } +type PlayDlResolver = ReturnType; - 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", - }; - } - - throw new AppError( - "Media source must be an HTTP(S) URL or existing local file", - "UNSUPPORTED_MEDIA_SOURCE", - 400, - ); +export interface MediaResolverDependencies { + ytdlp?: YtDlpClient; + playDlResolver?: PlayDlResolver; } -function resolveUrlSource(source: string): ResolvedMediaSource | null { - let url: URL; +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 { - url = new URL(source); + 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 { diff --git a/tests/media/mediaResolver.test.ts b/tests/media/mediaResolver.test.ts index bbbed4b..a60aec2 100644 --- a/tests/media/mediaResolver.test.ts +++ b/tests/media/mediaResolver.test.ts @@ -1,9 +1,9 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { AppError } from "../../src/errors"; -import { resolveMediaSource } from "../../src/media/mediaResolver"; +import { createMediaResolver, resolveMediaSource } from "../../src/media/mediaResolver"; describe("resolveMediaSource", () => { it("accepts http URLs", async () => { @@ -44,13 +44,26 @@ describe("resolveMediaSource", () => { }); }); - it("rejects unsupported sources", async () => { - await expect(resolveMediaSource("not a url or file")).rejects.toMatchObject( - { - code: "UNSUPPORTED_MEDIA_SOURCE", - statusCode: 400, - } satisfies Partial, - ); + it("resolves search queries to YouTube results", async () => { + const resolver = createMediaResolver({ + ytdlp: { + getMetadata: vi.fn(), + getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/search"), + }, + playDlResolver: { + searchYouTube: vi.fn(async () => ({ + title: "Search Result", + url: "https://youtube.com/watch?v=search", + })), + resolveSpotifyTrack: vi.fn(), + }, + }); + + await expect(resolver("artist song")).resolves.toEqual({ + source: "https://audio.example.com/search", + title: "Search Result", + kind: "search", + }); }); it("rejects non-http URL sources", async () => { @@ -77,4 +90,50 @@ describe("resolveMediaSource", () => { source: "https://cdn.example.com/song.mp3", }); }); + + it("resolves YouTube URLs with yt-dlp metadata", async () => { + const resolver = createMediaResolver({ + ytdlp: { + getMetadata: vi.fn(async () => ({ + title: "YouTube Song", + webpageUrl: "https://youtube.com/watch?v=abc", + })), + getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/abc"), + }, + playDlResolver: { + searchYouTube: vi.fn(), + resolveSpotifyTrack: vi.fn(), + }, + }); + + await expect(resolver("https://youtu.be/abc")).resolves.toEqual({ + source: "https://audio.example.com/abc", + title: "YouTube Song", + kind: "youtube", + }); + }); + + it("resolves Spotify track URLs through YouTube search", async () => { + const resolver = createMediaResolver({ + ytdlp: { + getMetadata: vi.fn(), + getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/spotify"), + }, + playDlResolver: { + searchYouTube: vi.fn(), + resolveSpotifyTrack: vi.fn(async () => ({ + title: "Spotify Match", + url: "https://youtube.com/watch?v=spotify", + })), + }, + }); + + await expect( + resolver("https://open.spotify.com/track/123"), + ).resolves.toEqual({ + source: "https://audio.example.com/spotify", + title: "Spotify Match", + kind: "spotify", + }); + }); });