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,91 @@
1
+ import type { LibraryRootMeta } from "@/types/library-root-meta";
2
+ import type { ScanProgressTick } from "@/lib/library/scan-progress-tick";
3
+ import { scanDirectoryForTracks } from "@/lib/library/scan-tree";
4
+ import type { ScanTreeOptions } from "@/types/scan-tree-options";
5
+ import type { Track } from "@/types/track";
6
+
7
+ export type CollectTracksResult = {
8
+ tracks: Track[];
9
+ failedRootCount: number;
10
+ firstError: string | null;
11
+ };
12
+
13
+ function scanFailureMessage(e: unknown): string {
14
+ if (e instanceof DOMException) {
15
+ return `${e.name}: ${e.message}`;
16
+ }
17
+ if (e instanceof Error) {
18
+ return e.message;
19
+ }
20
+ return String(e);
21
+ }
22
+
23
+ /**
24
+ * Scans every configured library root and merges track rows in stable order.
25
+ */
26
+ export async function collectTracksForMeta(
27
+ meta: readonly LibraryRootMeta[],
28
+ handles: ReadonlyMap<string, FileSystemDirectoryHandle>,
29
+ scanOptions: ScanTreeOptions,
30
+ onProgress?: (tick: ScanProgressTick) => void,
31
+ ): Promise<CollectTracksResult> {
32
+ const list: Track[] = [];
33
+ let failedRootCount = 0;
34
+ let firstError: string | null = null;
35
+ const roots = meta.filter((r) => handles.has(r.id));
36
+ const rootCount = roots.length;
37
+
38
+ for (let rootIndex = 0; rootIndex < roots.length; rootIndex++) {
39
+ const r = roots[rootIndex];
40
+ const h = handles.get(r.id);
41
+ if (!h) continue;
42
+ const emit = (
43
+ partial: Pick<ScanProgressTick, "phase" | "filesDone" | "filesTotal">,
44
+ ): void => {
45
+ onProgress?.({
46
+ rootIndex,
47
+ rootCount,
48
+ rootName: r.name,
49
+ phase: partial.phase,
50
+ filesDone: partial.filesDone,
51
+ filesTotal: partial.filesTotal,
52
+ });
53
+ };
54
+ emit({ phase: "walk" });
55
+ try {
56
+ const chunk = await scanDirectoryForTracks(
57
+ r.id,
58
+ r.name,
59
+ h,
60
+ scanOptions,
61
+ (inner) => {
62
+ if (inner.phase === "walk") {
63
+ emit({ phase: "walk" });
64
+ } else {
65
+ emit({
66
+ phase: "metadata",
67
+ filesDone: inner.filesDone,
68
+ filesTotal: inner.filesTotal,
69
+ });
70
+ }
71
+ },
72
+ );
73
+ list.push(...chunk);
74
+ } catch (e) {
75
+ failedRootCount += 1;
76
+ if (!firstError) firstError = scanFailureMessage(e);
77
+ }
78
+ }
79
+
80
+ list.sort((a, b) => {
81
+ const ra = a.library?.rootId ?? "";
82
+ const rb = b.library?.rootId ?? "";
83
+ const c = ra.localeCompare(rb, undefined, { sensitivity: "base" });
84
+ if (c !== 0) return c;
85
+ const pa = a.library?.relativePath ?? a.title;
86
+ const pb = b.library?.relativePath ?? b.title;
87
+ return pa.localeCompare(pb, undefined, { sensitivity: "base" });
88
+ });
89
+
90
+ return { tracks: list, failedRootCount, firstError };
91
+ }
@@ -0,0 +1,37 @@
1
+ import {
2
+ albumCompositeKey,
3
+ artistDisplayName,
4
+ } from "@/lib/library/favorite-keys";
5
+ import type { Track } from "@/types/track";
6
+
7
+ export type LibraryStats = {
8
+ trackCount: number;
9
+ folderCount: number;
10
+ artistCount: number;
11
+ albumCount: number;
12
+ totalDurationSec: number;
13
+ };
14
+
15
+ /**
16
+ * Derives aggregate library counts from the scanned track catalog.
17
+ */
18
+ export default function computeLibraryStats(
19
+ tracks: readonly Track[],
20
+ folderCount: number,
21
+ ): LibraryStats {
22
+ const artists = new Set<string>();
23
+ const albums = new Set<string>();
24
+ let totalDurationSec = 0;
25
+ for (const t of tracks) {
26
+ artists.add(artistDisplayName(t.artist));
27
+ albums.add(albumCompositeKey(t.album, t.artist));
28
+ if (t.durationSec > 0) totalDurationSec += t.durationSec;
29
+ }
30
+ return {
31
+ trackCount: tracks.length,
32
+ folderCount,
33
+ artistCount: artists.size,
34
+ albumCount: albums.size,
35
+ totalDurationSec,
36
+ };
37
+ }
@@ -0,0 +1,27 @@
1
+ /** IndexedDB database name for saved library folder handles */
2
+ export const LIBRARY_DB_NAME = "muzical-library";
3
+
4
+ /** Object store for configured roots (includes directory handles) */
5
+ export const LIBRARY_STORE_NAME = "libraryRoots";
6
+
7
+ /** Object store for last successful scan snapshot (metadata only, no handles) */
8
+ export const LIBRARY_CATALOG_STORE_NAME = "libraryCatalog";
9
+
10
+ /** DB schema version */
11
+ export const LIBRARY_DB_VERSION = 3;
12
+
13
+ /** Lowercase extensions including dot */
14
+ export const LIBRARY_AUDIO_EXTENSIONS: readonly string[] = [
15
+ ".mp3",
16
+ ".flac",
17
+ ".m4a",
18
+ ".aac",
19
+ ".ogg",
20
+ ".opus",
21
+ ".wav",
22
+ ".webm",
23
+ ".aiff",
24
+ ".aif",
25
+ ".alac",
26
+ ".wma",
27
+ ];
@@ -0,0 +1,59 @@
1
+ import type { ExtractedCoverBytes } from "@/lib/library/extract-cover-bytes-from-audio-file";
2
+ import { extractCoverBytesFromAudioFile } from "@/lib/library/extract-cover-bytes-from-audio-file";
3
+
4
+ const COVER_BYTES_CACHE_MAX = 48;
5
+
6
+ type CacheEntry = {
7
+ bytes: ExtractedCoverBytes;
8
+ lastUsedAt: number;
9
+ };
10
+
11
+ const cache = new Map<string, CacheEntry>();
12
+ const pendingByTrackId = new Map<string, Promise<ExtractedCoverBytes | null>>();
13
+
14
+ function touchEntry(trackId: string, entry: CacheEntry): void {
15
+ cache.delete(trackId);
16
+ cache.set(trackId, entry);
17
+ }
18
+
19
+ function evictIfNeeded(): void {
20
+ while (cache.size > COVER_BYTES_CACHE_MAX) {
21
+ const oldestKey = cache.keys().next().value as string | undefined;
22
+ if (!oldestKey) return;
23
+ cache.delete(oldestKey);
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Returns embedded cover bytes for a track, extracting on first use and caching in-memory.
29
+ */
30
+ export async function getCoverBytesForTrack(
31
+ trackId: string,
32
+ file: File,
33
+ ): Promise<ExtractedCoverBytes | null> {
34
+ const cached = cache.get(trackId);
35
+ if (cached) {
36
+ touchEntry(trackId, { ...cached, lastUsedAt: Date.now() });
37
+ return cached.bytes;
38
+ }
39
+
40
+ const pending = pendingByTrackId.get(trackId);
41
+ if (pending) return pending;
42
+
43
+ const p = (async (): Promise<ExtractedCoverBytes | null> => {
44
+ const bytes = await extractCoverBytesFromAudioFile(file);
45
+ if (bytes) {
46
+ const entry: CacheEntry = { bytes, lastUsedAt: Date.now() };
47
+ cache.set(trackId, entry);
48
+ evictIfNeeded();
49
+ }
50
+ return bytes;
51
+ })();
52
+
53
+ pendingByTrackId.set(trackId, p);
54
+ try {
55
+ return await p;
56
+ } finally {
57
+ pendingByTrackId.delete(trackId);
58
+ }
59
+ }
@@ -0,0 +1,13 @@
1
+ import { LIBRARY_AUDIO_EXTENSIONS } from "@/lib/library/constants";
2
+ import type { LibraryScanPreferences } from "@/types/library-scan-preferences";
3
+
4
+ /**
5
+ * Default scan preferences: all extensions, unlimited depth, no symlink follow.
6
+ */
7
+ export default function defaultLibraryScanPreferences(): LibraryScanPreferences {
8
+ return {
9
+ maxScanDepth: 0,
10
+ followSymlinks: false,
11
+ enabledExtensions: [...LIBRARY_AUDIO_EXTENSIONS],
12
+ };
13
+ }
@@ -0,0 +1,41 @@
1
+ import { parseBlob } from "music-metadata";
2
+
3
+ export type ExtractedCoverBytes = {
4
+ mime: string;
5
+ /** Image bytes (no object URL) for efficient reuse */
6
+ data: Uint8Array<ArrayBuffer>;
7
+ };
8
+
9
+ /**
10
+ * Extracts embedded cover art from an audio `File`.
11
+ * Returns raw image bytes; callers can create object URLs and should revoke them.
12
+ */
13
+ export async function extractCoverBytesFromAudioFile(
14
+ file: File,
15
+ ): Promise<ExtractedCoverBytes | null> {
16
+ try {
17
+ const meta = await parseBlob(file, { duration: false });
18
+ const pictures = meta.common.picture;
19
+ if (!pictures?.length) return null;
20
+
21
+ const front = pictures.find((p) => {
22
+ const t = p.type?.toLowerCase() ?? "";
23
+ return t.includes("front") || t.includes("cover");
24
+ });
25
+ const chosen =
26
+ front ??
27
+ pictures.reduce((a, b) => (b.data.length > a.data.length ? b : a));
28
+ const mime =
29
+ chosen.format?.trim() ||
30
+ (chosen.name?.toLowerCase().endsWith(".png")
31
+ ? "image/png"
32
+ : "image/jpeg");
33
+
34
+ return {
35
+ mime,
36
+ data: Uint8Array.from(chosen.data),
37
+ };
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
@@ -0,0 +1,31 @@
1
+ import { parseBlob } from 'music-metadata'
2
+
3
+ /**
4
+ * Parses embedded artwork from an audio file and returns an object URL for the best
5
+ * candidate image. The caller must `URL.revokeObjectURL` when the URL is no longer needed.
6
+ */
7
+ export async function extractCoverObjectUrlFromAudioFile(
8
+ file: File,
9
+ ): Promise<string | null> {
10
+ try {
11
+ const meta = await parseBlob(file, { duration: false })
12
+ const pictures = meta.common.picture
13
+ if (!pictures?.length) return null
14
+
15
+ const front = pictures.find((p) => {
16
+ const t = p.type?.toLowerCase() ?? ''
17
+ return t.includes('front') || t.includes('cover')
18
+ })
19
+ const chosen = front ?? pictures.reduce((a, b) =>
20
+ b.data.length > a.data.length ? b : a,
21
+ )
22
+
23
+ const mime =
24
+ chosen.format?.trim() ||
25
+ (chosen.name?.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg')
26
+ const blob = new Blob([new Uint8Array(chosen.data)], { type: mime })
27
+ return URL.createObjectURL(blob)
28
+ } catch {
29
+ return null
30
+ }
31
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Stable artist label for favorites and grouping (matches library browser).
3
+ */
4
+ export function artistDisplayName(artist: string | undefined): string {
5
+ const t = artist?.trim();
6
+ return t ? t : "Unknown artist";
7
+ }
8
+
9
+ /**
10
+ * Album + artist composite key (matches `groupByAlbum` in the library browser).
11
+ */
12
+ export function albumCompositeKey(album: string, artist: string): string {
13
+ return `${album}\u0000${artist}`;
14
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Maps File System Access / Security errors to a short, actionable UI string.
3
+ */
4
+ export function formatFsAccessErrorMessage(e: unknown): string {
5
+ if (e instanceof DOMException) {
6
+ if (e.name === "AbortError") {
7
+ return "";
8
+ }
9
+ if (
10
+ e.name === "SecurityError" ||
11
+ e.name === "NotAllowedError" ||
12
+ e.message.includes("not allowed by the user agent") ||
13
+ e.message.includes("The request is not allowed")
14
+ ) {
15
+ return "Folder access is blocked in this context. Open the app in Chrome or Edge in a normal window (not an embedded preview), and use https:// or http://localhost.";
16
+ }
17
+ }
18
+ if (e instanceof Error) {
19
+ const m = e.message;
20
+ if (
21
+ m.includes("not allowed by the user agent") ||
22
+ m.includes("The request is not allowed")
23
+ ) {
24
+ return "Folder access is blocked in this context. Open the app in Chrome or Edge in a normal window (not an embedded preview), and use https:// or http://localhost.";
25
+ }
26
+ return m;
27
+ }
28
+ return "Could not access the folder.";
29
+ }
@@ -0,0 +1,270 @@
1
+ import {
2
+ LIBRARY_CATALOG_STORE_NAME,
3
+ LIBRARY_DB_NAME,
4
+ LIBRARY_DB_VERSION,
5
+ LIBRARY_STORE_NAME,
6
+ } from "@/lib/library/constants";
7
+ import type { Track } from "@/types/track";
8
+
9
+ export type StoredLibraryRoot = {
10
+ id: string;
11
+ name: string;
12
+ addedAt: number;
13
+ handle: FileSystemDirectoryHandle;
14
+ };
15
+
16
+ const CATALOG_KEY = "catalog" as const;
17
+ const FAVORITES_KEY = "favorites" as const;
18
+
19
+ export type StoredLibraryCatalog = {
20
+ key: typeof CATALOG_KEY;
21
+ /** Sorted root ids — must match configured folders for this snapshot to apply */
22
+ rootIds: string[];
23
+ tracks: Track[];
24
+ savedAt: number;
25
+ };
26
+
27
+ export type StoredFavorites = {
28
+ key: typeof FAVORITES_KEY;
29
+ songIds: string[];
30
+ artistNames: string[];
31
+ albumKeys: string[];
32
+ };
33
+
34
+ /**
35
+ * Opens the library IndexedDB, creating the object store on upgrade.
36
+ */
37
+ export function openLibraryDb(): Promise<IDBDatabase> {
38
+ return new Promise((resolve, reject) => {
39
+ const req = indexedDB.open(LIBRARY_DB_NAME, LIBRARY_DB_VERSION);
40
+ req.onerror = (): void => {
41
+ reject(req.error ?? new Error("IndexedDB open failed"));
42
+ };
43
+ req.onsuccess = (): void => {
44
+ resolve(req.result);
45
+ };
46
+ req.onupgradeneeded = (): void => {
47
+ const db = req.result;
48
+ if (!db.objectStoreNames.contains(LIBRARY_STORE_NAME)) {
49
+ db.createObjectStore(LIBRARY_STORE_NAME, { keyPath: "id" });
50
+ }
51
+ if (!db.objectStoreNames.contains(LIBRARY_CATALOG_STORE_NAME)) {
52
+ db.createObjectStore(LIBRARY_CATALOG_STORE_NAME, { keyPath: "key" });
53
+ }
54
+ };
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Persists a library root (directory handle is stored via structured clone).
60
+ */
61
+ export function idbPutRoot(
62
+ db: IDBDatabase,
63
+ root: StoredLibraryRoot,
64
+ ): Promise<void> {
65
+ return new Promise((resolve, reject) => {
66
+ const tx = db.transaction(LIBRARY_STORE_NAME, "readwrite");
67
+ const store = tx.objectStore(LIBRARY_STORE_NAME);
68
+ const putReq = store.put(root);
69
+ putReq.onerror = (): void => {
70
+ reject(putReq.error ?? new Error("IndexedDB put failed"));
71
+ };
72
+ tx.oncomplete = (): void => {
73
+ resolve();
74
+ };
75
+ tx.onerror = (): void => {
76
+ reject(tx.error ?? new Error("IndexedDB transaction failed"));
77
+ };
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Deletes a library root by id.
83
+ */
84
+ export function idbDeleteRoot(db: IDBDatabase, id: string): Promise<void> {
85
+ return new Promise((resolve, reject) => {
86
+ const tx = db.transaction(LIBRARY_STORE_NAME, "readwrite");
87
+ const store = tx.objectStore(LIBRARY_STORE_NAME);
88
+ const delReq = store.delete(id);
89
+ delReq.onerror = (): void => {
90
+ reject(delReq.error ?? new Error("IndexedDB delete failed"));
91
+ };
92
+ tx.oncomplete = (): void => {
93
+ resolve();
94
+ };
95
+ tx.onerror = (): void => {
96
+ reject(tx.error ?? new Error("IndexedDB transaction failed"));
97
+ };
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Loads all configured library roots from IndexedDB.
103
+ */
104
+ export function idbGetAllRoots(db: IDBDatabase): Promise<StoredLibraryRoot[]> {
105
+ return new Promise((resolve, reject) => {
106
+ const tx = db.transaction(LIBRARY_STORE_NAME, "readonly");
107
+ const store = tx.objectStore(LIBRARY_STORE_NAME);
108
+ const getReq = store.getAll();
109
+ getReq.onerror = (): void => {
110
+ reject(getReq.error ?? new Error("IndexedDB getAll failed"));
111
+ };
112
+ getReq.onsuccess = (): void => {
113
+ resolve(getReq.result as StoredLibraryRoot[]);
114
+ };
115
+ });
116
+ }
117
+
118
+ function sortedRootIds(ids: readonly string[]): string[] {
119
+ return [...ids].sort((a, b) =>
120
+ a.localeCompare(b, undefined, { sensitivity: "base" }),
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Saves the last library scan snapshot (track metadata + paths) for faster reload.
126
+ */
127
+ export function idbPutCatalog(
128
+ db: IDBDatabase,
129
+ rootIds: readonly string[],
130
+ tracks: readonly Track[],
131
+ ): Promise<void> {
132
+ if (!db.objectStoreNames.contains(LIBRARY_CATALOG_STORE_NAME)) {
133
+ return Promise.reject(
134
+ new Error(`Missing object store ${LIBRARY_CATALOG_STORE_NAME}`),
135
+ );
136
+ }
137
+ const row: StoredLibraryCatalog = {
138
+ key: CATALOG_KEY,
139
+ rootIds: sortedRootIds(rootIds),
140
+ tracks: [...tracks],
141
+ savedAt: Date.now(),
142
+ };
143
+ return new Promise((resolve, reject) => {
144
+ const tx = db.transaction(LIBRARY_CATALOG_STORE_NAME, "readwrite");
145
+ const store = tx.objectStore(LIBRARY_CATALOG_STORE_NAME);
146
+ const putReq = store.put(row);
147
+ putReq.onerror = (): void => {
148
+ reject(putReq.error ?? new Error("IndexedDB catalog put failed"));
149
+ };
150
+ tx.oncomplete = (): void => {
151
+ resolve();
152
+ };
153
+ tx.onerror = (): void => {
154
+ reject(tx.error ?? new Error("IndexedDB catalog transaction failed"));
155
+ };
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Loads the persisted catalog snapshot, if any.
161
+ */
162
+ export function idbGetCatalog(
163
+ db: IDBDatabase,
164
+ ): Promise<StoredLibraryCatalog | null> {
165
+ if (!db.objectStoreNames.contains(LIBRARY_CATALOG_STORE_NAME)) {
166
+ return Promise.resolve(null);
167
+ }
168
+ return new Promise((resolve, reject) => {
169
+ const tx = db.transaction(LIBRARY_CATALOG_STORE_NAME, "readonly");
170
+ const store = tx.objectStore(LIBRARY_CATALOG_STORE_NAME);
171
+ const getReq = store.get(CATALOG_KEY);
172
+ getReq.onerror = (): void => {
173
+ reject(getReq.error ?? new Error("IndexedDB catalog get failed"));
174
+ };
175
+ getReq.onsuccess = (): void => {
176
+ const raw = getReq.result as StoredLibraryCatalog | undefined;
177
+ if (
178
+ !raw ||
179
+ raw.key !== CATALOG_KEY ||
180
+ !Array.isArray(raw.rootIds) ||
181
+ !Array.isArray(raw.tracks)
182
+ ) {
183
+ resolve(null);
184
+ return;
185
+ }
186
+ resolve(raw);
187
+ };
188
+ });
189
+ }
190
+
191
+ const defaultFavorites = (): StoredFavorites => ({
192
+ key: FAVORITES_KEY,
193
+ songIds: [],
194
+ artistNames: [],
195
+ albumKeys: [],
196
+ });
197
+
198
+ /**
199
+ * Loads saved favorites (songs by track id, artists by display name, albums by composite key).
200
+ */
201
+ export function idbGetFavorites(db: IDBDatabase): Promise<StoredFavorites> {
202
+ if (!db.objectStoreNames.contains(LIBRARY_CATALOG_STORE_NAME)) {
203
+ return Promise.resolve(defaultFavorites());
204
+ }
205
+ return new Promise((resolve, reject) => {
206
+ const tx = db.transaction(LIBRARY_CATALOG_STORE_NAME, "readonly");
207
+ const store = tx.objectStore(LIBRARY_CATALOG_STORE_NAME);
208
+ const getReq = store.get(FAVORITES_KEY);
209
+ getReq.onerror = (): void => {
210
+ reject(getReq.error ?? new Error("IndexedDB favorites get failed"));
211
+ };
212
+ getReq.onsuccess = (): void => {
213
+ const raw = getReq.result as StoredFavorites | undefined;
214
+ if (
215
+ !raw ||
216
+ raw.key !== FAVORITES_KEY ||
217
+ !Array.isArray(raw.songIds) ||
218
+ !Array.isArray(raw.artistNames) ||
219
+ !Array.isArray(raw.albumKeys)
220
+ ) {
221
+ resolve(defaultFavorites());
222
+ return;
223
+ }
224
+ resolve({
225
+ key: FAVORITES_KEY,
226
+ songIds: [...raw.songIds],
227
+ artistNames: [...raw.artistNames],
228
+ albumKeys: [...raw.albumKeys],
229
+ });
230
+ };
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Persists favorites in the same object store as the catalog (separate key).
236
+ */
237
+ export function idbPutFavorites(
238
+ db: IDBDatabase,
239
+ data: {
240
+ songIds: readonly string[];
241
+ artistNames: readonly string[];
242
+ albumKeys: readonly string[];
243
+ },
244
+ ): Promise<void> {
245
+ if (!db.objectStoreNames.contains(LIBRARY_CATALOG_STORE_NAME)) {
246
+ return Promise.reject(
247
+ new Error(`Missing object store ${LIBRARY_CATALOG_STORE_NAME}`),
248
+ );
249
+ }
250
+ const payload: StoredFavorites = {
251
+ key: FAVORITES_KEY,
252
+ songIds: [...data.songIds],
253
+ artistNames: [...data.artistNames],
254
+ albumKeys: [...data.albumKeys],
255
+ };
256
+ return new Promise((resolve, reject) => {
257
+ const tx = db.transaction(LIBRARY_CATALOG_STORE_NAME, "readwrite");
258
+ const store = tx.objectStore(LIBRARY_CATALOG_STORE_NAME);
259
+ const putReq = store.put(payload);
260
+ putReq.onerror = (): void => {
261
+ reject(putReq.error ?? new Error("IndexedDB favorites put failed"));
262
+ };
263
+ tx.oncomplete = (): void => {
264
+ resolve();
265
+ };
266
+ tx.onerror = (): void => {
267
+ reject(tx.error ?? new Error("IndexedDB favorites transaction failed"));
268
+ };
269
+ });
270
+ }
@@ -0,0 +1,34 @@
1
+ import { parseBlob } from "music-metadata";
2
+
3
+ /** Normalized tags used to build `Track` rows for the library. */
4
+ export type ExtractedAudioTags = {
5
+ title: string;
6
+ artist: string;
7
+ album: string;
8
+ durationSec: number;
9
+ };
10
+
11
+ /**
12
+ * Reads ID3/Vorbis/etc. tags and duration from an audio `File` (browser-safe `parseBlob`).
13
+ */
14
+ export async function extractTagsFromAudioFile(
15
+ file: File,
16
+ ): Promise<ExtractedAudioTags | null> {
17
+ try {
18
+ const meta = await parseBlob(file, { duration: true });
19
+ const title = meta.common.title?.trim() ?? "";
20
+ const artist =
21
+ meta.common.artist?.trim() ||
22
+ meta.common.artists?.[0]?.trim() ||
23
+ meta.common.albumartist?.trim() ||
24
+ meta.common.albumartists?.[0]?.trim() ||
25
+ "";
26
+ const album = meta.common.album?.trim() ?? "";
27
+ const d = meta.format.duration;
28
+ const durationSec =
29
+ typeof d === "number" && Number.isFinite(d) ? Math.round(d) : 0;
30
+ return { title, artist, album, durationSec };
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
@@ -0,0 +1,43 @@
1
+ import { LIBRARY_AUDIO_EXTENSIONS } from "@/lib/library/constants";
2
+ import defaultLibraryScanPreferences from "@/lib/library/default-library-scan-preferences";
3
+ import type { LibraryScanPreferences } from "@/types/library-scan-preferences";
4
+
5
+ const STORAGE_KEY = "muzical.libraryScanPreferences";
6
+
7
+ const allowedExt = new Set<string>(LIBRARY_AUDIO_EXTENSIONS);
8
+
9
+ /**
10
+ * Loads library scan preferences from localStorage with validation.
11
+ */
12
+ export default function readStoredLibraryScanPreferences(): LibraryScanPreferences {
13
+ const defaults = defaultLibraryScanPreferences();
14
+ if (typeof window === "undefined") return defaults;
15
+ try {
16
+ const raw = window.localStorage.getItem(STORAGE_KEY);
17
+ if (!raw) return defaults;
18
+ const parsed: unknown = JSON.parse(raw);
19
+ if (!parsed || typeof parsed !== "object") return defaults;
20
+ const o = parsed as Record<string, unknown>;
21
+ const maxScanDepth =
22
+ typeof o.maxScanDepth === "number" &&
23
+ Number.isFinite(o.maxScanDepth) &&
24
+ o.maxScanDepth >= 0
25
+ ? Math.floor(o.maxScanDepth)
26
+ : defaults.maxScanDepth;
27
+ const followSymlinks = o.followSymlinks === true;
28
+ let enabledExtensions = defaults.enabledExtensions;
29
+ if (Array.isArray(o.enabledExtensions)) {
30
+ const filtered = o.enabledExtensions.filter(
31
+ (x): x is string =>
32
+ typeof x === "string" && allowedExt.has(x.toLowerCase()),
33
+ );
34
+ if (filtered.length > 0)
35
+ enabledExtensions = [...new Set(filtered.map((x) => x.toLowerCase()))];
36
+ }
37
+ return { maxScanDepth, followSymlinks, enabledExtensions };
38
+ } catch {
39
+ return defaults;
40
+ }
41
+ }
42
+
43
+ export { STORAGE_KEY as LIBRARY_SCAN_PREFERENCES_STORAGE_KEY };