muzical-ui 0.1.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 (117) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +30 -0
  3. package/CLAUDE.md +1 -0
  4. package/LICENSE.md +21 -0
  5. package/README.md +36 -0
  6. package/app/favicon.ico +0 -0
  7. package/app/globals.css +67 -0
  8. package/app/layout.tsx +49 -0
  9. package/app/musicbrainz/page.tsx +6 -0
  10. package/app/page.tsx +12 -0
  11. package/app/settings/display/page.tsx +11 -0
  12. package/app/settings/layout.tsx +19 -0
  13. package/app/settings/library/page.tsx +11 -0
  14. package/app/settings/page.tsx +5 -0
  15. package/app/settings/playback/page.tsx +11 -0
  16. package/app/settings/youtube/page.tsx +11 -0
  17. package/bin/stt-ui.js +25 -0
  18. package/components/AlbumCoverThumb.tsx +82 -0
  19. package/components/BrowsePanel.tsx +64 -0
  20. package/components/DisplaySettingsPanel.tsx +30 -0
  21. package/components/FavoriteStarButton.tsx +41 -0
  22. package/components/LibraryBrowser.tsx +1180 -0
  23. package/components/LibraryProvider.tsx +1023 -0
  24. package/components/LibraryScanNotification.tsx +62 -0
  25. package/components/LibraryScanOptionsSection.tsx +123 -0
  26. package/components/LibrarySettingsPanel.tsx +116 -0
  27. package/components/LibraryStatistics.tsx +54 -0
  28. package/components/MusicBrainzBrowser.tsx +395 -0
  29. package/components/MusicBrainzTrackRow.tsx +52 -0
  30. package/components/MusicPlayer.tsx +1531 -0
  31. package/components/PanelResizeHandle.tsx +65 -0
  32. package/components/PlaybackSettingsPanel.tsx +32 -0
  33. package/components/QueueLoadingSpinner.tsx +19 -0
  34. package/components/SettingsNav.tsx +37 -0
  35. package/components/SettingsOverview.tsx +34 -0
  36. package/components/SettingsShell.tsx +47 -0
  37. package/components/SettingsSwitchRow.tsx +38 -0
  38. package/components/ThemeProvider.tsx +75 -0
  39. package/components/ThemeToggle.tsx +38 -0
  40. package/components/YouTubeSettingsPanel.tsx +79 -0
  41. package/components/YouTubeStreamNotification.tsx +30 -0
  42. package/components/format-library-root-added.ts +13 -0
  43. package/components/settings-nav-items.ts +40 -0
  44. package/eslint.config.mjs +18 -0
  45. package/lib/format-duration.ts +9 -0
  46. package/lib/format-total-library-duration.ts +14 -0
  47. package/lib/library/audio-filename.ts +31 -0
  48. package/lib/library/collect-tracks-for-meta.ts +91 -0
  49. package/lib/library/compute-library-stats.ts +37 -0
  50. package/lib/library/constants.ts +27 -0
  51. package/lib/library/cover-bytes-cache.ts +59 -0
  52. package/lib/library/default-library-scan-preferences.ts +13 -0
  53. package/lib/library/extract-cover-bytes-from-audio-file.ts +41 -0
  54. package/lib/library/extract-cover-object-url-from-audio-file.ts +31 -0
  55. package/lib/library/favorite-keys.ts +14 -0
  56. package/lib/library/format-fs-access-error.ts +29 -0
  57. package/lib/library/idb.ts +270 -0
  58. package/lib/library/read-audio-metadata.ts +34 -0
  59. package/lib/library/read-stored-library-scan-preferences.ts +43 -0
  60. package/lib/library/resolve-track-file.ts +26 -0
  61. package/lib/library/scan-preferences-to-tree-options.ts +15 -0
  62. package/lib/library/scan-progress-label.ts +18 -0
  63. package/lib/library/scan-progress-percent.ts +19 -0
  64. package/lib/library/scan-progress-tick.ts +9 -0
  65. package/lib/library/scan-tree.ts +191 -0
  66. package/lib/library/write-stored-library-scan-preferences.ts +19 -0
  67. package/lib/mock-playlist.ts +47 -0
  68. package/lib/musicbrainz/build-musicbrainz-lucene-queries.ts +46 -0
  69. package/lib/musicbrainz/escape-lucene-term.ts +6 -0
  70. package/lib/musicbrainz/fetch-musicbrainz-json.ts +55 -0
  71. package/lib/musicbrainz/fetch-release-tracks.ts +53 -0
  72. package/lib/musicbrainz/group-tracks-by-album.ts +26 -0
  73. package/lib/musicbrainz/group-tracks-by-artist.ts +23 -0
  74. package/lib/musicbrainz/merge-tracks-by-id.ts +16 -0
  75. package/lib/musicbrainz/musicbrainz-recording-to-track.ts +42 -0
  76. package/lib/musicbrainz/pick-preferred-release.ts +32 -0
  77. package/lib/musicbrainz/pick-release-group-release-id.ts +12 -0
  78. package/lib/musicbrainz/release-group-artist-name.ts +13 -0
  79. package/lib/musicbrainz/search-musicbrainz-recordings.ts +33 -0
  80. package/lib/musicbrainz/search-musicbrainz-release-groups.ts +24 -0
  81. package/lib/musicbrainz/search-musicbrainz.ts +65 -0
  82. package/lib/musicbrainz/types.ts +43 -0
  83. package/lib/musicbrainz.ts +3 -0
  84. package/lib/playback/build-queue-from-snapshot.ts +49 -0
  85. package/lib/playback/parse-persisted-track.ts +45 -0
  86. package/lib/playback/read-stored-playback-snapshot.ts +45 -0
  87. package/lib/playback/write-stored-playback-snapshot.ts +19 -0
  88. package/lib/theme-constants.ts +4 -0
  89. package/lib/theme-init-script.ts +9 -0
  90. package/lib/youtube/clear-youtube-data-api-blocked.ts +8 -0
  91. package/lib/youtube/collect-youtube-prefetch-targets.ts +20 -0
  92. package/lib/youtube/is-youtube-quota-error-message.ts +7 -0
  93. package/lib/youtube/mark-youtube-data-api-blocked.ts +8 -0
  94. package/lib/youtube/prefetch-youtube-video-ids.ts +55 -0
  95. package/lib/youtube/read-stored-youtube-api-key.ts +16 -0
  96. package/lib/youtube/read-youtube-data-api-blocked.ts +12 -0
  97. package/lib/youtube/search-youtube-video-id.ts +60 -0
  98. package/lib/youtube/should-use-youtube-search-playback.ts +19 -0
  99. package/lib/youtube/write-stored-youtube-api-key.ts +18 -0
  100. package/next.config.ts +7 -0
  101. package/package.json +94 -0
  102. package/pnpm-workspace.yaml +6 -0
  103. package/postcss.config.mjs +7 -0
  104. package/public/file.svg +1 -0
  105. package/public/globe.svg +1 -0
  106. package/public/next.svg +1 -0
  107. package/public/vercel.svg +1 -0
  108. package/public/window.svg +1 -0
  109. package/tsconfig.json +34 -0
  110. package/types/file-system-access.d.ts +22 -0
  111. package/types/library-root-meta.ts +5 -0
  112. package/types/library-scan-preferences.ts +9 -0
  113. package/types/library-scan-progress.ts +8 -0
  114. package/types/persisted-playback-snapshot.ts +11 -0
  115. package/types/queue.ts +7 -0
  116. package/types/scan-tree-options.ts +6 -0
  117. package/types/track.ts +29 -0
@@ -0,0 +1,26 @@
1
+ import type { Track } from "@/types/track";
2
+
3
+ /**
4
+ * Opens a `File` for a library track using the saved directory handle map.
5
+ */
6
+ export async function resolveTrackToFile(
7
+ track: Track,
8
+ rootHandles: ReadonlyMap<string, FileSystemDirectoryHandle>,
9
+ ): Promise<File | null> {
10
+ const lib = track.library;
11
+ if (!lib) return null;
12
+ const root = rootHandles.get(lib.rootId);
13
+ if (!root) return null;
14
+ const segments = lib.relativePath.split("/").filter(Boolean);
15
+ if (segments.length === 0) return null;
16
+ let dir = root;
17
+ for (let i = 0; i < segments.length - 1; i++) {
18
+ const seg = segments[i];
19
+ if (!seg) return null;
20
+ dir = await dir.getDirectoryHandle(seg);
21
+ }
22
+ const fileName = segments[segments.length - 1];
23
+ if (!fileName) return null;
24
+ const fh = await dir.getFileHandle(fileName);
25
+ return fh.getFile();
26
+ }
@@ -0,0 +1,15 @@
1
+ import type { LibraryScanPreferences } from "@/types/library-scan-preferences";
2
+ import type { ScanTreeOptions } from "@/types/scan-tree-options";
3
+
4
+ /**
5
+ * Converts stored scan preferences into options for the directory walk.
6
+ */
7
+ export default function scanPreferencesToTreeOptions(
8
+ prefs: LibraryScanPreferences,
9
+ ): ScanTreeOptions {
10
+ return {
11
+ maxScanDepth: prefs.maxScanDepth,
12
+ followSymlinks: prefs.followSymlinks,
13
+ enabledExtensions: new Set(prefs.enabledExtensions),
14
+ };
15
+ }
@@ -0,0 +1,18 @@
1
+ import type { ScanProgressTick } from "@/lib/library/scan-progress-tick";
2
+
3
+ /**
4
+ * Human-readable status line for the library scan notification.
5
+ */
6
+ export function scanProgressLabel(tick: ScanProgressTick): string {
7
+ const prefix =
8
+ tick.rootCount > 1 ? `[${tick.rootIndex + 1}/${tick.rootCount}] ` : "";
9
+ if (tick.phase === "walk") {
10
+ return `${prefix}Listing files in ${tick.rootName}…`;
11
+ }
12
+ const total = tick.filesTotal ?? 0;
13
+ const done = tick.filesDone ?? 0;
14
+ if (total > 0) {
15
+ return `${prefix}Reading tags (${done}/${total}) · ${tick.rootName}`;
16
+ }
17
+ return `${prefix}Reading tags · ${tick.rootName}`;
18
+ }
@@ -0,0 +1,19 @@
1
+ import type { ScanProgressTick } from "@/lib/library/scan-progress-tick";
2
+
3
+ /**
4
+ * Maps scan phase + per-root position to an overall 0–100 percentage.
5
+ */
6
+ export function scanProgressPercent(tick: ScanProgressTick): number {
7
+ if (tick.rootCount <= 0) return 0;
8
+ const perRoot = 100 / tick.rootCount;
9
+ let value = tick.rootIndex * perRoot;
10
+ if (tick.phase === "walk") {
11
+ value += perRoot * 0.12;
12
+ } else {
13
+ const total = tick.filesTotal ?? 0;
14
+ const done = tick.filesDone ?? 0;
15
+ const metaRatio = total > 0 ? Math.min(1, done / total) : 0;
16
+ value += perRoot * (0.12 + 0.88 * metaRatio);
17
+ }
18
+ return Math.min(100, Math.round(value));
19
+ }
@@ -0,0 +1,9 @@
1
+ /** Internal progress event emitted while scanning one or more library roots. */
2
+ export type ScanProgressTick = {
3
+ rootIndex: number;
4
+ rootCount: number;
5
+ rootName: string;
6
+ phase: "walk" | "metadata";
7
+ filesDone?: number;
8
+ filesTotal?: number;
9
+ };
@@ -0,0 +1,191 @@
1
+ import {
2
+ isAudioFilename,
3
+ stripAudioExtension,
4
+ } from "@/lib/library/audio-filename";
5
+ import { extractTagsFromAudioFile } from "@/lib/library/read-audio-metadata";
6
+ import type { ScanTreeOptions } from "@/types/scan-tree-options";
7
+ import type { Track } from "@/types/track";
8
+
9
+ const METADATA_PARSE_CONCURRENCY = 6;
10
+
11
+ export type PendingAudioFile = {
12
+ rootId: string;
13
+ rootDisplayName: string;
14
+ relativePath: string;
15
+ handle: FileSystemFileHandle;
16
+ };
17
+
18
+ /**
19
+ * Walks a directory tree and collects audio file handles (no I/O on file bodies yet).
20
+ */
21
+ export async function collectPendingAudioFiles(
22
+ rootId: string,
23
+ rootDisplayName: string,
24
+ dir: FileSystemDirectoryHandle,
25
+ pathPrefix: string,
26
+ depth: number,
27
+ options: ScanTreeOptions,
28
+ visitedDirs: Set<string>,
29
+ out: PendingAudioFile[],
30
+ ): Promise<void> {
31
+ if (options.maxScanDepth > 0 && depth >= options.maxScanDepth) return;
32
+
33
+ for await (const [name, entry] of dir.entries()) {
34
+ const rel = pathPrefix ? `${pathPrefix}/${name}` : name;
35
+ if (entry.kind === "directory") {
36
+ if (options.followSymlinks && visitedDirs.has(rel)) continue;
37
+ const nextVisited = options.followSymlinks
38
+ ? new Set(visitedDirs)
39
+ : visitedDirs;
40
+ if (options.followSymlinks) nextVisited.add(rel);
41
+ await collectPendingAudioFiles(
42
+ rootId,
43
+ rootDisplayName,
44
+ entry as FileSystemDirectoryHandle,
45
+ rel,
46
+ depth + 1,
47
+ options,
48
+ nextVisited,
49
+ out,
50
+ );
51
+ } else if (
52
+ entry.kind === "file" &&
53
+ isAudioFilename(name, options.enabledExtensions)
54
+ ) {
55
+ out.push({
56
+ rootId,
57
+ rootDisplayName,
58
+ relativePath: rel,
59
+ handle: entry as FileSystemFileHandle,
60
+ });
61
+ }
62
+ }
63
+ }
64
+
65
+ function basenameFromRelativePath(relativePath: string): string {
66
+ const i = relativePath.lastIndexOf("/");
67
+ return i >= 0 ? relativePath.slice(i + 1) : relativePath;
68
+ }
69
+
70
+ function folderAlbumFallback(
71
+ relativePath: string,
72
+ rootDisplayName: string,
73
+ ): string {
74
+ const segments = relativePath.split("/").filter(Boolean);
75
+ if (segments.length > 1) {
76
+ return segments[segments.length - 2] ?? rootDisplayName;
77
+ }
78
+ return rootDisplayName;
79
+ }
80
+
81
+ async function pendingFileToTrack(
82
+ p: PendingAudioFile,
83
+ enabledExtensions: ReadonlySet<string>,
84
+ ): Promise<Track> {
85
+ const fileName = basenameFromRelativePath(p.relativePath);
86
+ const fallbackTitle = stripAudioExtension(fileName, enabledExtensions);
87
+ const albumFolder = folderAlbumFallback(p.relativePath, p.rootDisplayName);
88
+ try {
89
+ const file = await p.handle.getFile();
90
+ const tags = await extractTagsFromAudioFile(file);
91
+ return {
92
+ id: `lib:${p.rootId}:${p.relativePath}`,
93
+ title: tags?.title ? tags.title : fallbackTitle,
94
+ artist: tags?.artist ? tags.artist : "Unknown artist",
95
+ album: tags?.album ? tags.album : albumFolder,
96
+ durationSec:
97
+ tags?.durationSec && tags.durationSec > 0 ? tags.durationSec : 0,
98
+ audioUrl: null,
99
+ library: { rootId: p.rootId, relativePath: p.relativePath },
100
+ };
101
+ } catch {
102
+ return {
103
+ id: `lib:${p.rootId}:${p.relativePath}`,
104
+ title: fallbackTitle,
105
+ artist: "Unknown artist",
106
+ album: albumFolder,
107
+ durationSec: 0,
108
+ audioUrl: null,
109
+ library: { rootId: p.rootId, relativePath: p.relativePath },
110
+ };
111
+ }
112
+ }
113
+
114
+ export type MetadataScanProgress = {
115
+ filesDone: number;
116
+ filesTotal: number;
117
+ };
118
+
119
+ export type DirectoryScanProgress =
120
+ | { phase: "walk" }
121
+ | { phase: "metadata"; filesDone: number; filesTotal: number };
122
+
123
+ /**
124
+ * Opens files in bounded parallel, parses tags, and returns `Track` rows sorted by path.
125
+ */
126
+ export async function buildTracksFromPending(
127
+ pending: readonly PendingAudioFile[],
128
+ enabledExtensions: ReadonlySet<string>,
129
+ onProgress?: (progress: MetadataScanProgress) => void,
130
+ ): Promise<Track[]> {
131
+ if (pending.length === 0) return [];
132
+ const tracks: Track[] = new Array(pending.length);
133
+ let nextIndex = 0;
134
+ let filesDone = 0;
135
+ const filesTotal = pending.length;
136
+
137
+ const report = (): void => {
138
+ onProgress?.({ filesDone, filesTotal });
139
+ };
140
+
141
+ report();
142
+
143
+ const worker = async (): Promise<void> => {
144
+ for (;;) {
145
+ const i = nextIndex++;
146
+ if (i >= pending.length) break;
147
+ tracks[i] = await pendingFileToTrack(
148
+ pending[i] as PendingAudioFile,
149
+ enabledExtensions,
150
+ );
151
+ filesDone += 1;
152
+ report();
153
+ }
154
+ };
155
+
156
+ const n = Math.min(METADATA_PARSE_CONCURRENCY, pending.length);
157
+ await Promise.all(Array.from({ length: n }, () => worker()));
158
+ return tracks;
159
+ }
160
+
161
+ /**
162
+ * Scans one library root: walk tree, read metadata per audio file, return tracks.
163
+ */
164
+ export async function scanDirectoryForTracks(
165
+ rootId: string,
166
+ rootDisplayName: string,
167
+ dir: FileSystemDirectoryHandle,
168
+ options: ScanTreeOptions,
169
+ onProgress?: (progress: DirectoryScanProgress) => void,
170
+ ): Promise<Track[]> {
171
+ onProgress?.({ phase: "walk" });
172
+ const pending: PendingAudioFile[] = [];
173
+ await collectPendingAudioFiles(
174
+ rootId,
175
+ rootDisplayName,
176
+ dir,
177
+ "",
178
+ 0,
179
+ options,
180
+ new Set<string>(),
181
+ pending,
182
+ );
183
+ onProgress?.({ phase: "metadata", filesDone: 0, filesTotal: pending.length });
184
+ return buildTracksFromPending(
185
+ pending,
186
+ options.enabledExtensions,
187
+ (metadata) => {
188
+ onProgress?.({ phase: "metadata", ...metadata });
189
+ },
190
+ );
191
+ }
@@ -0,0 +1,19 @@
1
+ import { LIBRARY_SCAN_PREFERENCES_STORAGE_KEY } from "@/lib/library/read-stored-library-scan-preferences";
2
+ import type { LibraryScanPreferences } from "@/types/library-scan-preferences";
3
+
4
+ /**
5
+ * Persists library scan preferences to localStorage.
6
+ */
7
+ export default function writeStoredLibraryScanPreferences(
8
+ prefs: LibraryScanPreferences,
9
+ ): void {
10
+ if (typeof window === "undefined") return;
11
+ try {
12
+ window.localStorage.setItem(
13
+ LIBRARY_SCAN_PREFERENCES_STORAGE_KEY,
14
+ JSON.stringify(prefs),
15
+ );
16
+ } catch {
17
+ /* ignore */
18
+ }
19
+ }
@@ -0,0 +1,47 @@
1
+ import type { Track } from "@/types/track";
2
+
3
+ /**
4
+ * Stand-in data until `GET /api/...` (or your route) returns real rows.
5
+ */
6
+ export const MOCK_PLAYLIST: readonly Track[] = [
7
+ {
8
+ id: "1",
9
+ title: "Northern Lights",
10
+ artist: "Aurora Fields",
11
+ album: "Polar Patterns",
12
+ durationSec: 214,
13
+ audioUrl: null,
14
+ },
15
+ {
16
+ id: "2",
17
+ title: "Basement Echo",
18
+ artist: "Mono City",
19
+ album: "Concrete Hymns",
20
+ durationSec: 189,
21
+ audioUrl: null,
22
+ },
23
+ {
24
+ id: "3",
25
+ title: "Paper Boats",
26
+ artist: "June & Tide",
27
+ album: "Harbor Demos",
28
+ durationSec: 241,
29
+ audioUrl: null,
30
+ },
31
+ {
32
+ id: "4",
33
+ title: "Static Bloom",
34
+ artist: "Velvet Wire",
35
+ album: "Lo-Fi Bloom",
36
+ durationSec: 198,
37
+ audioUrl: null,
38
+ },
39
+ {
40
+ id: "5",
41
+ title: "Slow Commute",
42
+ artist: "Transit Choir",
43
+ album: "Rush Hour B-Sides",
44
+ durationSec: 267,
45
+ audioUrl: null,
46
+ },
47
+ ];
@@ -0,0 +1,46 @@
1
+ import { escapeLuceneTerm } from "@/lib/musicbrainz/escape-lucene-term";
2
+
3
+ export type MusicBrainzSearchHints = {
4
+ artist?: string;
5
+ album?: string;
6
+ };
7
+
8
+ /**
9
+ * Build prioritized Lucene queries for a free-text MusicBrainz search.
10
+ */
11
+ export function buildMusicBrainzLuceneQueries(raw: string): {
12
+ queries: string[];
13
+ hints: MusicBrainzSearchHints;
14
+ } {
15
+ const trimmed = raw.trim();
16
+ if (!trimmed) return { queries: [], hints: {} };
17
+
18
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
19
+ const hints: MusicBrainzSearchHints = {};
20
+
21
+ if (tokens.length >= 2) {
22
+ const artist = escapeLuceneTerm(tokens[0]);
23
+ const albumPhrase = escapeLuceneTerm(tokens.slice(1).join(" "));
24
+ hints.artist = tokens[0];
25
+ hints.album = tokens.slice(1).join(" ");
26
+
27
+ const second = escapeLuceneTerm(tokens[1]);
28
+ const queries = [
29
+ `artist:${artist} AND release:${albumPhrase}`,
30
+ `artist:${artist} AND release-group:${albumPhrase}`,
31
+ `artist:${artist} AND releasegroup:${albumPhrase}`,
32
+ ];
33
+ if (tokens.length === 2) {
34
+ queries.push(`artist:${second} AND release:${artist}`);
35
+ }
36
+ queries.push(`"${escapeLuceneTerm(trimmed)}"`, trimmed);
37
+
38
+ return { hints, queries };
39
+ }
40
+
41
+ const single = escapeLuceneTerm(tokens[0]);
42
+ return {
43
+ hints: { artist: tokens[0] },
44
+ queries: [`artist:${single}`, trimmed],
45
+ };
46
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Escape a user term for MusicBrainz Lucene query syntax.
3
+ */
4
+ export function escapeLuceneTerm(term: string): string {
5
+ return term.replace(/([+\-!(){}[\]^"~*?:\\/])/g, "\\$1");
6
+ }
@@ -0,0 +1,55 @@
1
+ const MUSICBRAINZ_USER_AGENT =
2
+ "MuzicalUI/1.0 (https://github.com/f3rnox/muzical-ui)";
3
+
4
+ /**
5
+ * GET a MusicBrainz JSON API URL.
6
+ */
7
+ export async function fetchMusicBrainzJson<T>(
8
+ url: URL,
9
+ signal?: AbortSignal,
10
+ ): Promise<T> {
11
+ const urlString = url.toString();
12
+ let response: Response;
13
+ try {
14
+ response = await fetch(urlString, {
15
+ method: "GET",
16
+ headers: {
17
+ Accept: "application/json",
18
+ "User-Agent": MUSICBRAINZ_USER_AGENT,
19
+ },
20
+ signal,
21
+ });
22
+ } catch (error: unknown) {
23
+ if (
24
+ signal?.aborted ||
25
+ (error instanceof Error && error.name === "AbortError") ||
26
+ (typeof error === "object" &&
27
+ error !== null &&
28
+ "name" in error &&
29
+ error.name === "AbortError")
30
+ ) {
31
+ throw error;
32
+ }
33
+ console.error("[MusicBrainz] request failed", { url: urlString, error });
34
+ throw error;
35
+ }
36
+
37
+ if (!response.ok) {
38
+ let bodySnippet = "";
39
+ try {
40
+ bodySnippet = (await response.text()).slice(0, 300);
41
+ } catch {
42
+ /* ignore */
43
+ }
44
+ const message = `MusicBrainz request failed (${response.status})`;
45
+ console.error("[MusicBrainz] request failed", {
46
+ url: urlString,
47
+ status: response.status,
48
+ statusText: response.statusText,
49
+ body: bodySnippet || undefined,
50
+ });
51
+ throw new Error(message);
52
+ }
53
+
54
+ return (await response.json()) as T;
55
+ }
@@ -0,0 +1,53 @@
1
+ import { fetchMusicBrainzJson } from "@/lib/musicbrainz/fetch-musicbrainz-json";
2
+ import { musicBrainzRecordingToTrack } from "@/lib/musicbrainz/musicbrainz-recording-to-track";
3
+ import type { MusicBrainzReleaseDetail } from "@/lib/musicbrainz/types";
4
+ import type { Track } from "@/types/track";
5
+
6
+ /**
7
+ * Load all recordings on a MusicBrainz release as app tracks.
8
+ */
9
+ export async function fetchReleaseTracks(
10
+ releaseId: string,
11
+ albumTitle: string,
12
+ artistName: string,
13
+ signal?: AbortSignal,
14
+ ): Promise<Track[]> {
15
+ const url = new URL(`https://musicbrainz.org/ws/2/release/${releaseId}`);
16
+ url.searchParams.set("inc", "recordings+artist-credits+media");
17
+ url.searchParams.set("fmt", "json");
18
+
19
+ const body = await fetchMusicBrainzJson<MusicBrainzReleaseDetail>(
20
+ url,
21
+ signal,
22
+ );
23
+ const hints = { artist: artistName, album: albumTitle };
24
+ const out: Track[] = [];
25
+ const seen = new Set<string>();
26
+
27
+ for (const medium of body.media ?? []) {
28
+ for (const track of medium.tracks ?? []) {
29
+ const recording = track.recording;
30
+ const recordingId = recording?.id;
31
+ if (!recordingId || seen.has(recordingId)) continue;
32
+ seen.add(recordingId);
33
+
34
+ const merged = recording ?? {
35
+ id: track.id,
36
+ title: track.title,
37
+ length: track.length,
38
+ "artist-credit": body["artist-credit"],
39
+ };
40
+
41
+ const row = musicBrainzRecordingToTrack(merged, hints, albumTitle);
42
+ if (!row.title || row.title === "Unknown title") {
43
+ row.title = track.title?.trim() || row.title;
44
+ }
45
+ if (track.length && Number.isFinite(track.length)) {
46
+ row.durationSec = Math.round(track.length / 1000);
47
+ }
48
+ out.push(row);
49
+ }
50
+ }
51
+
52
+ return out;
53
+ }
@@ -0,0 +1,26 @@
1
+ import {
2
+ albumCompositeKey,
3
+ artistDisplayName,
4
+ } from "@/lib/library/favorite-keys";
5
+ import type { Track } from "@/types/track";
6
+
7
+ /**
8
+ * Group tracks by album title and artist (composite key).
9
+ */
10
+ export function groupTracksByAlbum(
11
+ tracks: readonly Track[],
12
+ ): Map<string, Track[]> {
13
+ const m = new Map<string, Track[]>();
14
+ for (const t of tracks) {
15
+ const key = albumCompositeKey(t.album, artistDisplayName(t.artist));
16
+ const arr = m.get(key) ?? [];
17
+ arr.push(t);
18
+ m.set(key, arr);
19
+ }
20
+ for (const arr of m.values()) {
21
+ arr.sort((a, b) =>
22
+ a.title.localeCompare(b.title, undefined, { sensitivity: "base" }),
23
+ );
24
+ }
25
+ return m;
26
+ }
@@ -0,0 +1,23 @@
1
+ import { artistDisplayName } from "@/lib/library/favorite-keys";
2
+ import type { Track } from "@/types/track";
3
+
4
+ /**
5
+ * Group MusicBrainz (or other) tracks by display artist name.
6
+ */
7
+ export function groupTracksByArtist(
8
+ tracks: readonly Track[],
9
+ ): Map<string, Track[]> {
10
+ const m = new Map<string, Track[]>();
11
+ for (const t of tracks) {
12
+ const a = artistDisplayName(t.artist);
13
+ const arr = m.get(a) ?? [];
14
+ arr.push(t);
15
+ m.set(a, arr);
16
+ }
17
+ for (const arr of m.values()) {
18
+ arr.sort((a, b) =>
19
+ a.title.localeCompare(b.title, undefined, { sensitivity: "base" }),
20
+ );
21
+ }
22
+ return m;
23
+ }
@@ -0,0 +1,16 @@
1
+ import type { Track } from "@/types/track";
2
+
3
+ /**
4
+ * Append tracks without duplicate ids.
5
+ */
6
+ export function mergeTracksById(
7
+ target: Track[],
8
+ incoming: readonly Track[],
9
+ ): void {
10
+ const seen = new Set(target.map((t) => t.id));
11
+ for (const t of incoming) {
12
+ if (seen.has(t.id)) continue;
13
+ seen.add(t.id);
14
+ target.push(t);
15
+ }
16
+ }
@@ -0,0 +1,42 @@
1
+ import type { MusicBrainzSearchHints } from "@/lib/musicbrainz/build-musicbrainz-lucene-queries";
2
+ import { pickPreferredRelease } from "@/lib/musicbrainz/pick-preferred-release";
3
+ import type { MusicBrainzRecording } from "@/lib/musicbrainz/types";
4
+ import type { Track } from "@/types/track";
5
+
6
+ /**
7
+ * Map a MusicBrainz recording entity to an app track.
8
+ */
9
+ export function musicBrainzRecordingToTrack(
10
+ recording: MusicBrainzRecording,
11
+ hints: MusicBrainzSearchHints = {},
12
+ albumOverride?: string,
13
+ ): Track {
14
+ const artist =
15
+ recording["artist-credit"]
16
+ ?.map((credit) => credit.name?.trim() || credit.artist?.name?.trim())
17
+ .filter(Boolean)
18
+ .join(" & ") ?? "Unknown artist";
19
+
20
+ const release = pickPreferredRelease(recording.releases, hints);
21
+ const album =
22
+ albumOverride?.trim() ||
23
+ release?.["release-group"]?.title?.trim() ||
24
+ release?.title?.trim() ||
25
+ "Unknown album";
26
+
27
+ const title = recording.title?.trim() || "Unknown title";
28
+ const youtubeQuery = `${title} ${artist}`.trim();
29
+
30
+ return {
31
+ id: `musicbrainz:${recording.id}`,
32
+ title,
33
+ artist,
34
+ album,
35
+ durationSec:
36
+ recording.length && Number.isFinite(recording.length)
37
+ ? Math.round(recording.length / 1000)
38
+ : 0,
39
+ source: "musicbrainz",
40
+ youtubeQuery,
41
+ };
42
+ }
@@ -0,0 +1,32 @@
1
+ import type { MusicBrainzReleaseRef } from "@/lib/musicbrainz/types";
2
+ import type { MusicBrainzSearchHints } from "@/lib/musicbrainz/build-musicbrainz-lucene-queries";
3
+
4
+ /**
5
+ * Choose the release entry that best matches search hints (album/artist).
6
+ */
7
+ export function pickPreferredRelease(
8
+ releases: readonly MusicBrainzReleaseRef[] | undefined,
9
+ hints: MusicBrainzSearchHints,
10
+ ): MusicBrainzReleaseRef | undefined {
11
+ if (!releases?.length) return undefined;
12
+
13
+ const albumHint = hints.album?.trim().toLowerCase();
14
+ if (albumHint) {
15
+ const exact = releases.find((r) => {
16
+ const title = r.title?.trim().toLowerCase() ?? "";
17
+ const rgTitle = r["release-group"]?.title?.trim().toLowerCase() ?? "";
18
+ return title === albumHint || rgTitle === albumHint;
19
+ });
20
+ if (exact) return exact;
21
+
22
+ const partial = releases.find((r) => {
23
+ const title = r.title?.trim().toLowerCase() ?? "";
24
+ const rgTitle = r["release-group"]?.title?.trim().toLowerCase() ?? "";
25
+ return title.includes(albumHint) || rgTitle.includes(albumHint);
26
+ });
27
+ if (partial) return partial;
28
+ }
29
+
30
+ const official = releases.find((r) => r.status === "Official");
31
+ return official ?? releases[0];
32
+ }
@@ -0,0 +1,12 @@
1
+ import type { MusicBrainzReleaseGroup } from "@/lib/musicbrainz/types";
2
+
3
+ /**
4
+ * Pick a representative release id from a release group search hit.
5
+ */
6
+ export function pickReleaseGroupReleaseId(
7
+ group: MusicBrainzReleaseGroup,
8
+ ): string | null {
9
+ const releases = group.releases ?? [];
10
+ const official = releases.find((r) => r.status === "Official");
11
+ return official?.id ?? releases[0]?.id ?? null;
12
+ }
@@ -0,0 +1,13 @@
1
+ import type { MusicBrainzReleaseGroup } from "@/lib/musicbrainz/types";
2
+
3
+ /**
4
+ * Display artist for a release group.
5
+ */
6
+ export function releaseGroupArtistName(group: MusicBrainzReleaseGroup): string {
7
+ return (
8
+ group["artist-credit"]
9
+ ?.map((c) => c.name?.trim() || c.artist?.name?.trim())
10
+ .filter(Boolean)
11
+ .join(" & ") ?? "Unknown artist"
12
+ );
13
+ }