feat: add yt-dlp media helper
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6aeabc690f
commit
2e30a063d2
@@ -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<YtDlpMetadata>;
|
||||
getDirectAudioUrl(url: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface YtDlpDependencies {
|
||||
spawn?: typeof nodeSpawn;
|
||||
}
|
||||
|
||||
export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
|
||||
const spawn = dependencies.spawn ?? nodeSpawn;
|
||||
|
||||
return {
|
||||
async getMetadata(url: string): Promise<YtDlpMetadata> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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()}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user