@freqhole/playlistz 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/config.json +11 -0
- package/.changeset/nice-wolves-thank.md +5 -0
- package/.freqhole-versions.json +4 -0
- package/.github/copilot-instructions.md +201 -0
- package/.github/workflows/changesets.yml +50 -0
- package/.github/workflows/npm-publish.yml +124 -0
- package/.github/workflows/pr-checks.yml +103 -0
- package/README.md +30 -0
- package/build-component.js +141 -0
- package/build-zip-bundle-lib.js +44 -0
- package/config/playwright.config.ts +47 -0
- package/config/vite.config.ts +44 -0
- package/config/vitest.config.ts +39 -0
- package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
- package/dist/assets/index-CbOXzGiA.js +216 -0
- package/dist/assets/index-CbOXzGiA.js.map +1 -0
- package/dist/assets/index-TvJ6RFpy.css +1 -0
- package/dist/assets/midden-DceCrT_L.js +2 -0
- package/dist/assets/midden-DceCrT_L.js.map +1 -0
- package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
- package/dist/index.html +55 -0
- package/dist/sw.js +134 -0
- package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
- package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
- package/docs/E2E_TESTID_PLAN.md +234 -0
- package/docs/IROH_P2P_PLAN.md +302 -0
- package/docs/ROADMAP.md +695 -0
- package/docs/TODO.md +167 -0
- package/docs/bundle-embedding-plan.md +134 -0
- package/docs/standalone-refactor.md +184 -0
- package/e2e/all-playlists.spec.ts +220 -0
- package/e2e/audio-player.spec.ts +226 -0
- package/e2e/collaborative-features.spec.ts +229 -0
- package/e2e/contexts.ts +238 -0
- package/e2e/edit-panel.spec.ts +87 -0
- package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
- package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
- package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
- package/e2e/fixtures/chord-stack-3s.wav +0 -0
- package/e2e/fixtures/cover-anim.gif +0 -0
- package/e2e/fixtures/cover-blue.png +0 -0
- package/e2e/fixtures/cover-checkers.png +0 -0
- package/e2e/fixtures/cover-gradient.jpg +0 -0
- package/e2e/fixtures/cover-mono.gif +0 -0
- package/e2e/fixtures/cover-noise.png +0 -0
- package/e2e/fixtures/cover-plasma.webp +0 -0
- package/e2e/fixtures/cover-portrait.jpg +0 -0
- package/e2e/fixtures/cover-red.png +0 -0
- package/e2e/fixtures/cover-thumb.jpg +0 -0
- package/e2e/fixtures/cover-wide.webp +0 -0
- package/e2e/fixtures/generate.mjs +257 -0
- package/e2e/fixtures/long-drone-90s.mp3 +0 -0
- package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
- package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
- package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
- package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
- package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
- package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
- package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
- package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
- package/e2e/fixtures/tone-220hz-10s.wav +0 -0
- package/e2e/fixtures/tone-440hz-2s.wav +0 -0
- package/e2e/fixtures/tone-880hz-5s.wav +0 -0
- package/e2e/fixtures/tone-stereo-3s.wav +0 -0
- package/e2e/fixtures/user-provided/README.md +1 -0
- package/e2e/helpers/app.ts +143 -0
- package/e2e/helpers/hooks.ts +133 -0
- package/e2e/helpers/index.ts +12 -0
- package/e2e/helpers/media.ts +125 -0
- package/e2e/helpers.ts +10 -0
- package/e2e/p2p-collaboration.spec.ts +356 -0
- package/e2e/p2p-multi-peer.spec.ts +723 -0
- package/e2e/p2p-states.spec.ts +302 -0
- package/e2e/playback.spec.ts +56 -0
- package/e2e/playlist-crud.spec.ts +126 -0
- package/e2e/share-link-autoplay.spec.ts +129 -0
- package/e2e/sharing-access.spec.ts +205 -0
- package/e2e/sharing.spec.ts +195 -0
- package/e2e/song-cache-state.spec.ts +202 -0
- package/e2e/zip-bundle.spec.ts +855 -0
- package/eslint.config.js +114 -0
- package/index.html +54 -0
- package/package.json +119 -0
- package/public/sw.js +134 -0
- package/scripts/use-local.mjs +37 -0
- package/scripts/use-published.mjs +37 -0
- package/src/App.tsx +9 -0
- package/src/cli/check.ts +164 -0
- package/src/cli/generate.ts +184 -0
- package/src/cli/http.ts +88 -0
- package/src/cli/index.ts +65 -0
- package/src/cli/init.ts +18 -0
- package/src/components/AllPlaylistsPanel.tsx +713 -0
- package/src/components/AudioPlayer.tsx +122 -0
- package/src/components/MarqueeText.tsx +101 -0
- package/src/components/PlaylistCoverModal.tsx +519 -0
- package/src/components/PlaylistEditPanel.tsx +803 -0
- package/src/components/PlaylistSharePanel.tsx +1020 -0
- package/src/components/ShareLinkKnockPanel.tsx +144 -0
- package/src/components/SharePanel.tsx +584 -0
- package/src/components/SongEditModal.tsx +453 -0
- package/src/components/SongEditPanel.tsx +578 -0
- package/src/components/SongRow.tsx +689 -0
- package/src/components/index.tsx +494 -0
- package/src/components/playlist/index.tsx +1203 -0
- package/src/context/PlaylistzContext.tsx +74 -0
- package/src/dev-hooks.ts +35 -0
- package/src/hooks/createDocIndexQuery.ts +53 -0
- package/src/hooks/createDocStore.test.ts +303 -0
- package/src/hooks/createDocStore.ts +90 -0
- package/src/hooks/useDragAndDrop.test.ts +474 -0
- package/src/hooks/useDragAndDrop.ts +400 -0
- package/src/hooks/useImageModal.test.ts +174 -0
- package/src/hooks/useImageModal.ts +201 -0
- package/src/hooks/usePlaylistManager.test.ts +453 -0
- package/src/hooks/usePlaylistManager.ts +685 -0
- package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
- package/src/hooks/usePlaylistsQuery.ts +44 -0
- package/src/hooks/useSongState.test.ts +236 -0
- package/src/hooks/useSongState.ts +114 -0
- package/src/hooks/useUIState.ts +71 -0
- package/src/index.tsx +18 -0
- package/src/services/audioService.dev.ts +22 -0
- package/src/services/audioService.test.ts +1226 -0
- package/src/services/audioService.ts +1395 -0
- package/src/services/automergeRepo.test.ts +269 -0
- package/src/services/automergeRepo.ts +226 -0
- package/src/services/blobTransferService.dev.ts +119 -0
- package/src/services/blobTransferService.test.ts +441 -0
- package/src/services/blobTransferService.ts +702 -0
- package/src/services/docIndexService.test.ts +179 -0
- package/src/services/docIndexService.ts +118 -0
- package/src/services/fileProcessingService.test.ts +554 -0
- package/src/services/fileProcessingService.ts +239 -0
- package/src/services/imageService.test.ts +701 -0
- package/src/services/imageService.ts +365 -0
- package/src/services/indexedDBService.integration.test.ts +104 -0
- package/src/services/indexedDBService.test.ts +202 -0
- package/src/services/indexedDBService.ts +436 -0
- package/src/services/offlineService.test.ts +661 -0
- package/src/services/offlineService.ts +382 -0
- package/src/services/p2pService.test.ts +305 -0
- package/src/services/p2pService.ts +344 -0
- package/src/services/playlistDocService.test.ts +448 -0
- package/src/services/playlistDocService.ts +707 -0
- package/src/services/playlistDownloadService.test.ts +674 -0
- package/src/services/playlistDownloadService.ts +389 -0
- package/src/services/sharingService.test.ts +812 -0
- package/src/services/sharingService.ts +1073 -0
- package/src/services/sharingState.ts +161 -0
- package/src/services/songReactivity.test.ts +620 -0
- package/src/services/songReactivity.ts +145 -0
- package/src/services/standaloneService.test.ts +1025 -0
- package/src/services/standaloneService.ts +588 -0
- package/src/services/streamingAudioService.test.ts +275 -0
- package/src/services/streamingAudioService.ts +166 -0
- package/src/styles.css +428 -0
- package/src/test-setup.ts +547 -0
- package/src/types/global.d.ts +40 -0
- package/src/types/playlist.ts +99 -0
- package/src/utils/hashUtils.ts +41 -0
- package/src/utils/log.ts +97 -0
- package/src/utils/m3u.test.ts +172 -0
- package/src/utils/m3u.ts +136 -0
- package/src/utils/mockData.ts +166 -0
- package/src/utils/standaloneTemplates.test.ts +175 -0
- package/src/utils/standaloneTemplates.ts +83 -0
- package/src/utils/swTemplate.ts +84 -0
- package/src/utils/timeUtils.ts +166 -0
- package/src/utils/typeGuards.ts +171 -0
- package/src/web-component.tsx +98 -0
- package/src/zip-bundle/index.ts +7 -0
- package/src/zip-bundle/m3u.ts +45 -0
- package/src/zip-bundle/types.ts +50 -0
- package/src/zip-bundle/utils.ts +33 -0
- package/src/zip-bundle/zipBuilder.ts +309 -0
- package/tailwind.config.js +55 -0
- package/tsconfig.json +43 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for calculating SHA-256 hashes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Calculate SHA-256 hash of ArrayBuffer data
|
|
7
|
+
* @param data ArrayBuffer containing the data to hash
|
|
8
|
+
* @returns Promise<string> Hex string representation of the hash
|
|
9
|
+
*/
|
|
10
|
+
export async function calculateSHA256(data: ArrayBuffer): Promise<string> {
|
|
11
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
12
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
13
|
+
const hashHex = hashArray
|
|
14
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
15
|
+
.join("");
|
|
16
|
+
return hashHex;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Calculate SHA-256 hash of a File
|
|
21
|
+
* @param file File to hash
|
|
22
|
+
* @returns Promise<string> Hex string representation of the hash
|
|
23
|
+
*/
|
|
24
|
+
export async function calculateFileSHA256(file: File): Promise<string> {
|
|
25
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
26
|
+
return calculateSHA256(arrayBuffer);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Verify if a given hash matches the SHA-256 of the provided data
|
|
31
|
+
* @param data ArrayBuffer containing the data to verify
|
|
32
|
+
* @param expectedHash Expected SHA-256 hash as hex string
|
|
33
|
+
* @returns Promise<boolean> True if hash matches, false otherwise
|
|
34
|
+
*/
|
|
35
|
+
export async function verifySHA256(
|
|
36
|
+
data: ArrayBuffer,
|
|
37
|
+
expectedHash: string
|
|
38
|
+
): Promise<boolean> {
|
|
39
|
+
const actualHash = await calculateSHA256(data);
|
|
40
|
+
return actualHash === expectedHash;
|
|
41
|
+
}
|
package/src/utils/log.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// lightweight logger with level + tag filtering.
|
|
2
|
+
// level order: trace < debug < info < warn < error
|
|
3
|
+
//
|
|
4
|
+
// build-time config (vite env vars):
|
|
5
|
+
// VITE_LOG_LEVEL - "trace" | "debug" | "info" | "warn" | "error"
|
|
6
|
+
// default: "debug" in dev, "warn" in prod
|
|
7
|
+
// VITE_LOG_FILTER - comma-separated tag prefixes, e.g. "p2p,audio" (default: all tags)
|
|
8
|
+
//
|
|
9
|
+
// runtime override via devtools (no rebuild needed):
|
|
10
|
+
// localStorage.logLevel = "trace";
|
|
11
|
+
// localStorage.logFilter = "automerge.repo,idb.docindex";
|
|
12
|
+
// location.reload();
|
|
13
|
+
//
|
|
14
|
+
// trace is off by default even in dev - enable it explicitly when needed.
|
|
15
|
+
// it's useful for detailed call-by-call tracing of services without adding
|
|
16
|
+
// noise to normal debug output.
|
|
17
|
+
//
|
|
18
|
+
// tags use dotted namespaces, e.g. "p2p.transfer", "audio.player", "idb.service".
|
|
19
|
+
// filter prefix matching: "p2p" matches "p2p", "p2p.transfer", "p2p.knock", etc.
|
|
20
|
+
//
|
|
21
|
+
// usage:
|
|
22
|
+
// import { log } from "../utils/log.js";
|
|
23
|
+
// log.warn("share.panel", "could not build share link:", err);
|
|
24
|
+
// log.debug("playlist.sync", "syncPlaylists #", syncId, "entries:", entries.length);
|
|
25
|
+
// log.trace("automerge.repo", "findPlaylistDoc call #", n, docId);
|
|
26
|
+
|
|
27
|
+
type LogLevel = "trace" | "debug" | "info" | "warn" | "error";
|
|
28
|
+
|
|
29
|
+
const LEVEL_NUM: Record<LogLevel, number> = {
|
|
30
|
+
trace: -1,
|
|
31
|
+
debug: 0,
|
|
32
|
+
info: 1,
|
|
33
|
+
warn: 2,
|
|
34
|
+
error: 3,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function resolveLevel(): number {
|
|
38
|
+
const override =
|
|
39
|
+
typeof localStorage !== "undefined" && typeof localStorage.getItem === "function"
|
|
40
|
+
? (localStorage.getItem("logLevel") as LogLevel | null)
|
|
41
|
+
: null;
|
|
42
|
+
// VITE_LOG_LEVEL is injected at build time; fall back to debug in dev, warn in prod
|
|
43
|
+
const env = import.meta.env.VITE_LOG_LEVEL as LogLevel | undefined;
|
|
44
|
+
// trace is never on by default - must be explicitly requested
|
|
45
|
+
const raw = override ?? env ?? (import.meta.env.DEV ? "debug" : "warn");
|
|
46
|
+
return LEVEL_NUM[raw as LogLevel] ?? LEVEL_NUM.warn;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveFilter(): string[] {
|
|
50
|
+
const override =
|
|
51
|
+
typeof localStorage !== "undefined" && typeof localStorage.getItem === "function"
|
|
52
|
+
? localStorage.getItem("logFilter")
|
|
53
|
+
: null;
|
|
54
|
+
const env = import.meta.env.VITE_LOG_FILTER as string | undefined;
|
|
55
|
+
const raw = override ?? env ?? "";
|
|
56
|
+
return raw
|
|
57
|
+
? raw
|
|
58
|
+
.split(",")
|
|
59
|
+
.map((s) => s.trim())
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
: [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function allowed(tag: string): boolean {
|
|
65
|
+
const filter = resolveFilter();
|
|
66
|
+
if (filter.length === 0) return true;
|
|
67
|
+
return filter.some(
|
|
68
|
+
(prefix) => tag === prefix || tag.startsWith(prefix + ".")
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function emit(
|
|
73
|
+
level: LogLevel,
|
|
74
|
+
tag: string,
|
|
75
|
+
msg: string,
|
|
76
|
+
...args: unknown[]
|
|
77
|
+
): void {
|
|
78
|
+
if (LEVEL_NUM[level] < resolveLevel()) return;
|
|
79
|
+
if (!allowed(tag)) return;
|
|
80
|
+
const prefix = `[${tag}]`;
|
|
81
|
+
if (level === "error") console.error(prefix, msg, ...args);
|
|
82
|
+
else if (level === "warn") console.warn(prefix, msg, ...args);
|
|
83
|
+
else console.log(prefix, msg, ...args);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const log = {
|
|
87
|
+
trace: (tag: string, msg: string, ...args: unknown[]): void =>
|
|
88
|
+
emit("trace", tag, msg, ...args),
|
|
89
|
+
debug: (tag: string, msg: string, ...args: unknown[]): void =>
|
|
90
|
+
emit("debug", tag, msg, ...args),
|
|
91
|
+
info: (tag: string, msg: string, ...args: unknown[]): void =>
|
|
92
|
+
emit("info", tag, msg, ...args),
|
|
93
|
+
warn: (tag: string, msg: string, ...args: unknown[]): void =>
|
|
94
|
+
emit("warn", tag, msg, ...args),
|
|
95
|
+
error: (tag: string, msg: string, ...args: unknown[]): void =>
|
|
96
|
+
emit("error", tag, msg, ...args),
|
|
97
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseM3U, serializeM3U, generateM3UContent } from "./m3u.js";
|
|
3
|
+
import type { Playlist, Song } from "../types/playlist.js";
|
|
4
|
+
|
|
5
|
+
// ---- helpers ----
|
|
6
|
+
|
|
7
|
+
const BASIC_M3U = `#EXTM3U
|
|
8
|
+
# Playlist: test mix
|
|
9
|
+
# Description: some songs
|
|
10
|
+
# PlaylistImage: data/playlist-cover.jpg
|
|
11
|
+
|
|
12
|
+
#EXTINF:180, artist a - song one
|
|
13
|
+
# Title: song one
|
|
14
|
+
# Artist: artist a
|
|
15
|
+
# Album: album x
|
|
16
|
+
# Image: data/01-song one-cover.jpg
|
|
17
|
+
data/01-song one.mp3
|
|
18
|
+
|
|
19
|
+
#EXTINF:240, artist b - song two
|
|
20
|
+
# Title: song two
|
|
21
|
+
# Artist: artist b
|
|
22
|
+
# Album: album y
|
|
23
|
+
data/02-song two.m4a
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
// ---- parseM3U ----
|
|
27
|
+
|
|
28
|
+
describe("parseM3U", () => {
|
|
29
|
+
it("parses playlist header fields", () => {
|
|
30
|
+
const p = parseM3U(BASIC_M3U);
|
|
31
|
+
expect(p.title).toBe("test mix");
|
|
32
|
+
expect(p.description).toBe("some songs");
|
|
33
|
+
expect(p.playlistImageFile).toBe("data/playlist-cover.jpg");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("id and rev are null when absent", () => {
|
|
37
|
+
const p = parseM3U(BASIC_M3U);
|
|
38
|
+
expect(p.id).toBeNull();
|
|
39
|
+
expect(p.rev).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("parses PlaylistId and PlaylistRev when present", () => {
|
|
43
|
+
const src = `#EXTM3U\n# Playlist: x\n# PlaylistId: abc-123\n# PlaylistRev: 7\n`;
|
|
44
|
+
const p = parseM3U(src);
|
|
45
|
+
expect(p.id).toBe("abc-123");
|
|
46
|
+
expect(p.rev).toBe(7);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("parses song count correctly", () => {
|
|
50
|
+
expect(parseM3U(BASIC_M3U).songs).toHaveLength(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("parses first song fields", () => {
|
|
54
|
+
const s = parseM3U(BASIC_M3U).songs[0]!;
|
|
55
|
+
expect(s.title).toBe("song one");
|
|
56
|
+
expect(s.artist).toBe("artist a");
|
|
57
|
+
expect(s.album).toBe("album x");
|
|
58
|
+
expect(s.duration).toBe(180);
|
|
59
|
+
expect(s.audioFile).toBe("data/01-song one.mp3");
|
|
60
|
+
expect(s.imageFile).toBe("data/01-song one-cover.jpg");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("parses song with no image as empty imageFile", () => {
|
|
64
|
+
const s = parseM3U(BASIC_M3U).songs[1]!;
|
|
65
|
+
expect(s.imageFile).toBe("");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("preserves rawLines for write-back", () => {
|
|
69
|
+
const p = parseM3U(BASIC_M3U);
|
|
70
|
+
expect(p.rawLines.length).toBeGreaterThan(0);
|
|
71
|
+
expect(p.rawLines[0]).toBe("#EXTM3U");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("parses empty string without throwing", () => {
|
|
75
|
+
const p = parseM3U("");
|
|
76
|
+
expect(p.songs).toHaveLength(0);
|
|
77
|
+
expect(p.title).toBe("");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ---- serializeM3U ----
|
|
82
|
+
|
|
83
|
+
describe("serializeM3U", () => {
|
|
84
|
+
it("inserts PlaylistId and PlaylistRev when absent", () => {
|
|
85
|
+
const p = parseM3U(BASIC_M3U);
|
|
86
|
+
p.id = "new-id";
|
|
87
|
+
p.rev = 0;
|
|
88
|
+
const out = serializeM3U(p);
|
|
89
|
+
expect(out).toContain("# PlaylistId: new-id");
|
|
90
|
+
expect(out).toContain("# PlaylistRev: 0");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("updates existing PlaylistId in place", () => {
|
|
94
|
+
const src = `#EXTM3U\n# Playlist: x\n# PlaylistId: old-id\n# PlaylistRev: 1\n`;
|
|
95
|
+
const p = parseM3U(src);
|
|
96
|
+
p.id = "updated-id";
|
|
97
|
+
p.rev = 2;
|
|
98
|
+
const out = serializeM3U(p);
|
|
99
|
+
expect(out).toContain("# PlaylistId: updated-id");
|
|
100
|
+
expect(out).not.toContain("# PlaylistId: old-id");
|
|
101
|
+
expect(out).toContain("# PlaylistRev: 2");
|
|
102
|
+
// should not duplicate
|
|
103
|
+
expect(out.split("# PlaylistId:").length - 1).toBe(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("round-trips all songs unchanged", () => {
|
|
107
|
+
const p = parseM3U(BASIC_M3U);
|
|
108
|
+
p.id = "x";
|
|
109
|
+
p.rev = 0;
|
|
110
|
+
const out = serializeM3U(p);
|
|
111
|
+
expect(out).toContain("data/01-song one.mp3");
|
|
112
|
+
expect(out).toContain("data/02-song two.m4a");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ---- generateM3UContent ----
|
|
117
|
+
|
|
118
|
+
describe("generateM3UContent", () => {
|
|
119
|
+
const getExt = (mime: string) => {
|
|
120
|
+
const map: Record<string, string> = { "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp" };
|
|
121
|
+
return map[mime] ?? ".jpg";
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const playlist = {
|
|
125
|
+
id: "pl-1",
|
|
126
|
+
title: "my playlist",
|
|
127
|
+
description: "cool songs",
|
|
128
|
+
rev: 3,
|
|
129
|
+
imageData: new ArrayBuffer(1),
|
|
130
|
+
imageType: "image/jpeg",
|
|
131
|
+
} as unknown as Playlist;
|
|
132
|
+
|
|
133
|
+
const songs = [
|
|
134
|
+
{
|
|
135
|
+
title: "tune",
|
|
136
|
+
artist: "dj",
|
|
137
|
+
album: "ep",
|
|
138
|
+
duration: 120,
|
|
139
|
+
originalFilename: "01-tune.mp3",
|
|
140
|
+
imageData: new ArrayBuffer(1),
|
|
141
|
+
imageType: "image/jpeg",
|
|
142
|
+
} as unknown as Song,
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
it("starts with #EXTM3U", () => {
|
|
146
|
+
const out = generateM3UContent(playlist, songs, ["01-tune.mp3"], getExt);
|
|
147
|
+
expect(out).toMatch(/^#EXTM3U\n/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("includes PlaylistId and PlaylistRev", () => {
|
|
151
|
+
const out = generateM3UContent(playlist, songs, ["01-tune.mp3"], getExt);
|
|
152
|
+
expect(out).toContain("# PlaylistId: pl-1");
|
|
153
|
+
expect(out).toContain("# PlaylistRev: 3");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("includes playlist title and description", () => {
|
|
157
|
+
const out = generateM3UContent(playlist, songs, ["01-tune.mp3"], getExt);
|
|
158
|
+
expect(out).toContain("# Playlist: my playlist");
|
|
159
|
+
expect(out).toContain("# Description: cool songs");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("includes EXTINF with correct duration and audio path", () => {
|
|
163
|
+
const out = generateM3UContent(playlist, songs, ["01-tune.mp3"], getExt);
|
|
164
|
+
expect(out).toContain("#EXTINF:120, dj - tune");
|
|
165
|
+
expect(out).toContain("data/01-tune.mp3");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("skips songs with no filename", () => {
|
|
169
|
+
const out = generateM3UContent(playlist, songs, [undefined as unknown as string], getExt);
|
|
170
|
+
expect(out).not.toContain("#EXTINF");
|
|
171
|
+
});
|
|
172
|
+
});
|
package/src/utils/m3u.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// shared m3u8 format utilities: parser + generator.
|
|
2
|
+
// browser-compatible and node-compatible (no fs/path imports).
|
|
3
|
+
|
|
4
|
+
import type { Playlist, Song } from "../types/playlist.js";
|
|
5
|
+
|
|
6
|
+
// ---- types ----
|
|
7
|
+
|
|
8
|
+
export interface M3USong {
|
|
9
|
+
duration: number;
|
|
10
|
+
title: string;
|
|
11
|
+
artist: string;
|
|
12
|
+
album: string;
|
|
13
|
+
imageFile: string; // e.g. "data/01-song-cover.jpg" as written in m3u8
|
|
14
|
+
audioFile: string; // e.g. "data/01-song.mp3" as written in m3u8
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface M3UPlaylist {
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
playlistImageFile: string; // e.g. "data/playlist-cover.jpg"
|
|
21
|
+
id: string | null;
|
|
22
|
+
rev: number | null;
|
|
23
|
+
songs: M3USong[];
|
|
24
|
+
rawLines: string[]; // original lines, preserved for write-back
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---- parser ----
|
|
28
|
+
|
|
29
|
+
export function parseM3U(content: string): M3UPlaylist {
|
|
30
|
+
const lines = content.split("\n");
|
|
31
|
+
const result: M3UPlaylist = {
|
|
32
|
+
title: "",
|
|
33
|
+
description: "",
|
|
34
|
+
playlistImageFile: "",
|
|
35
|
+
id: null,
|
|
36
|
+
rev: null,
|
|
37
|
+
songs: [],
|
|
38
|
+
rawLines: lines,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
let pendingExtinf: { duration: number } | null = null;
|
|
42
|
+
let pendingTitle = "";
|
|
43
|
+
let pendingArtist = "";
|
|
44
|
+
let pendingAlbum = "";
|
|
45
|
+
let pendingImage = "";
|
|
46
|
+
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
const t = line.trim();
|
|
49
|
+
|
|
50
|
+
if (t.startsWith("# Playlist:")) result.title = t.slice("# Playlist:".length).trim();
|
|
51
|
+
else if (t.startsWith("# Description:"))result.description = t.slice("# Description:".length).trim();
|
|
52
|
+
else if (t.startsWith("# PlaylistImage:"))result.playlistImageFile = t.slice("# PlaylistImage:".length).trim();
|
|
53
|
+
else if (t.startsWith("# PlaylistId:")) result.id = t.slice("# PlaylistId:".length).trim();
|
|
54
|
+
else if (t.startsWith("# PlaylistRev:"))result.rev = parseInt(t.slice("# PlaylistRev:".length).trim(), 10);
|
|
55
|
+
else if (t.startsWith("#EXTINF:")) {
|
|
56
|
+
const durationStr = t.slice("#EXTINF:".length).split(",")[0] ?? "0";
|
|
57
|
+
pendingExtinf = { duration: parseInt(durationStr, 10) };
|
|
58
|
+
pendingTitle = pendingArtist = pendingAlbum = pendingImage = "";
|
|
59
|
+
} else if (t.startsWith("# Title:")) pendingTitle = t.slice("# Title:".length).trim();
|
|
60
|
+
else if (t.startsWith("# Artist:")) pendingArtist = t.slice("# Artist:".length).trim();
|
|
61
|
+
else if (t.startsWith("# Album:")) pendingAlbum = t.slice("# Album:".length).trim();
|
|
62
|
+
else if (t.startsWith("# Image:")) pendingImage = t.slice("# Image:".length).trim();
|
|
63
|
+
else if (t && !t.startsWith("#") && pendingExtinf) {
|
|
64
|
+
result.songs.push({
|
|
65
|
+
duration: pendingExtinf.duration,
|
|
66
|
+
title: pendingTitle,
|
|
67
|
+
artist: pendingArtist,
|
|
68
|
+
album: pendingAlbum,
|
|
69
|
+
imageFile: pendingImage,
|
|
70
|
+
audioFile: t,
|
|
71
|
+
});
|
|
72
|
+
pendingExtinf = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- write-back: insert/update PlaylistId + PlaylistRev in raw lines ----
|
|
80
|
+
|
|
81
|
+
export function serializeM3U(parsed: M3UPlaylist): string {
|
|
82
|
+
const lines = [...parsed.rawLines];
|
|
83
|
+
|
|
84
|
+
const upsertAfter = (marker: string, tag: string, value: string) => {
|
|
85
|
+
const existing = lines.findIndex((l) => l.trim().startsWith(tag));
|
|
86
|
+
if (existing >= 0) {
|
|
87
|
+
lines[existing] = `${tag} ${value}`;
|
|
88
|
+
} else {
|
|
89
|
+
const after = lines.findIndex((l) => l.trim().startsWith(marker));
|
|
90
|
+
if (after >= 0) lines.splice(after + 1, 0, `${tag} ${value}`);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
upsertAfter("# Playlist:", "# PlaylistId:", parsed.id ?? "");
|
|
95
|
+
upsertAfter("# PlaylistId:", "# PlaylistRev:", String(parsed.rev ?? 0));
|
|
96
|
+
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- generator (used by playlistDownloadService) ----
|
|
101
|
+
|
|
102
|
+
export function generateM3UContent(
|
|
103
|
+
playlist: Playlist,
|
|
104
|
+
songs: Song[],
|
|
105
|
+
fileNames: string[],
|
|
106
|
+
getFileExtension: (mimeType: string) => string
|
|
107
|
+
): string {
|
|
108
|
+
let out = "#EXTM3U\n";
|
|
109
|
+
out += `# Playlist: ${playlist.title}\n`;
|
|
110
|
+
if (playlist.id) out += `# PlaylistId: ${playlist.id}\n`;
|
|
111
|
+
out += `# PlaylistRev: ${playlist.rev ?? 0}\n`;
|
|
112
|
+
if (playlist.description) out += `# Description: ${playlist.description}\n`;
|
|
113
|
+
if (playlist.imageData) {
|
|
114
|
+
const ext = getFileExtension(playlist.imageType ?? "image/jpeg");
|
|
115
|
+
out += `# PlaylistImage: data/playlist-cover${ext}\n`;
|
|
116
|
+
}
|
|
117
|
+
out += "\n";
|
|
118
|
+
|
|
119
|
+
songs.forEach((song, i) => {
|
|
120
|
+
const fileName = fileNames[i];
|
|
121
|
+
if (!fileName) return;
|
|
122
|
+
const duration = Math.round(song.duration ?? 0);
|
|
123
|
+
out += `#EXTINF:${duration}, ${song.artist} - ${song.title}\n`;
|
|
124
|
+
out += `# Title: ${song.title}\n`;
|
|
125
|
+
out += `# Artist: ${song.artist}\n`;
|
|
126
|
+
out += `# Album: ${song.album}\n`;
|
|
127
|
+
if (song.imageData && song.originalFilename) {
|
|
128
|
+
const baseName = song.originalFilename.replace(/\.[^.]+$/, "");
|
|
129
|
+
const ext = getFileExtension(song.imageType ?? "image/jpeg");
|
|
130
|
+
out += `# Image: data/${baseName}-cover${ext}\n`;
|
|
131
|
+
}
|
|
132
|
+
out += `data/${fileName}\n\n`;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// test utilities for creating properly typed mock data
|
|
2
|
+
|
|
3
|
+
import type { Playlist, Song } from "../types/playlist.js";
|
|
4
|
+
import type { StandaloneData } from "../services/standaloneService.js";
|
|
5
|
+
import type { FreqholePlaylistSong as StandaloneSongData } from "../utils/standaloneTemplates.js";
|
|
6
|
+
|
|
7
|
+
// create a minimal but valid song object for testing
|
|
8
|
+
export function createMockSong(overrides: Partial<Song> = {}): Song {
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
return {
|
|
11
|
+
id: "test-song-id",
|
|
12
|
+
mimeType: "audio/mp3",
|
|
13
|
+
originalFilename: "test-song.mp3",
|
|
14
|
+
title: "Test Song",
|
|
15
|
+
artist: "Test Artist",
|
|
16
|
+
album: "Test Album",
|
|
17
|
+
duration: 180,
|
|
18
|
+
position: 0,
|
|
19
|
+
createdAt: now,
|
|
20
|
+
updatedAt: now,
|
|
21
|
+
playlistId: "test-playlist-id",
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// create a minimal but valid playlist object for testing
|
|
27
|
+
export function createMockPlaylist(
|
|
28
|
+
overrides: Partial<Playlist> = {}
|
|
29
|
+
): Playlist {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
return {
|
|
32
|
+
id: "test-playlist-id",
|
|
33
|
+
title: "Test Playlist",
|
|
34
|
+
songIds: [],
|
|
35
|
+
createdAt: now,
|
|
36
|
+
updatedAt: now,
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// create a song with audio data for testing
|
|
42
|
+
export function createMockSongWithAudio(overrides: Partial<Song> = {}): Song {
|
|
43
|
+
const audioData = new ArrayBuffer(1024); // mock audio data
|
|
44
|
+
return createMockSong({
|
|
45
|
+
audioData,
|
|
46
|
+
fileSize: audioData.byteLength,
|
|
47
|
+
...overrides,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// create a song with image data for testing
|
|
52
|
+
export function createMockSongWithImage(overrides: Partial<Song> = {}): Song {
|
|
53
|
+
const imageData = new ArrayBuffer(2048); // mock image data
|
|
54
|
+
const thumbnailData = new ArrayBuffer(512); // mock thumbnail data
|
|
55
|
+
return createMockSong({
|
|
56
|
+
imageData,
|
|
57
|
+
thumbnailData,
|
|
58
|
+
imageType: "image/jpeg",
|
|
59
|
+
...overrides,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// create a playlist with image data for testing
|
|
64
|
+
export function createMockPlaylistWithImage(
|
|
65
|
+
overrides: Partial<Playlist> = {}
|
|
66
|
+
): Playlist {
|
|
67
|
+
const imageData = new ArrayBuffer(2048);
|
|
68
|
+
const thumbnailData = new ArrayBuffer(512);
|
|
69
|
+
return createMockPlaylist({
|
|
70
|
+
imageData,
|
|
71
|
+
thumbnailData,
|
|
72
|
+
imageType: "image/jpeg",
|
|
73
|
+
...overrides,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// create mock standalone song data
|
|
78
|
+
export function createMockStandaloneSongData(
|
|
79
|
+
overrides: Partial<StandaloneSongData> = {}
|
|
80
|
+
): StandaloneSongData {
|
|
81
|
+
return {
|
|
82
|
+
id: "test-song-id",
|
|
83
|
+
title: "Test Song",
|
|
84
|
+
artist: "Test Artist",
|
|
85
|
+
album: "Test Album",
|
|
86
|
+
duration: 180,
|
|
87
|
+
originalFilename: "test-song.mp3",
|
|
88
|
+
fileSize: 1024,
|
|
89
|
+
sha: "mock-sha-hash",
|
|
90
|
+
...overrides,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// create mock standalone data
|
|
95
|
+
export function createMockStandaloneData(
|
|
96
|
+
overrides: Partial<StandaloneData> = {}
|
|
97
|
+
): StandaloneData {
|
|
98
|
+
return {
|
|
99
|
+
playlist: {
|
|
100
|
+
id: "test-playlist-id",
|
|
101
|
+
title: "test playlist",
|
|
102
|
+
description: "test description",
|
|
103
|
+
rev: 1,
|
|
104
|
+
},
|
|
105
|
+
songs: [createMockStandaloneSongData()],
|
|
106
|
+
...overrides,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// create a complete playlist with songs for testing
|
|
111
|
+
export function createMockPlaylistWithSongs(songCount = 3): {
|
|
112
|
+
playlist: Playlist;
|
|
113
|
+
songs: Song[];
|
|
114
|
+
} {
|
|
115
|
+
const songs = Array.from({ length: songCount }, (_, i) =>
|
|
116
|
+
createMockSong({
|
|
117
|
+
id: `song-${i}`,
|
|
118
|
+
title: `song ${i + 1}`,
|
|
119
|
+
position: i,
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const playlist = createMockPlaylist({
|
|
124
|
+
songIds: songs.map((song) => song.id),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return { playlist, songs };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// helper to create arraybuffer with specific content for testing
|
|
131
|
+
export function createMockArrayBuffer(
|
|
132
|
+
size: number,
|
|
133
|
+
fillByte = 42
|
|
134
|
+
): ArrayBuffer {
|
|
135
|
+
const buffer = new ArrayBuffer(size);
|
|
136
|
+
const view = new Uint8Array(buffer);
|
|
137
|
+
view.fill(fillByte);
|
|
138
|
+
return buffer;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// mock file object for upload tests
|
|
142
|
+
export function createMockFile(
|
|
143
|
+
name = "test.mp3",
|
|
144
|
+
type = "audio/mp3",
|
|
145
|
+
size = 1024
|
|
146
|
+
): File {
|
|
147
|
+
const content = new ArrayBuffer(size);
|
|
148
|
+
return new File([content], name, { type });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// create minimal objects for testing (when full objects aren't needed)
|
|
152
|
+
export const mockIds = {
|
|
153
|
+
playlist: "test-playlist-id",
|
|
154
|
+
song: "test-song-id",
|
|
155
|
+
user: "test-user-id",
|
|
156
|
+
} as const;
|
|
157
|
+
|
|
158
|
+
// common test data
|
|
159
|
+
export const mockTimestamp = 1640995200000; // fixed timestamp for consistent tests
|
|
160
|
+
export const mockSha =
|
|
161
|
+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
162
|
+
export const mockMimeTypes = {
|
|
163
|
+
audio: "audio/mp3",
|
|
164
|
+
image: "image/jpeg",
|
|
165
|
+
video: "video/mp4",
|
|
166
|
+
} as const;
|