@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.
Files changed (180) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.changeset/nice-wolves-thank.md +5 -0
  3. package/.freqhole-versions.json +4 -0
  4. package/.github/copilot-instructions.md +201 -0
  5. package/.github/workflows/changesets.yml +50 -0
  6. package/.github/workflows/npm-publish.yml +124 -0
  7. package/.github/workflows/pr-checks.yml +103 -0
  8. package/README.md +30 -0
  9. package/build-component.js +141 -0
  10. package/build-zip-bundle-lib.js +44 -0
  11. package/config/playwright.config.ts +47 -0
  12. package/config/vite.config.ts +44 -0
  13. package/config/vitest.config.ts +39 -0
  14. package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
  15. package/dist/assets/index-CbOXzGiA.js +216 -0
  16. package/dist/assets/index-CbOXzGiA.js.map +1 -0
  17. package/dist/assets/index-TvJ6RFpy.css +1 -0
  18. package/dist/assets/midden-DceCrT_L.js +2 -0
  19. package/dist/assets/midden-DceCrT_L.js.map +1 -0
  20. package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
  21. package/dist/index.html +55 -0
  22. package/dist/sw.js +134 -0
  23. package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
  24. package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
  25. package/docs/E2E_TESTID_PLAN.md +234 -0
  26. package/docs/IROH_P2P_PLAN.md +302 -0
  27. package/docs/ROADMAP.md +695 -0
  28. package/docs/TODO.md +167 -0
  29. package/docs/bundle-embedding-plan.md +134 -0
  30. package/docs/standalone-refactor.md +184 -0
  31. package/e2e/all-playlists.spec.ts +220 -0
  32. package/e2e/audio-player.spec.ts +226 -0
  33. package/e2e/collaborative-features.spec.ts +229 -0
  34. package/e2e/contexts.ts +238 -0
  35. package/e2e/edit-panel.spec.ts +87 -0
  36. package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
  37. package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
  38. package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
  39. package/e2e/fixtures/chord-stack-3s.wav +0 -0
  40. package/e2e/fixtures/cover-anim.gif +0 -0
  41. package/e2e/fixtures/cover-blue.png +0 -0
  42. package/e2e/fixtures/cover-checkers.png +0 -0
  43. package/e2e/fixtures/cover-gradient.jpg +0 -0
  44. package/e2e/fixtures/cover-mono.gif +0 -0
  45. package/e2e/fixtures/cover-noise.png +0 -0
  46. package/e2e/fixtures/cover-plasma.webp +0 -0
  47. package/e2e/fixtures/cover-portrait.jpg +0 -0
  48. package/e2e/fixtures/cover-red.png +0 -0
  49. package/e2e/fixtures/cover-thumb.jpg +0 -0
  50. package/e2e/fixtures/cover-wide.webp +0 -0
  51. package/e2e/fixtures/generate.mjs +257 -0
  52. package/e2e/fixtures/long-drone-90s.mp3 +0 -0
  53. package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
  54. package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
  55. package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
  56. package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
  57. package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
  58. package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
  59. package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
  60. package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
  61. package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
  62. package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
  63. package/e2e/fixtures/tone-220hz-10s.wav +0 -0
  64. package/e2e/fixtures/tone-440hz-2s.wav +0 -0
  65. package/e2e/fixtures/tone-880hz-5s.wav +0 -0
  66. package/e2e/fixtures/tone-stereo-3s.wav +0 -0
  67. package/e2e/fixtures/user-provided/README.md +1 -0
  68. package/e2e/helpers/app.ts +143 -0
  69. package/e2e/helpers/hooks.ts +133 -0
  70. package/e2e/helpers/index.ts +12 -0
  71. package/e2e/helpers/media.ts +125 -0
  72. package/e2e/helpers.ts +10 -0
  73. package/e2e/p2p-collaboration.spec.ts +356 -0
  74. package/e2e/p2p-multi-peer.spec.ts +723 -0
  75. package/e2e/p2p-states.spec.ts +302 -0
  76. package/e2e/playback.spec.ts +56 -0
  77. package/e2e/playlist-crud.spec.ts +126 -0
  78. package/e2e/share-link-autoplay.spec.ts +129 -0
  79. package/e2e/sharing-access.spec.ts +205 -0
  80. package/e2e/sharing.spec.ts +195 -0
  81. package/e2e/song-cache-state.spec.ts +202 -0
  82. package/e2e/zip-bundle.spec.ts +855 -0
  83. package/eslint.config.js +114 -0
  84. package/index.html +54 -0
  85. package/package.json +119 -0
  86. package/public/sw.js +134 -0
  87. package/scripts/use-local.mjs +37 -0
  88. package/scripts/use-published.mjs +37 -0
  89. package/src/App.tsx +9 -0
  90. package/src/cli/check.ts +164 -0
  91. package/src/cli/generate.ts +184 -0
  92. package/src/cli/http.ts +88 -0
  93. package/src/cli/index.ts +65 -0
  94. package/src/cli/init.ts +18 -0
  95. package/src/components/AllPlaylistsPanel.tsx +713 -0
  96. package/src/components/AudioPlayer.tsx +122 -0
  97. package/src/components/MarqueeText.tsx +101 -0
  98. package/src/components/PlaylistCoverModal.tsx +519 -0
  99. package/src/components/PlaylistEditPanel.tsx +803 -0
  100. package/src/components/PlaylistSharePanel.tsx +1020 -0
  101. package/src/components/ShareLinkKnockPanel.tsx +144 -0
  102. package/src/components/SharePanel.tsx +584 -0
  103. package/src/components/SongEditModal.tsx +453 -0
  104. package/src/components/SongEditPanel.tsx +578 -0
  105. package/src/components/SongRow.tsx +689 -0
  106. package/src/components/index.tsx +494 -0
  107. package/src/components/playlist/index.tsx +1203 -0
  108. package/src/context/PlaylistzContext.tsx +74 -0
  109. package/src/dev-hooks.ts +35 -0
  110. package/src/hooks/createDocIndexQuery.ts +53 -0
  111. package/src/hooks/createDocStore.test.ts +303 -0
  112. package/src/hooks/createDocStore.ts +90 -0
  113. package/src/hooks/useDragAndDrop.test.ts +474 -0
  114. package/src/hooks/useDragAndDrop.ts +400 -0
  115. package/src/hooks/useImageModal.test.ts +174 -0
  116. package/src/hooks/useImageModal.ts +201 -0
  117. package/src/hooks/usePlaylistManager.test.ts +453 -0
  118. package/src/hooks/usePlaylistManager.ts +685 -0
  119. package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
  120. package/src/hooks/usePlaylistsQuery.ts +44 -0
  121. package/src/hooks/useSongState.test.ts +236 -0
  122. package/src/hooks/useSongState.ts +114 -0
  123. package/src/hooks/useUIState.ts +71 -0
  124. package/src/index.tsx +18 -0
  125. package/src/services/audioService.dev.ts +22 -0
  126. package/src/services/audioService.test.ts +1226 -0
  127. package/src/services/audioService.ts +1395 -0
  128. package/src/services/automergeRepo.test.ts +269 -0
  129. package/src/services/automergeRepo.ts +226 -0
  130. package/src/services/blobTransferService.dev.ts +119 -0
  131. package/src/services/blobTransferService.test.ts +441 -0
  132. package/src/services/blobTransferService.ts +702 -0
  133. package/src/services/docIndexService.test.ts +179 -0
  134. package/src/services/docIndexService.ts +118 -0
  135. package/src/services/fileProcessingService.test.ts +554 -0
  136. package/src/services/fileProcessingService.ts +239 -0
  137. package/src/services/imageService.test.ts +701 -0
  138. package/src/services/imageService.ts +365 -0
  139. package/src/services/indexedDBService.integration.test.ts +104 -0
  140. package/src/services/indexedDBService.test.ts +202 -0
  141. package/src/services/indexedDBService.ts +436 -0
  142. package/src/services/offlineService.test.ts +661 -0
  143. package/src/services/offlineService.ts +382 -0
  144. package/src/services/p2pService.test.ts +305 -0
  145. package/src/services/p2pService.ts +344 -0
  146. package/src/services/playlistDocService.test.ts +448 -0
  147. package/src/services/playlistDocService.ts +707 -0
  148. package/src/services/playlistDownloadService.test.ts +674 -0
  149. package/src/services/playlistDownloadService.ts +389 -0
  150. package/src/services/sharingService.test.ts +812 -0
  151. package/src/services/sharingService.ts +1073 -0
  152. package/src/services/sharingState.ts +161 -0
  153. package/src/services/songReactivity.test.ts +620 -0
  154. package/src/services/songReactivity.ts +145 -0
  155. package/src/services/standaloneService.test.ts +1025 -0
  156. package/src/services/standaloneService.ts +588 -0
  157. package/src/services/streamingAudioService.test.ts +275 -0
  158. package/src/services/streamingAudioService.ts +166 -0
  159. package/src/styles.css +428 -0
  160. package/src/test-setup.ts +547 -0
  161. package/src/types/global.d.ts +40 -0
  162. package/src/types/playlist.ts +99 -0
  163. package/src/utils/hashUtils.ts +41 -0
  164. package/src/utils/log.ts +97 -0
  165. package/src/utils/m3u.test.ts +172 -0
  166. package/src/utils/m3u.ts +136 -0
  167. package/src/utils/mockData.ts +166 -0
  168. package/src/utils/standaloneTemplates.test.ts +175 -0
  169. package/src/utils/standaloneTemplates.ts +83 -0
  170. package/src/utils/swTemplate.ts +84 -0
  171. package/src/utils/timeUtils.ts +166 -0
  172. package/src/utils/typeGuards.ts +171 -0
  173. package/src/web-component.tsx +98 -0
  174. package/src/zip-bundle/index.ts +7 -0
  175. package/src/zip-bundle/m3u.ts +45 -0
  176. package/src/zip-bundle/types.ts +50 -0
  177. package/src/zip-bundle/utils.ts +33 -0
  178. package/src/zip-bundle/zipBuilder.ts +309 -0
  179. package/tailwind.config.js +55 -0
  180. 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
+ }
@@ -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
+ });
@@ -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;