import { afterEach, describe, expect, test, vi } from "vitest"; import { resolveBandcampQueue } from "../../utils/bandcamp.ts"; import { isStreamStale, resolveStreamQueue, STREAM_STALE_MS, } from "../../utils/streamSources.ts"; import { embedListId, fetchPlaylistTracks, withJsApi, } from "../../utils/youtube.ts"; import { extractPeaks } from "../../utils/waveform.ts"; import { calls, fail, route } from "../fetch.ts"; import type { PlayerItem } from "../../contexts/PlayerContext.ts"; import { playerItemKey } from "../../contexts/PlayerContext.ts"; afterEach(() => vi.useRealTimers()); const album = { sourceUrl: "https://artist.bandcamp.com/album/a", itemType: "album", title: "A", artist: "Artist", artworkUrl: "https://f4.bcbits.com/a.jpg", resolvedAt: 1000, tracks: [ { index: 0, trackNum: 1, title: "One", duration: 100, streamUrl: "https://s/1", streamable: true, capped: false, }, { index: 1, trackNum: 2, title: "Two", streamUrl: null, streamable: false, capped: true, }, { index: 2, trackNum: 3, title: "Three", duration: 50, streamUrl: "https://s/3", streamable: true, capped: false, }, ], }; describe("resolveBandcampQueue", () => { test("turns the streamable tracks into a queue", async () => { route("GET /api/bandcamp/tracks", () => album); const items = await resolveBandcampQueue(album.sourceUrl, { dumpHref: "/dumps/x", embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", }); expect(items).toHaveLength(2); expect(items[0]).toMatchObject({ kind: "stream", type: "bandcamp", streamUrl: "https://s/1", title: "One", subtitle: "Artist", trackNum: 1, duration: 100, artworkUrl: album.artworkUrl, dumpHref: "/dumps/x", resolveUrl: album.sourceUrl, resolveIndex: 0, resolvedAt: 1000, embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", }); expect(items[1]).toMatchObject({ resolveIndex: 2 }); const [req] = calls("GET /api/bandcamp/tracks"); expect(req.url.searchParams.get("url")).toBe(album.sourceUrl); expect(req.url.searchParams.has("force")).toBe(false); }); test("force asks the server to skip its cache", async () => { route("GET /api/bandcamp/tracks", () => album); await resolveBandcampQueue(album.sourceUrl, {}, { force: true }); expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force")) .toBe("1"); }); test("falls back to the given artwork", async () => { route( "GET /api/bandcamp/tracks", () => ({ ...album, artworkUrl: undefined }), ); const [item] = await resolveBandcampQueue(album.sourceUrl, { fallbackArtworkUrl: "https://mine/art.png", }); expect(item.artworkUrl).toBe("https://mine/art.png"); }); test("throws on an HTTP error, an error envelope, or nothing to play", async () => { route("GET /api/bandcamp/tracks", () => fail("down", 502), { once: true }); await expect(resolveBandcampQueue("u")).rejects.toThrow(/502/); route("GET /api/bandcamp/tracks", () => fail("nope", 200), { once: true }); await expect(resolveBandcampQueue("u")).rejects.toThrow(/no data/); route("GET /api/bandcamp/tracks", () => ({ ...album, tracks: [album.tracks[1]], })); await expect(resolveBandcampQueue("u")).rejects.toThrow(/no streamable/); }); }); describe("stream sources", () => { const stream: Extract = { kind: "stream", type: "bandcamp", streamUrl: "https://s/1", resolveUrl: album.sourceUrl, resolveIndex: 0, resolvedAt: Date.now(), artworkUrl: "https://old/art.png", dumpHref: "/dumps/x", }; test("re-resolving a bandcamp stream forces a fresh lookup and keeps the art", async () => { route( "GET /api/bandcamp/tracks", () => ({ ...album, artworkUrl: undefined }), ); const items = await resolveStreamQueue(stream); expect(items[0].artworkUrl).toBe("https://old/art.png"); expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force")) .toBe("1"); }); test("an unknown stream type has no resolver", async () => { await expect(resolveStreamQueue({ ...stream, type: "other" })).rejects .toThrow(/no resolver/); }); test("isStreamStale after the window", () => { vi.useFakeTimers(); vi.setSystemTime(10 * STREAM_STALE_MS); expect(isStreamStale({ ...stream, resolvedAt: Date.now() - 1000 })).toBe( false, ); expect( isStreamStale({ ...stream, resolvedAt: Date.now() - STREAM_STALE_MS - 1, }), ).toBe(true); }); test("playerItemKey ignores the signed URL", () => { expect(playerItemKey(stream)).toBe( playerItemKey({ ...stream, streamUrl: "https://s/refreshed" }), ); expect( playerItemKey({ kind: "file", fileUrl: "f", mimeType: "audio/mpeg" }), ) .toBe("file:f"); expect(playerItemKey({ kind: "embed", embedUrl: "e", type: "youtube" })) .toBe("embed:e"); }); }); describe("youtube", () => { test("embedListId reads ?list= from either embed shape", () => { expect(embedListId("https://www.youtube.com/embed/videoseries?list=PL1")) .toBe("PL1"); expect(embedListId("https://www.youtube.com/embed/abc?list=PL2")).toBe( "PL2", ); expect(embedListId("https://www.youtube.com/embed/abc")).toBeNull(); expect(embedListId("nonsense")).toBeNull(); }); test("withJsApi adds the API parameters and our origin", () => { const url = new URL(withJsApi("https://www.youtube.com/embed/abc?list=PL")); expect(url.searchParams.get("enablejsapi")).toBe("1"); expect(url.searchParams.get("origin")).toBe(location.origin); expect(url.searchParams.get("list")).toBe("PL"); expect(withJsApi("bad url")).toBe("bad url"); }); test("fetchPlaylistTracks", async () => { route( "GET /api/youtube/playlist", (req) => req.url.searchParams.get("list") === "PL" ? { tracks: [{ videoId: "a", title: "A", duration: 3 }] } : { tracks: [] }, ); await expect(fetchPlaylistTracks("PL")).resolves.toEqual([ { videoId: "a", title: "A", duration: 3 }, ]); await expect(fetchPlaylistTracks("EMPTY")).rejects.toThrow(/no tracks/); route("GET /api/youtube/playlist", () => fail("x", 500)); await expect(fetchPlaylistTracks("PL")).rejects.toThrow(/500/); }); }); describe("extractPeaks", () => { test("decodes once per URL and takes each block's peak", async () => { const data = new Float32Array([0.1, -0.5, 0.2, 0.9, -0.3, 0.4]); const decode = vi.fn(async () => ({ getChannelData: () => data })); const close = vi.fn(); vi.stubGlobal( "AudioContext", class { decodeAudioData = decode; close = close; }, ); route("GET /api/files/:id", () => new Response(new Uint8Array([1, 2]))); const peaks = await extractPeaks("http://localhost:8000/api/files/w1", 3); expect([...peaks].map((p) => +p.toFixed(2))).toEqual([0.5, 0.9, 0.4]); expect(close).toHaveBeenCalled(); await extractPeaks("http://localhost:8000/api/files/w1", 3); expect(decode).toHaveBeenCalledTimes(1); expect(calls("GET /api/files/:id")).toHaveLength(1); }); test("a failed fetch throws", async () => { route("GET /api/files/:id", () => fail("gone", 404)); await expect(extractPeaks("http://localhost:8000/api/files/w2", 3)).rejects .toThrow(/404/); }); });