Compare commits

...

5 Commits

Author SHA1 Message Date
MythEclipse
5abe5cc39f feat: update media input guidance
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:49:01 +07:00
MythEclipse
c954cc0406 feat: resolve youtube search and spotify media
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:46:43 +07:00
MythEclipse
95ea0cee75 feat: add play-dl search resolver
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:44:42 +07:00
MythEclipse
2e30a063d2 feat: add yt-dlp media helper
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:43:06 +07:00
MythEclipse
6aeabc690f feat: prepare media resolver source kinds
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:11:23 +07:00
12 changed files with 1173 additions and 40 deletions

View File

@@ -0,0 +1,713 @@
# Media YouTube and Spotify Resolver Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extend media playback input so users can queue YouTube URLs, plain search queries, and Spotify track URLs that resolve to playable YouTube audio.
**Architecture:** Keep playback unchanged: `musicPlayer` still passes one resolved source to ffmpeg. Add resolver units that turn rich inputs into direct playable URLs before queueing: `play-dl` for YouTube search and Spotify metadata, `yt-dlp` wrapper for YouTube metadata/direct URL extraction when available. Spotify track support resolves metadata then searches YouTube; no Spotify playlist/album support in this phase.
**Tech Stack:** TypeScript, Vitest, Node `child_process`, `play-dl`, external `yt-dlp` command when installed, existing Express/media controller/music player.
---
## File Structure
- Modify `package.json` and `pnpm-lock.yaml` — add `play-dl` dependency.
- Modify `src/media/mediaTypes.ts` — extend `MediaSourceKind` with `youtube`, `spotify`, and `search`.
- Create `src/media/ytdlp.ts` — small wrapper around external `yt-dlp` for JSON metadata and direct audio URL extraction.
- Create `src/media/playDlResolver.ts` — wrapper around `play-dl` for YouTube search and Spotify track metadata.
- Modify `src/media/mediaResolver.ts` — compose local/direct URL/YouTube/search/Spotify resolution.
- Modify `public/index.html` — update input label/placeholder to mention YouTube, Spotify track, and search.
- Tests:
- `tests/media/ytdlp.test.ts`
- `tests/media/playDlResolver.test.ts`
- `tests/media/mediaResolver.test.ts`
---
### Task 1: Add play-dl and Media Source Kinds
**Files:**
- Modify: `package.json`
- Modify: `pnpm-lock.yaml`
- Modify: `src/media/mediaTypes.ts`
- Test: `tests/media/mediaResolver.test.ts`
- [ ] **Step 1: Write failing type expectation in resolver test**
Append to `tests/media/mediaResolver.test.ts`:
```ts
it("keeps direct URLs as generic URL sources", async () => {
await expect(
resolveMediaSource("https://cdn.example.com/song.mp3"),
).resolves.toMatchObject({
kind: "url",
source: "https://cdn.example.com/song.mp3",
});
});
```
This test should already pass before type changes; it protects existing behavior.
- [ ] **Step 2: Install play-dl**
Run:
```bash
pnpm -C /mnt/code/bete add play-dl
```
Expected: `package.json` contains `"play-dl"` in dependencies and `pnpm-lock.yaml` updates.
- [ ] **Step 3: Extend media source kinds**
Modify `src/media/mediaTypes.ts`:
```ts
export type MediaSourceKind = "url" | "local" | "youtube" | "spotify" | "search";
```
- [ ] **Step 4: Run protected resolver test and typecheck**
Run:
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 1**
```bash
git -C /mnt/code/bete add package.json pnpm-lock.yaml src/media/mediaTypes.ts tests/media/mediaResolver.test.ts
git -C /mnt/code/bete commit -m "feat: prepare media resolver source kinds"
```
---
### Task 2: yt-dlp Wrapper
**Files:**
- Create: `src/media/ytdlp.ts`
- Test: `tests/media/ytdlp.test.ts`
- [ ] **Step 1: Write failing yt-dlp tests**
Create `tests/media/ytdlp.test.ts`:
```ts
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");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/ytdlp.test.ts
```
Expected: FAIL because `src/media/ytdlp.ts` does not exist.
- [ ] **Step 3: Implement yt-dlp wrapper**
Create `src/media/ytdlp.ts`:
```ts
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> {
return runYtDlp(spawn, [
url,
"--get-url",
"--format",
"bestaudio[protocol^=http]/bestaudio/best",
"--no-playlist",
"--no-warnings",
"--quiet",
]).then((value) => 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()}`));
});
});
}
```
- [ ] **Step 4: Run yt-dlp tests and typecheck**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/ytdlp.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 2**
```bash
git -C /mnt/code/bete add src/media/ytdlp.ts tests/media/ytdlp.test.ts
git -C /mnt/code/bete commit -m "feat: add yt-dlp media helper"
```
---
### Task 3: play-dl Resolver Wrapper
**Files:**
- Create: `src/media/playDlResolver.ts`
- Test: `tests/media/playDlResolver.test.ts`
- [ ] **Step 1: Write failing play-dl resolver tests**
Create `tests/media/playDlResolver.test.ts`:
```ts
import { describe, expect, it, vi } from "vitest";
import { createPlayDlResolver } from "../../src/media/playDlResolver";
describe("createPlayDlResolver", () => {
it("returns the first YouTube search result", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(async () => [
{ title: "Song Result", url: "https://youtube.com/watch?v=abc" },
]),
spotify: vi.fn(),
});
await expect(resolver.searchYouTube("artist song")).resolves.toEqual({
title: "Song Result",
url: "https://youtube.com/watch?v=abc",
});
});
it("turns Spotify track metadata into a YouTube search query", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(async () => [
{ title: "Artist - Track", url: "https://youtube.com/watch?v=track" },
]),
spotify: vi.fn(async () => ({
type: "track",
name: "Track",
artists: [{ name: "Artist" }],
})),
});
await expect(
resolver.resolveSpotifyTrack("https://open.spotify.com/track/123"),
).resolves.toEqual({
title: "Artist - Track",
url: "https://youtube.com/watch?v=track",
});
});
it("rejects Spotify playlists in this phase", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(),
spotify: vi.fn(async () => ({ type: "playlist", name: "Playlist" })),
});
await expect(
resolver.resolveSpotifyTrack("https://open.spotify.com/playlist/123"),
).rejects.toThrow("Only Spotify track URLs are supported");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/playDlResolver.test.ts
```
Expected: FAIL because `src/media/playDlResolver.ts` does not exist.
- [ ] **Step 3: Implement play-dl wrapper**
Create `src/media/playDlResolver.ts`:
```ts
import play from "play-dl";
export interface PlayDlResult {
title: string;
url: string;
}
interface PlayDlSearchResult {
title?: string;
url?: string;
}
interface SpotifyTrackLike {
type?: string;
name?: string;
artists?: Array<{ name?: string }>;
}
export interface PlayDlDependencies {
search?: (query: string, options: { limit: number }) => Promise<PlayDlSearchResult[]>;
spotify?: (url: string) => Promise<SpotifyTrackLike>;
}
export function createPlayDlResolver(dependencies: PlayDlDependencies = {}) {
const search = dependencies.search ?? play.search;
const spotify = dependencies.spotify ?? play.spotify;
return {
async searchYouTube(query: string): Promise<PlayDlResult> {
const results = await search(query, { limit: 1 });
const first = results[0];
if (!first?.url) throw new Error(`No YouTube result found for ${query}`);
return {
title: first.title || query,
url: first.url,
};
},
async resolveSpotifyTrack(url: string): Promise<PlayDlResult> {
const track = await spotify(url);
if (track.type !== "track") {
throw new Error("Only Spotify track URLs are supported");
}
const artists = (track.artists || [])
.map((artist) => artist.name)
.filter(Boolean)
.join(" ");
const query = `${artists} ${track.name || ""} audio`.trim();
return this.searchYouTube(query);
},
};
}
```
- [ ] **Step 4: Run play-dl tests and typecheck**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/playDlResolver.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 3**
```bash
git -C /mnt/code/bete add src/media/playDlResolver.ts tests/media/playDlResolver.test.ts
git -C /mnt/code/bete commit -m "feat: add play-dl search resolver"
```
---
### Task 4: Compose Resolver for YouTube, Search, and Spotify Track
**Files:**
- Modify: `src/media/mediaResolver.ts`
- Test: `tests/media/mediaResolver.test.ts`
- [ ] **Step 1: Write failing composed resolver tests**
Append to `tests/media/mediaResolver.test.ts`:
```ts
import { createMediaResolver } from "../../src/media/mediaResolver";
// Add inside describe("resolveMediaSource", ...):
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 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("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",
});
});
```
Also update imports at the top:
```ts
import { describe, expect, it, vi } from "vitest";
import { createMediaResolver, resolveMediaSource } from "../../src/media/mediaResolver";
```
- [ ] **Step 2: Run test to verify it fails**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
```
Expected: FAIL because `createMediaResolver` does not exist.
- [ ] **Step 3: Implement composed resolver**
Modify `src/media/mediaResolver.ts` to export `createMediaResolver()` and keep `resolveMediaSource` as the default instance:
```ts
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";
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;
const localPath = path.resolve(source);
if (existsSync(localPath) && statSync(localPath).isFile()) {
return {
source: localPath,
title: path.basename(localPath),
kind: "local",
};
}
if (!url) {
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, YouTube URL, Spotify track URL, search query, or existing local file",
"UNSUPPORTED_MEDIA_SOURCE",
400,
);
};
}
export const resolveMediaSource = createMediaResolver();
function parseUrl(source: string): URL | null {
try {
return new URL(source);
} catch {
return null;
}
}
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 {
source,
title: titleFromUrl(url),
kind: "url",
};
}
function titleFromUrl(url: URL): string {
const filename = decodeURIComponent(url.pathname.split("/").pop() || "");
return path.basename(filename) || url.hostname;
}
```
- [ ] **Step 4: Run resolver tests and typecheck**
```bash
pnpm -C /mnt/code/bete exec vitest run tests/media/mediaResolver.test.ts
pnpm -C /mnt/code/bete run typecheck
```
Expected: PASS.
- [ ] **Step 5: Commit task 4**
```bash
git -C /mnt/code/bete add src/media/mediaResolver.ts tests/media/mediaResolver.test.ts
git -C /mnt/code/bete commit -m "feat: resolve youtube search and spotify media"
```
---
### Task 5: Dashboard Copy and Full Verification
**Files:**
- Modify: `public/index.html`
- [ ] **Step 1: Update media input copy**
Change the media input label and placeholder in `public/index.html` from:
```html
<label for="mediaSourceInput">Music URL / file path</label>
<input id="mediaSourceInput" type="text" placeholder="https://example.com/song.mp3">
```
to:
```html
<label for="mediaSourceInput">Music URL, YouTube, Spotify track, search, or file path</label>
<input id="mediaSourceInput" type="text" placeholder="YouTube URL, Spotify track, or search terms">
```
- [ ] **Step 2: Run full verification**
```bash
pnpm -C /mnt/code/bete run test
pnpm -C /mnt/code/bete run typecheck
pnpm -C /mnt/code/bete run lint
```
Expected: PASS.
- [ ] **Step 3: Manual verification**
Run:
```bash
pnpm -C /mnt/code/bete run dev
```
Manual checks:
1. Queue a direct MP3 URL: still plays.
2. Queue a local file path: still plays.
3. Queue a YouTube URL: resolves title and plays audio.
4. Queue plain search terms: resolves first YouTube result and plays audio.
5. Queue a Spotify track URL: resolves Spotify metadata, searches YouTube, and plays audio.
6. Queue a Spotify playlist URL: returns a clear unsupported error.
- [ ] **Step 4: Commit task 5**
```bash
git -C /mnt/code/bete add public/index.html
git -C /mnt/code/bete commit -m "feat: update media input guidance"
```
---
## Self-Review
Spec coverage:
- YouTube URL support: Task 2 + Task 4.
- Search query support: Task 3 + Task 4.
- Spotify track URL to YouTube support: Task 3 + Task 4.
- No Spotify playlist/album support: Task 3 explicitly rejects non-track Spotify types, Task 5 manual check covers playlist error.
- Dashboard copy: Task 5.
- Existing direct URL/local file behavior protected: Task 1 + existing tests.
Placeholder scan: no placeholders, TODOs, or vague test instructions remain.
Type consistency: `MediaSourceKind` includes `youtube`, `spotify`, and `search`; resolver returns those exact values; tests assert those values.

View File

@@ -21,9 +21,9 @@
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@dank074/discord-video-stream": "workspace:*",
"@discordjs/opus": "^0.10.0", "@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.1", "@discordjs/voice": "^0.19.1",
"@dank074/discord-video-stream": "workspace:*",
"@snazzah/davey": "^0.1.10", "@snazzah/davey": "^0.1.10",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
@@ -38,6 +38,7 @@
"pg": "^8.20.0", "pg": "^8.20.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"play-dl": "^1.9.7",
"prism-media": "2.0.0-alpha.0", "prism-media": "2.0.0-alpha.0",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"react": "^19.2.6", "react": "^19.2.6",

16
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
pino-http: pino-http:
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.0.0 version: 11.0.0
play-dl:
specifier: ^1.9.7
version: 1.9.7
prism-media: prism-media:
specifier: 2.0.0-alpha.0 specifier: 2.0.0-alpha.0
version: 2.0.0-alpha.0 version: 2.0.0-alpha.0
@@ -3364,6 +3367,13 @@ packages:
pkg-types@1.3.1: pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
play-audio@0.5.2:
resolution: {integrity: sha512-ZAqHUKkQLix2Iga7pPbsf1LpUoBjcpwU93F1l3qBIfxYddQLhxS6GKmS0d3jV8kSVaUbr6NnOEcEMFvuX93SWQ==}
play-dl@1.9.7:
resolution: {integrity: sha512-KpgerWxUCY4s9Mhze2qdqPhiqd8Ve6HufpH9mBH3FN+vux55qSh6WJKDabfie8IBHN7lnrAlYcT/UdGax58c2A==}
engines: {node: '>=16.0.0'}
plur@4.0.0: plur@4.0.0:
resolution: {integrity: sha512-4UGewrYgqDFw9vV6zNV+ADmPAUAfJPKtGvb/VdpQAx25X5f3xXdGdyOEVFwkl8Hl/tl7+xbeHqSEM+D5/TirUg==} resolution: {integrity: sha512-4UGewrYgqDFw9vV6zNV+ADmPAUAfJPKtGvb/VdpQAx25X5f3xXdGdyOEVFwkl8Hl/tl7+xbeHqSEM+D5/TirUg==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -7293,6 +7303,12 @@ snapshots:
mlly: 1.8.2 mlly: 1.8.2
pathe: 2.0.3 pathe: 2.0.3
play-audio@0.5.2: {}
play-dl@1.9.7:
dependencies:
play-audio: 0.5.2
plur@4.0.0: plur@4.0.0:
dependencies: dependencies:
irregular-plurals: 3.5.0 irregular-plurals: 3.5.0

View File

@@ -45,7 +45,7 @@
</div> </div>
<div class="content-card"> <div class="content-card">
<div class="card-title"><h2>Media</h2><span class="mini" id="mediaStatus">Idle</span></div> <div class="card-title"><h2>Media</h2><span class="mini" id="mediaStatus">Idle</span></div>
<div class="field-group"><label for="mediaSourceInput">Music URL / file path</label><input id="mediaSourceInput" type="text" placeholder="https://example.com/song.mp3"></div> <div class="field-group"><label for="mediaSourceInput">Music URL, YouTube, Spotify track, search, or file path</label><input id="mediaSourceInput" type="text" placeholder="YouTube URL, Spotify track, or search terms"></div>
<div class="button-row"><button id="queueMediaBtn" class="btn btn-primary">Queue / Play</button><button id="skipMediaBtn" class="btn btn-success">Skip</button><button id="stopMediaBtn" class="btn btn-danger">Stop</button></div> <div class="button-row"><button id="queueMediaBtn" class="btn btn-primary">Queue / Play</button><button id="skipMediaBtn" class="btn btn-success">Skip</button><button id="stopMediaBtn" class="btn btn-danger">Stop</button></div>
<div id="mediaQueueList" class="feed"><div class="empty">No media queued</div></div> <div id="mediaQueueList" class="feed"><div class="empty">No media queued</div></div>
</div> </div>

View File

@@ -2,42 +2,103 @@ 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 type { ResolvedMediaSource } from "./mediaTypes"; import type { ResolvedMediaSource } from "./mediaTypes";
import { createPlayDlResolver } from "./playDlResolver";
import { createYtDlp, type YtDlpClient } from "./ytdlp";
export async function resolveMediaSource( type PlayDlResolver = ReturnType<typeof createPlayDlResolver>;
input: string,
): Promise<ResolvedMediaSource> { export interface MediaResolverDependencies {
const source = input.trim(); ytdlp?: YtDlpClient;
if (!source) { playDlResolver?: PlayDlResolver;
throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400); }
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;
const localPath = path.resolve(source);
if (existsSync(localPath) && statSync(localPath).isFile()) {
return {
source: localPath,
title: path.basename(localPath),
kind: "local",
};
}
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, YouTube URL, Spotify track URL, search query, or existing local file",
"UNSUPPORTED_MEDIA_SOURCE",
400,
);
};
}
export const resolveMediaSource = createMediaResolver();
function parseUrl(source: string): URL | null {
try {
return new URL(source);
} catch {
return null;
} }
}
const urlSource = resolveUrlSource(source); function looksLikeUrl(source: string): boolean {
if (urlSource) return urlSource; return /^[a-z][a-z\d+.-]*:/i.test(source);
}
const localPath = path.resolve(source); function isYouTubeUrl(url: URL): boolean {
if (existsSync(localPath) && statSync(localPath).isFile()) { return [
return { "youtube.com",
source: localPath, "www.youtube.com",
title: path.basename(localPath), "m.youtube.com",
kind: "local", "youtu.be",
}; ].includes(url.hostname);
} }
throw new AppError( function isSpotifyTrackUrl(url: URL): boolean {
"Media source must be an HTTP(S) URL or existing local file", return (
"UNSUPPORTED_MEDIA_SOURCE", url.hostname === "open.spotify.com" && url.pathname.startsWith("/track/")
400,
); );
} }
function resolveUrlSource(source: string): ResolvedMediaSource | null { function resolveUrlSource(source: string): ResolvedMediaSource | null {
let url: URL; const url = parseUrl(source);
try { if (!url) return null;
url = new URL(source);
} catch {
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,7 +1,12 @@
import type { Readable } from "node:stream"; import type { Readable } from "node:stream";
export type MediaMode = "music" | "screen"; export type MediaMode = "music" | "screen";
export type MediaSourceKind = "url" | "local"; export type MediaSourceKind =
| "url"
| "local"
| "youtube"
| "spotify"
| "search";
export type MediaQueueItemStatus = "queued" | "playing" | "failed"; export type MediaQueueItemStatus = "queued" | "playing" | "failed";
export interface ResolvedMediaSource { export interface ResolvedMediaSource {

View File

@@ -0,0 +1,60 @@
import play from "play-dl";
export interface PlayDlResult {
title: string;
url: string;
}
interface PlayDlSearchResult {
title?: string;
url?: string;
}
interface SpotifyTrackLike {
type?: string;
name?: string;
artists?: Array<{ name?: string }>;
}
type SearchFunction = (
query: string,
options: { limit: number },
) => Promise<PlayDlSearchResult[]>;
type SpotifyFunction = (url: string) => Promise<SpotifyTrackLike>;
export interface PlayDlDependencies {
search?: SearchFunction;
spotify?: SpotifyFunction;
}
export function createPlayDlResolver(dependencies: PlayDlDependencies = {}) {
const search: SearchFunction = dependencies.search ?? play.search;
const spotify: SpotifyFunction =
dependencies.spotify ?? (play.spotify as SpotifyFunction);
return {
async searchYouTube(query: string): Promise<PlayDlResult> {
const results = await search(query, { limit: 1 });
const first = results[0];
if (!first?.url) throw new Error(`No YouTube result found for ${query}`);
return {
title: first.title || query,
url: first.url,
};
},
async resolveSpotifyTrack(url: string): Promise<PlayDlResult> {
const track = await spotify(url);
if (track.type !== "track") {
throw new Error("Only Spotify track URLs are supported");
}
const artists = (track.artists || [])
.map((artist) => artist.name)
.filter(Boolean)
.join(" ");
const query = `${artists} ${track.name || ""} audio`.trim();
return this.searchYouTube(query);
},
};
}

81
src/media/ytdlp.ts Normal file
View File

@@ -0,0 +1,81 @@
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()}`));
});
});
}

View File

@@ -93,7 +93,12 @@ export interface AnalysisResult {
} }
export type MediaMode = "music" | "screen"; export type MediaMode = "music" | "screen";
export type MediaSourceKind = "url" | "local"; export type MediaSourceKind =
| "url"
| "local"
| "youtube"
| "spotify"
| "search";
export type MediaQueueItemStatus = "queued" | "playing" | "failed"; export type MediaQueueItemStatus = "queued" | "playing" | "failed";
export interface MediaQueueItem { export interface MediaQueueItem {

View File

@@ -1,9 +1,12 @@
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 +47,28 @@ 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(
} satisfies Partial<AppError>, 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 () => { it("rejects non-http URL sources", async () => {
@@ -68,4 +86,61 @@ describe("resolveMediaSource", () => {
statusCode: 400, statusCode: 400,
} satisfies Partial<AppError>); } satisfies Partial<AppError>);
}); });
it("keeps direct URLs as generic URL sources", async () => {
await expect(
resolveMediaSource("https://cdn.example.com/song.mp3"),
).resolves.toMatchObject({
kind: "url",
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",
});
});
}); });

View File

@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
import { createPlayDlResolver } from "../../src/media/playDlResolver";
describe("createPlayDlResolver", () => {
it("returns the first YouTube search result", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(async () => [
{ title: "Song Result", url: "https://youtube.com/watch?v=abc" },
]),
spotify: vi.fn(),
});
await expect(resolver.searchYouTube("artist song")).resolves.toEqual({
title: "Song Result",
url: "https://youtube.com/watch?v=abc",
});
});
it("turns Spotify track metadata into a YouTube search query", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(async () => [
{ title: "Artist - Track", url: "https://youtube.com/watch?v=track" },
]),
spotify: vi.fn(async () => ({
type: "track",
name: "Track",
artists: [{ name: "Artist" }],
})),
});
await expect(
resolver.resolveSpotifyTrack("https://open.spotify.com/track/123"),
).resolves.toEqual({
title: "Artist - Track",
url: "https://youtube.com/watch?v=track",
});
});
it("rejects Spotify playlists in this phase", async () => {
const resolver = createPlayDlResolver({
search: vi.fn(),
spotify: vi.fn(async () => ({ type: "playlist", name: "Playlist" })),
});
await expect(
resolver.resolveSpotifyTrack("https://open.spotify.com/playlist/123"),
).rejects.toThrow("Only Spotify track URLs are supported");
});
});

67
tests/media/ytdlp.test.ts Normal file
View 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");
});
});