From 93134a9793a02007ccb5aea1a7767e6e9cd42f73 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Fri, 15 May 2026 17:05:37 +0700 Subject: [PATCH] feat: resolve media music sources Co-Authored-By: Claude Opus 4.7 --- src/media/mediaResolver.ts | 41 ++++++++++++++++++++++++++++++ tests/media/mediaResolver.test.ts | 42 +++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/media/mediaResolver.ts create mode 100644 tests/media/mediaResolver.test.ts diff --git a/src/media/mediaResolver.ts b/src/media/mediaResolver.ts new file mode 100644 index 0000000..c90beaf --- /dev/null +++ b/src/media/mediaResolver.ts @@ -0,0 +1,41 @@ +import { existsSync, statSync } from "node:fs"; +import path from "node:path"; +import { AppError } from "../errors"; +import type { ResolvedMediaSource } from "./mediaTypes"; + +export async function resolveMediaSource( + input: string, +): Promise { + const source = input.trim(); + if (!source) { + throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400); + } + + if (source.startsWith("http://") || source.startsWith("https://")) { + return { + source, + title: titleFromUrl(source), + kind: "url", + }; + } + + if (existsSync(source) && statSync(source).isFile()) { + return { + source, + title: path.basename(source), + kind: "local", + }; + } + + throw new AppError( + "Media source must be an HTTP(S) URL or existing local file", + "UNSUPPORTED_MEDIA_SOURCE", + 400, + ); +} + +function titleFromUrl(source: string): string { + const url = new URL(source); + const filename = decodeURIComponent(url.pathname.split("/").pop() || ""); + return filename || url.hostname; +} \ No newline at end of file diff --git a/tests/media/mediaResolver.test.ts b/tests/media/mediaResolver.test.ts new file mode 100644 index 0000000..db66086 --- /dev/null +++ b/tests/media/mediaResolver.test.ts @@ -0,0 +1,42 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { AppError } from "../../src/errors"; +import { resolveMediaSource } from "../../src/media/mediaResolver"; + +describe("resolveMediaSource", () => { + it("accepts http URLs", async () => { + await expect(resolveMediaSource("https://example.com/music.mp3")).resolves.toEqual({ + source: "https://example.com/music.mp3", + title: "music.mp3", + kind: "url", + }); + }); + + it("accepts existing local files", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "media-resolver-")); + const file = path.join(dir, "song.ogg"); + writeFileSync(file, "audio"); + + await expect(resolveMediaSource(file)).resolves.toEqual({ + source: file, + title: "song.ogg", + kind: "local", + }); + }); + + it("rejects empty sources", async () => { + await expect(resolveMediaSource(" ")).rejects.toMatchObject({ + code: "MISSING_MEDIA_SOURCE", + statusCode: 400, + } satisfies Partial); + }); + + it("rejects unsupported sources", async () => { + await expect(resolveMediaSource("not a url or file")).rejects.toMatchObject({ + code: "UNSUPPORTED_MEDIA_SOURCE", + statusCode: 400, + } satisfies Partial); + }); +});