From 2e30a063d2b2c27098a2642479be5a8f4612ce1c Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Fri, 15 May 2026 19:43:06 +0700 Subject: [PATCH] feat: add yt-dlp media helper Co-Authored-By: Claude Opus 4.7 --- src/media/ytdlp.ts | 78 +++++++++++++++++++++++++++++++++++++++ tests/media/ytdlp.test.ts | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 src/media/ytdlp.ts create mode 100644 tests/media/ytdlp.test.ts diff --git a/src/media/ytdlp.ts b/src/media/ytdlp.ts new file mode 100644 index 0000000..e7a940b --- /dev/null +++ b/src/media/ytdlp.ts @@ -0,0 +1,78 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawn as nodeSpawn } from "node:child_process"; + +export interface YtDlpMetadata { + title: string; + webpageUrl: string; +} + +export interface YtDlpClient { + getMetadata(url: string): Promise; + getDirectAudioUrl(url: string): Promise; +} + +export interface YtDlpDependencies { + spawn?: typeof nodeSpawn; +} + +export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient { + const spawn = dependencies.spawn ?? nodeSpawn; + + return { + async getMetadata(url: string): Promise { + const data = await runYtDlp(spawn, [ + url, + "--dump-single-json", + "--no-playlist", + "--no-warnings", + "--quiet", + ]); + const parsed = JSON.parse(data) as { title?: string; webpage_url?: string }; + return { + title: parsed.title || url, + webpageUrl: parsed.webpage_url || url, + }; + }, + + async getDirectAudioUrl(url: string): Promise { + const value = await runYtDlp(spawn, [ + url, + "--get-url", + "--format", + "bestaudio[protocol^=http]/bestaudio/best", + "--no-playlist", + "--no-warnings", + "--quiet", + ]); + return value.trim().split("\n")[0] || url; + }, + }; +} + +async function runYtDlp( + spawn: typeof nodeSpawn, + args: string[], +): Promise { + return new Promise((resolve, reject) => { + const proc = spawn("yt-dlp", args, { + stdio: ["ignore", "pipe", "pipe"], + }) as unknown as ChildProcessWithoutNullStreams; + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + proc.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + proc.on("error", reject); + proc.on("close", (code) => { + if (code === 0) { + resolve(stdout); + return; + } + reject(new Error(`yt-dlp failed with code ${code}: ${stderr.trim()}`)); + }); + }); +} diff --git a/tests/media/ytdlp.test.ts b/tests/media/ytdlp.test.ts new file mode 100644 index 0000000..19912a3 --- /dev/null +++ b/tests/media/ytdlp.test.ts @@ -0,0 +1,67 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { createYtDlp } from "../../src/media/ytdlp"; + +class FakeProcess extends EventEmitter { + stdout = new PassThrough(); + stderr = new PassThrough(); +} + +describe("createYtDlp", () => { + it("reads YouTube metadata as JSON", async () => { + const proc = new FakeProcess(); + const spawn = vi.fn(() => proc); + const ytdlp = createYtDlp({ spawn }); + + const result = ytdlp.getMetadata("https://youtu.be/video"); + proc.stdout.write( + JSON.stringify({ + title: "Song Title", + webpage_url: "https://youtube.com/watch?v=video", + }), + ); + proc.stdout.end(); + proc.emit("close", 0); + + await expect(result).resolves.toEqual({ + title: "Song Title", + webpageUrl: "https://youtube.com/watch?v=video", + }); + expect(spawn).toHaveBeenCalledWith( + "yt-dlp", + [ + "https://youtu.be/video", + "--dump-single-json", + "--no-playlist", + "--no-warnings", + "--quiet", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + }); + + it("reads direct audio URL", async () => { + const proc = new FakeProcess(); + const ytdlp = createYtDlp({ spawn: vi.fn(() => proc) }); + + const result = ytdlp.getDirectAudioUrl("https://youtu.be/video"); + proc.stdout.write("https://audio.example.com/stream\n"); + proc.stdout.end(); + proc.emit("close", 0); + + await expect(result).resolves.toBe("https://audio.example.com/stream"); + }); + + it("rejects when yt-dlp exits non-zero", async () => { + const proc = new FakeProcess(); + const ytdlp = createYtDlp({ spawn: vi.fn(() => proc) }); + + const result = ytdlp.getMetadata("https://youtu.be/video"); + proc.stderr.write("failed"); + proc.stderr.end(); + proc.emit("close", 1); + + await expect(result).rejects.toThrow("yt-dlp failed with code 1"); + }); +});