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
parent 95ea0cee75
commit c954cc0406
2 changed files with 147 additions and 36 deletions

View File

@@ -1,16 +1,42 @@
import { existsSync, statSync } from "node:fs"; import { existsSync, statSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { AppError } from "../errors"; import { AppError } from "../errors";
import { createPlayDlResolver } from "./playDlResolver";
import type { ResolvedMediaSource } from "./mediaTypes"; import type { ResolvedMediaSource } from "./mediaTypes";
import { createYtDlp, type YtDlpClient } from "./ytdlp";
export async function resolveMediaSource( type PlayDlResolver = ReturnType<typeof createPlayDlResolver>;
input: string,
): Promise<ResolvedMediaSource> { 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(); const source = input.trim();
if (!source) { if (!source) {
throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400); 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); const urlSource = resolveUrlSource(source);
if (urlSource) return urlSource; 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( 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", "UNSUPPORTED_MEDIA_SOURCE",
400, 400,
); );
};
} }
function resolveUrlSource(source: string): ResolvedMediaSource | null { export const resolveMediaSource = createMediaResolver();
let url: URL;
function parseUrl(source: string): URL | null {
try { try {
url = new URL(source); return new URL(source);
} catch { } catch {
return null; 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; if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return { return {

View File

@@ -1,9 +1,9 @@
import { mkdtempSync, writeFileSync } from "node:fs"; import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { AppError } from "../../src/errors"; import { AppError } from "../../src/errors";
import { resolveMediaSource } from "../../src/media/mediaResolver"; import { createMediaResolver, resolveMediaSource } from "../../src/media/mediaResolver";
describe("resolveMediaSource", () => { describe("resolveMediaSource", () => {
it("accepts http URLs", async () => { it("accepts http URLs", async () => {
@@ -44,13 +44,26 @@ describe("resolveMediaSource", () => {
}); });
}); });
it("rejects unsupported sources", async () => { it("resolves search queries to YouTube results", async () => {
await expect(resolveMediaSource("not a url or file")).rejects.toMatchObject( const resolver = createMediaResolver({
{ ytdlp: {
code: "UNSUPPORTED_MEDIA_SOURCE", getMetadata: vi.fn(),
statusCode: 400, getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/search"),
} satisfies Partial<AppError>, },
); 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 () => { it("rejects non-http URL sources", async () => {
@@ -77,4 +90,50 @@ describe("resolveMediaSource", () => {
source: "https://cdn.example.com/song.mp3", 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",
});
});
}); });