feat: add yt-dlp media helper
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
78
src/media/ytdlp.ts
Normal file
78
src/media/ytdlp.ts
Normal file
@@ -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()}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
67
tests/media/ytdlp.test.ts
Normal file
67
tests/media/ytdlp.test.ts
Normal file
@@ -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