From 95ea0cee7543876bc9682f05f0029e3a97eafcb9 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Fri, 15 May 2026 19:44:42 +0700 Subject: [PATCH] feat: add play-dl search resolver Co-Authored-By: Claude Opus 4.7 --- src/media/playDlResolver.ts | 59 ++++++++++++++++++++++++++++++ tests/media/playDlResolver.test.ts | 49 +++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 src/media/playDlResolver.ts create mode 100644 tests/media/playDlResolver.test.ts diff --git a/src/media/playDlResolver.ts b/src/media/playDlResolver.ts new file mode 100644 index 0000000..6ce43c2 --- /dev/null +++ b/src/media/playDlResolver.ts @@ -0,0 +1,59 @@ +import play from "play-dl"; + +export interface PlayDlResult { + title: string; + url: string; +} + +interface PlayDlSearchResult { + title?: string; + url?: string; +} + +interface SpotifyTrackLike { + type?: string; + name?: string; + artists?: Array<{ name?: string }>; +} + +type SearchFunction = ( + query: string, + options: { limit: number }, +) => Promise; + +type SpotifyFunction = (url: string) => Promise; + +export interface PlayDlDependencies { + search?: SearchFunction; + spotify?: SpotifyFunction; +} + +export function createPlayDlResolver(dependencies: PlayDlDependencies = {}) { + const search: SearchFunction = dependencies.search ?? play.search; + const spotify: SpotifyFunction = dependencies.spotify ?? (play.spotify as SpotifyFunction); + + return { + async searchYouTube(query: string): Promise { + const results = await search(query, { limit: 1 }); + const first = results[0]; + if (!first?.url) throw new Error(`No YouTube result found for ${query}`); + return { + title: first.title || query, + url: first.url, + }; + }, + + async resolveSpotifyTrack(url: string): Promise { + const track = await spotify(url); + if (track.type !== "track") { + throw new Error("Only Spotify track URLs are supported"); + } + const artists = (track.artists || []) + .map((artist) => artist.name) + .filter(Boolean) + .join(" "); + const query = `${artists} ${track.name || ""} audio`.trim(); + return this.searchYouTube(query); + }, + }; +} diff --git a/tests/media/playDlResolver.test.ts b/tests/media/playDlResolver.test.ts new file mode 100644 index 0000000..41b9c4f --- /dev/null +++ b/tests/media/playDlResolver.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPlayDlResolver } from "../../src/media/playDlResolver"; + +describe("createPlayDlResolver", () => { + it("returns the first YouTube search result", async () => { + const resolver = createPlayDlResolver({ + search: vi.fn(async () => [ + { title: "Song Result", url: "https://youtube.com/watch?v=abc" }, + ]), + spotify: vi.fn(), + }); + + await expect(resolver.searchYouTube("artist song")).resolves.toEqual({ + title: "Song Result", + url: "https://youtube.com/watch?v=abc", + }); + }); + + it("turns Spotify track metadata into a YouTube search query", async () => { + const resolver = createPlayDlResolver({ + search: vi.fn(async () => [ + { title: "Artist - Track", url: "https://youtube.com/watch?v=track" }, + ]), + spotify: vi.fn(async () => ({ + type: "track", + name: "Track", + artists: [{ name: "Artist" }], + })), + }); + + await expect( + resolver.resolveSpotifyTrack("https://open.spotify.com/track/123"), + ).resolves.toEqual({ + title: "Artist - Track", + url: "https://youtube.com/watch?v=track", + }); + }); + + it("rejects Spotify playlists in this phase", async () => { + const resolver = createPlayDlResolver({ + search: vi.fn(), + spotify: vi.fn(async () => ({ type: "playlist", name: "Playlist" })), + }); + + await expect( + resolver.resolveSpotifyTrack("https://open.spotify.com/playlist/123"), + ).rejects.toThrow("Only Spotify track URLs are supported"); + }); +});