feat: resolve youtube search and spotify media

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-05-15 19:46:43 +07:00
co-authored by Claude Opus 4.7
parent 95ea0cee75
commit c954cc0406
2 changed files with 147 additions and 36 deletions
+59 -7
View File
@@ -1,16 +1,42 @@
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<ResolvedMediaSource> {
type PlayDlResolver = ReturnType<typeof createPlayDlResolver>;
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<ResolvedMediaSource> {
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;
@@ -23,21 +49,47 @@ export async function resolveMediaSource(
};
}
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 or existing local file",
"Media source must be an HTTP(S) URL, YouTube URL, Spotify track URL, search query, or existing local file",
"UNSUPPORTED_MEDIA_SOURCE",
400,
);
};
}
function resolveUrlSource(source: string): ResolvedMediaSource | null {
let url: URL;
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 {
+68 -9
View File
@@ -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<AppError>,
);
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",
});
});
});