diff --git a/docs/superpowers/plans/2026-05-15-media-youtube-spotify-resolver.md b/docs/superpowers/plans/2026-05-15-media-youtube-spotify-resolver.md new file mode 100644 index 0000000..54a0f32 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-media-youtube-spotify-resolver.md @@ -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; + 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 { + 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 { + 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; + spotify?: (url: string) => Promise; +} + +export function createPlayDlResolver(dependencies: PlayDlDependencies = {}) { + const search = dependencies.search ?? play.search; + const spotify = dependencies.spotify ?? play.spotify; + + return { + async searchYouTube(query: string): Promise { + 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 { + 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; + +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 { + 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 + + +``` + +to: + +```html + + +``` + +- [ ] **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. diff --git a/public/index.html b/public/index.html index 938a42b..bdf56a4 100644 --- a/public/index.html +++ b/public/index.html @@ -45,7 +45,7 @@

Media

Idle
-
+
No media queued
diff --git a/src/media/mediaResolver.ts b/src/media/mediaResolver.ts index ab4b680..eec68ff 100644 --- a/src/media/mediaResolver.ts +++ b/src/media/mediaResolver.ts @@ -1,8 +1,8 @@ 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 { createPlayDlResolver } from "./playDlResolver"; import { createYtDlp, type YtDlpClient } from "./ytdlp"; type PlayDlResolver = ReturnType; @@ -21,7 +21,11 @@ export function createMediaResolver( return async function resolve(input: string): Promise { const source = input.trim(); 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); @@ -78,13 +82,18 @@ function looksLikeUrl(source: string): boolean { } function isYouTubeUrl(url: URL): boolean { - return ["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"].includes( - url.hostname, - ); + 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/"); + return ( + url.hostname === "open.spotify.com" && url.pathname.startsWith("/track/") + ); } function resolveUrlSource(source: string): ResolvedMediaSource | null { diff --git a/src/media/mediaTypes.ts b/src/media/mediaTypes.ts index eb6464e..a214d8e 100644 --- a/src/media/mediaTypes.ts +++ b/src/media/mediaTypes.ts @@ -1,7 +1,12 @@ import type { Readable } from "node:stream"; export type MediaMode = "music" | "screen"; -export type MediaSourceKind = "url" | "local" | "youtube" | "spotify" | "search"; +export type MediaSourceKind = + | "url" + | "local" + | "youtube" + | "spotify" + | "search"; export type MediaQueueItemStatus = "queued" | "playing" | "failed"; export interface ResolvedMediaSource { diff --git a/src/media/playDlResolver.ts b/src/media/playDlResolver.ts index 6ce43c2..719f938 100644 --- a/src/media/playDlResolver.ts +++ b/src/media/playDlResolver.ts @@ -30,7 +30,8 @@ export interface PlayDlDependencies { export function createPlayDlResolver(dependencies: PlayDlDependencies = {}) { const search: SearchFunction = dependencies.search ?? play.search; - const spotify: SpotifyFunction = dependencies.spotify ?? (play.spotify as SpotifyFunction); + const spotify: SpotifyFunction = + dependencies.spotify ?? (play.spotify as SpotifyFunction); return { async searchYouTube(query: string): Promise { diff --git a/src/media/ytdlp.ts b/src/media/ytdlp.ts index e7a940b..a2005ee 100644 --- a/src/media/ytdlp.ts +++ b/src/media/ytdlp.ts @@ -27,7 +27,10 @@ export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient { "--no-warnings", "--quiet", ]); - const parsed = JSON.parse(data) as { title?: string; webpage_url?: string }; + const parsed = JSON.parse(data) as { + title?: string; + webpage_url?: string; + }; return { title: parsed.title || url, webpageUrl: parsed.webpage_url || url, diff --git a/src/moderation/types.ts b/src/moderation/types.ts index de3a275..055f190 100644 --- a/src/moderation/types.ts +++ b/src/moderation/types.ts @@ -93,7 +93,12 @@ export interface AnalysisResult { } export type MediaMode = "music" | "screen"; -export type MediaSourceKind = "url" | "local" | "youtube" | "spotify" | "search"; +export type MediaSourceKind = + | "url" + | "local" + | "youtube" + | "spotify" + | "search"; export type MediaQueueItemStatus = "queued" | "playing" | "failed"; export interface MediaQueueItem { diff --git a/tests/media/mediaResolver.test.ts b/tests/media/mediaResolver.test.ts index a60aec2..7ddb888 100644 --- a/tests/media/mediaResolver.test.ts +++ b/tests/media/mediaResolver.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { AppError } from "../../src/errors"; -import { createMediaResolver, resolveMediaSource } from "../../src/media/mediaResolver"; +import { + createMediaResolver, + resolveMediaSource, +} from "../../src/media/mediaResolver"; describe("resolveMediaSource", () => { it("accepts http URLs", async () => { @@ -48,7 +51,9 @@ describe("resolveMediaSource", () => { const resolver = createMediaResolver({ ytdlp: { getMetadata: vi.fn(), - getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/search"), + getDirectAudioUrl: vi.fn( + async () => "https://audio.example.com/search", + ), }, playDlResolver: { searchYouTube: vi.fn(async () => ({ @@ -117,7 +122,9 @@ describe("resolveMediaSource", () => { const resolver = createMediaResolver({ ytdlp: { getMetadata: vi.fn(), - getDirectAudioUrl: vi.fn(async () => "https://audio.example.com/spotify"), + getDirectAudioUrl: vi.fn( + async () => "https://audio.example.com/spotify", + ), }, playDlResolver: { searchYouTube: vi.fn(),