feat: add play-dl search resolver

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-05-15 19:44:42 +07:00
parent 2e30a063d2
commit 95ea0cee75
2 changed files with 108 additions and 0 deletions

View File

@@ -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<PlayDlSearchResult[]>;
type SpotifyFunction = (url: string) => Promise<SpotifyTrackLike>;
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<PlayDlResult> {
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<PlayDlResult> {
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);
},
};
}

View File

@@ -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");
});
});