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,33 @@
1
+ import { fetchMusicBrainzJson } from "@/lib/musicbrainz/fetch-musicbrainz-json";
2
+ import { musicBrainzRecordingToTrack } from "@/lib/musicbrainz/musicbrainz-recording-to-track";
3
+ import type { MusicBrainzSearchHints } from "@/lib/musicbrainz/build-musicbrainz-lucene-queries";
4
+ import type { MusicBrainzRecording } from "@/lib/musicbrainz/types";
5
+ import type { Track } from "@/types/track";
6
+
7
+ /**
8
+ * Search MusicBrainz recordings with a Lucene query.
9
+ */
10
+ export async function searchMusicBrainzRecordingsQuery(
11
+ luceneQuery: string,
12
+ hints: MusicBrainzSearchHints,
13
+ signal?: AbortSignal,
14
+ limit = 25,
15
+ ): Promise<Track[]> {
16
+ const trimmed = luceneQuery.trim();
17
+ if (!trimmed) return [];
18
+
19
+ const url = new URL("https://musicbrainz.org/ws/2/recording");
20
+ url.searchParams.set("query", trimmed);
21
+ url.searchParams.set("fmt", "json");
22
+ url.searchParams.set("limit", String(limit));
23
+ url.searchParams.set("inc", "artist-credits+releases");
24
+
25
+ const body = await fetchMusicBrainzJson<{
26
+ recordings?: MusicBrainzRecording[];
27
+ }>(url, signal);
28
+ const recordings = Array.isArray(body.recordings) ? body.recordings : [];
29
+
30
+ return recordings.map((recording) =>
31
+ musicBrainzRecordingToTrack(recording, hints),
32
+ );
33
+ }
@@ -0,0 +1,24 @@
1
+ import { fetchMusicBrainzJson } from "@/lib/musicbrainz/fetch-musicbrainz-json";
2
+ import type { MusicBrainzReleaseGroup } from "@/lib/musicbrainz/types";
3
+
4
+ /**
5
+ * Search MusicBrainz release groups (albums, EPs, etc.).
6
+ */
7
+ export async function searchMusicBrainzReleaseGroups(
8
+ query: string,
9
+ signal?: AbortSignal,
10
+ limit = 8,
11
+ ): Promise<MusicBrainzReleaseGroup[]> {
12
+ const trimmed = query.trim();
13
+ if (!trimmed) return [];
14
+
15
+ const url = new URL("https://musicbrainz.org/ws/2/release-group");
16
+ url.searchParams.set("query", trimmed);
17
+ url.searchParams.set("fmt", "json");
18
+ url.searchParams.set("limit", String(limit));
19
+
20
+ const body = await fetchMusicBrainzJson<{
21
+ "release-groups"?: MusicBrainzReleaseGroup[];
22
+ }>(url, signal);
23
+ return Array.isArray(body["release-groups"]) ? body["release-groups"] : [];
24
+ }
@@ -0,0 +1,65 @@
1
+ import { buildMusicBrainzLuceneQueries } from "@/lib/musicbrainz/build-musicbrainz-lucene-queries";
2
+ import { fetchReleaseTracks } from "@/lib/musicbrainz/fetch-release-tracks";
3
+ import { mergeTracksById } from "@/lib/musicbrainz/merge-tracks-by-id";
4
+ import { pickReleaseGroupReleaseId } from "@/lib/musicbrainz/pick-release-group-release-id";
5
+ import { releaseGroupArtistName } from "@/lib/musicbrainz/release-group-artist-name";
6
+ import { searchMusicBrainzRecordingsQuery } from "@/lib/musicbrainz/search-musicbrainz-recordings";
7
+ import { searchMusicBrainzReleaseGroups } from "@/lib/musicbrainz/search-musicbrainz-release-groups";
8
+ import type { Track } from "@/types/track";
9
+
10
+ const MAX_RECORDING_RESULTS = 40;
11
+ const MAX_RELEASE_GROUP_EXPAND = 4;
12
+
13
+ /**
14
+ * Search MusicBrainz across recordings and release groups; returns deduped tracks.
15
+ */
16
+ export async function searchMusicBrainz(
17
+ query: string,
18
+ signal?: AbortSignal,
19
+ ): Promise<Track[]> {
20
+ const trimmed = query.trim();
21
+ if (!trimmed) return [];
22
+
23
+ const { queries, hints } = buildMusicBrainzLuceneQueries(trimmed);
24
+ const merged: Track[] = [];
25
+
26
+ for (const luceneQuery of queries) {
27
+ if (signal?.aborted) return merged;
28
+ const batch = await searchMusicBrainzRecordingsQuery(
29
+ luceneQuery,
30
+ hints,
31
+ signal,
32
+ 25,
33
+ );
34
+ mergeTracksById(merged, batch);
35
+ if (merged.length >= MAX_RECORDING_RESULTS) break;
36
+ }
37
+
38
+ const groups = await searchMusicBrainzReleaseGroups(trimmed, signal, 10);
39
+ const albumHint = hints.album?.trim().toLowerCase() ?? "";
40
+ const prioritized = [...groups].sort((a, b) => {
41
+ if (!albumHint) return 0;
42
+ const aTitle = a.title?.trim().toLowerCase() ?? "";
43
+ const bTitle = b.title?.trim().toLowerCase() ?? "";
44
+ const aScore =
45
+ aTitle === albumHint ? 2 : aTitle.includes(albumHint) ? 1 : 0;
46
+ const bScore =
47
+ bTitle === albumHint ? 2 : bTitle.includes(albumHint) ? 1 : 0;
48
+ return bScore - aScore;
49
+ });
50
+
51
+ let expanded = 0;
52
+ for (const group of prioritized) {
53
+ if (signal?.aborted) break;
54
+ if (expanded >= MAX_RELEASE_GROUP_EXPAND) break;
55
+ const releaseId = pickReleaseGroupReleaseId(group);
56
+ if (!releaseId) continue;
57
+ const artist = releaseGroupArtistName(group);
58
+ const album = group.title?.trim() || "Unknown album";
59
+ const tracks = await fetchReleaseTracks(releaseId, album, artist, signal);
60
+ mergeTracksById(merged, tracks);
61
+ expanded += 1;
62
+ }
63
+
64
+ return merged;
65
+ }
@@ -0,0 +1,43 @@
1
+ export type MusicBrainzArtistCredit = {
2
+ name?: string;
3
+ artist?: { name?: string };
4
+ };
5
+
6
+ export type MusicBrainzReleaseRef = {
7
+ id?: string;
8
+ title?: string;
9
+ status?: string;
10
+ "release-group"?: { title?: string };
11
+ };
12
+
13
+ export type MusicBrainzRecording = {
14
+ id: string;
15
+ title: string;
16
+ length?: number;
17
+ "artist-credit"?: MusicBrainzArtistCredit[];
18
+ releases?: MusicBrainzReleaseRef[];
19
+ };
20
+
21
+ export type MusicBrainzReleaseGroup = {
22
+ id: string;
23
+ title: string;
24
+ "first-release-date"?: string;
25
+ "artist-credit"?: MusicBrainzArtistCredit[];
26
+ releases?: Array<{ id: string; title?: string; status?: string }>;
27
+ };
28
+
29
+ export type MusicBrainzReleaseTrack = {
30
+ id: string;
31
+ title: string;
32
+ length?: number;
33
+ recording?: MusicBrainzRecording;
34
+ };
35
+
36
+ export type MusicBrainzReleaseDetail = {
37
+ id: string;
38
+ title: string;
39
+ "artist-credit"?: MusicBrainzArtistCredit[];
40
+ media?: Array<{
41
+ tracks?: MusicBrainzReleaseTrack[];
42
+ }>;
43
+ };
@@ -0,0 +1,3 @@
1
+ export { searchMusicBrainz } from "@/lib/musicbrainz/search-musicbrainz";
2
+ export { searchMusicBrainz as searchMusicBrainzRecordings } from "@/lib/musicbrainz/search-musicbrainz";
3
+ export type { MusicBrainzRecording } from "@/lib/musicbrainz/types";
@@ -0,0 +1,49 @@
1
+ import type { PersistedPlaybackSnapshot } from "@/types/persisted-playback-snapshot";
2
+ import type { QueuedTrack } from "@/types/queue";
3
+ import type { Track } from "@/types/track";
4
+
5
+ export type RestoredQueue = {
6
+ queue: QueuedTrack[];
7
+ activeQueueId: string | null;
8
+ positionSec: number;
9
+ };
10
+
11
+ /**
12
+ * Rebuilds a playback queue from a snapshot and the current library catalog.
13
+ */
14
+ export default function buildQueueFromSnapshot(
15
+ libraryCatalog: readonly Track[],
16
+ snapshot: PersistedPlaybackSnapshot,
17
+ ): RestoredQueue {
18
+ const libraryById = new Map<string, Track>();
19
+ for (const t of libraryCatalog) libraryById.set(t.id, t);
20
+
21
+ const resolvedTracks: Track[] = [];
22
+ if (snapshot.tracks && snapshot.tracks.length > 0) {
23
+ for (const row of snapshot.tracks) {
24
+ resolvedTracks.push(libraryById.get(row.id) ?? row);
25
+ }
26
+ } else {
27
+ for (const trackId of snapshot.trackIds) {
28
+ const track = libraryById.get(trackId);
29
+ if (track) resolvedTracks.push(track);
30
+ }
31
+ }
32
+
33
+ const queue: QueuedTrack[] = resolvedTracks.map((track) => ({
34
+ queueId: crypto.randomUUID(),
35
+ track,
36
+ }));
37
+ let activeQueueId: string | null = null;
38
+ if (snapshot.activeTrackId) {
39
+ const row = queue.find((q) => q.track.id === snapshot.activeTrackId);
40
+ activeQueueId = row?.queueId ?? queue[0]?.queueId ?? null;
41
+ } else {
42
+ activeQueueId = queue[0]?.queueId ?? null;
43
+ }
44
+ return {
45
+ queue,
46
+ activeQueueId,
47
+ positionSec: snapshot.positionSec,
48
+ };
49
+ }
@@ -0,0 +1,45 @@
1
+ import type { LibraryTrackRef } from '@/types/track'
2
+ import type { Track } from '@/types/track'
3
+
4
+ function parseLibraryRef(value: unknown): LibraryTrackRef | undefined {
5
+ if (!value || typeof value !== 'object') return undefined
6
+ const o = value as Record<string, unknown>
7
+ if (typeof o.rootId !== 'string' || typeof o.relativePath !== 'string') return undefined
8
+ return { rootId: o.rootId, relativePath: o.relativePath }
9
+ }
10
+
11
+ /**
12
+ * Validates a track object loaded from a persisted playback snapshot.
13
+ */
14
+ export default function parsePersistedTrack(value: unknown): Track | null {
15
+ if (!value || typeof value !== 'object') return null
16
+ const o = value as Record<string, unknown>
17
+ if (typeof o.id !== 'string' || !o.id.trim()) return null
18
+ if (typeof o.title !== 'string') return null
19
+ if (typeof o.artist !== 'string') return null
20
+ if (typeof o.album !== 'string') return null
21
+ const durationSec =
22
+ typeof o.durationSec === 'number' && Number.isFinite(o.durationSec) && o.durationSec >= 0
23
+ ? o.durationSec
24
+ : 0
25
+ const source =
26
+ o.source === 'library' || o.source === 'musicbrainz' || o.source === 'youtube'
27
+ ? o.source
28
+ : undefined
29
+ const audioUrl = typeof o.audioUrl === 'string' ? o.audioUrl : o.audioUrl === null ? null : undefined
30
+ const youtubeQuery = typeof o.youtubeQuery === 'string' ? o.youtubeQuery : undefined
31
+ const youtubeVideoId = typeof o.youtubeVideoId === 'string' ? o.youtubeVideoId : undefined
32
+ const library = parseLibraryRef(o.library)
33
+ return {
34
+ id: o.id.trim(),
35
+ title: o.title,
36
+ artist: o.artist,
37
+ album: o.album,
38
+ durationSec,
39
+ audioUrl,
40
+ youtubeQuery,
41
+ youtubeVideoId,
42
+ source,
43
+ library,
44
+ }
45
+ }
@@ -0,0 +1,45 @@
1
+ import parsePersistedTrack from "@/lib/playback/parse-persisted-track";
2
+ import type { PersistedPlaybackSnapshot } from "@/types/persisted-playback-snapshot";
3
+ import type { Track } from "@/types/track";
4
+
5
+ const STORAGE_KEY = "muzical.playbackSnapshot";
6
+
7
+ /**
8
+ * Loads the last saved queue / playhead snapshot from localStorage.
9
+ */
10
+ export default function readStoredPlaybackSnapshot(): PersistedPlaybackSnapshot | null {
11
+ if (typeof window === "undefined") return null;
12
+ try {
13
+ const raw = window.localStorage.getItem(STORAGE_KEY);
14
+ if (!raw) return null;
15
+ const parsed: unknown = JSON.parse(raw);
16
+ if (!parsed || typeof parsed !== "object") return null;
17
+ const o = parsed as Record<string, unknown>;
18
+ if (!Array.isArray(o.trackIds)) return null;
19
+ const trackIds = o.trackIds.filter(
20
+ (x): x is string => typeof x === "string" && x.length > 0,
21
+ );
22
+ let tracks: Track[] | undefined;
23
+ if (Array.isArray(o.tracks)) {
24
+ const list: Track[] = [];
25
+ for (const item of o.tracks) {
26
+ const row = parsePersistedTrack(item);
27
+ if (row) list.push(row);
28
+ }
29
+ if (list.length > 0) tracks = list;
30
+ }
31
+ const activeTrackId =
32
+ typeof o.activeTrackId === "string" ? o.activeTrackId : null;
33
+ const positionSec =
34
+ typeof o.positionSec === "number" &&
35
+ Number.isFinite(o.positionSec) &&
36
+ o.positionSec >= 0
37
+ ? o.positionSec
38
+ : 0;
39
+ return { trackIds, tracks, activeTrackId, positionSec };
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export { STORAGE_KEY as PLAYBACK_SNAPSHOT_STORAGE_KEY };
@@ -0,0 +1,19 @@
1
+ import { PLAYBACK_SNAPSHOT_STORAGE_KEY } from "@/lib/playback/read-stored-playback-snapshot";
2
+ import type { PersistedPlaybackSnapshot } from "@/types/persisted-playback-snapshot";
3
+
4
+ /**
5
+ * Persists queue / playhead snapshot to localStorage.
6
+ */
7
+ export default function writeStoredPlaybackSnapshot(
8
+ snapshot: PersistedPlaybackSnapshot,
9
+ ): void {
10
+ if (typeof window === "undefined") return;
11
+ try {
12
+ window.localStorage.setItem(
13
+ PLAYBACK_SNAPSHOT_STORAGE_KEY,
14
+ JSON.stringify(snapshot),
15
+ );
16
+ } catch {
17
+ /* ignore */
18
+ }
19
+ }
@@ -0,0 +1,4 @@
1
+ /** localStorage key for persisted color scheme */
2
+ export const THEME_STORAGE_KEY = "muzical-theme" as const;
3
+
4
+ export type ColorScheme = "light" | "dark";
@@ -0,0 +1,9 @@
1
+ import { THEME_STORAGE_KEY } from "@/lib/theme-constants";
2
+
3
+ /**
4
+ * Runs before paint so the first frame matches stored theme (default light).
5
+ */
6
+ export function getThemeInitScript(): string {
7
+ const k = JSON.stringify(THEME_STORAGE_KEY);
8
+ return `(function(){try{var s=localStorage.getItem(${k});document.documentElement.classList.toggle('dark',s==='dark');}catch(e){}})();`;
9
+ }
@@ -0,0 +1,8 @@
1
+ import { setYoutubeDataApiBlockedInternal } from '@/lib/youtube/read-youtube-data-api-blocked'
2
+
3
+ /**
4
+ * Re-enables YouTube Data API v3 lookups (e.g. after saving a new API key).
5
+ */
6
+ export default function clearYoutubeDataApiBlocked(): void {
7
+ setYoutubeDataApiBlockedInternal(false)
8
+ }
@@ -0,0 +1,20 @@
1
+ import type { Track } from '@/types/track'
2
+ import type { YoutubePrefetchTarget } from '@/lib/youtube/prefetch-youtube-video-ids'
3
+
4
+ /**
5
+ * Builds deduped prefetch targets from tracks missing a YouTube video id.
6
+ */
7
+ export default function collectYoutubePrefetchTargets(
8
+ tracks: readonly Track[],
9
+ ): YoutubePrefetchTarget[] {
10
+ const seen = new Set<string>()
11
+ const out: YoutubePrefetchTarget[] = []
12
+ for (const track of tracks) {
13
+ const query = track.youtubeQuery?.trim()
14
+ if (!query || track.youtubeVideoId?.trim()) continue
15
+ if (seen.has(track.id)) continue
16
+ seen.add(track.id)
17
+ out.push({ trackId: track.id, query })
18
+ }
19
+ return out
20
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Returns true when a YouTube Data API error message indicates quota exhaustion.
3
+ */
4
+ export default function isYoutubeQuotaErrorMessage(message: string): boolean {
5
+ const m = message.toLowerCase()
6
+ return m.includes('quota') || m.includes('quotaexceeded')
7
+ }
@@ -0,0 +1,8 @@
1
+ import { setYoutubeDataApiBlockedInternal } from '@/lib/youtube/read-youtube-data-api-blocked'
2
+
3
+ /**
4
+ * Disables further YouTube Data API v3 search requests for this session.
5
+ */
6
+ export default function markYoutubeDataApiBlocked(): void {
7
+ setYoutubeDataApiBlockedInternal(true)
8
+ }
@@ -0,0 +1,55 @@
1
+ import readYoutubeDataApiBlocked from '@/lib/youtube/read-youtube-data-api-blocked'
2
+ import searchYoutubeVideoId from '@/lib/youtube/search-youtube-video-id'
3
+
4
+ export type YoutubePrefetchTarget = {
5
+ trackId: string
6
+ query: string
7
+ }
8
+
9
+ type PrefetchYoutubeVideoIdsOptions = {
10
+ signal?: AbortSignal
11
+ maxConcurrent?: number
12
+ }
13
+
14
+ /**
15
+ * Resolves YouTube video ids for many tracks concurrently (Data API v3).
16
+ */
17
+ export default async function prefetchYoutubeVideoIds(
18
+ targets: readonly YoutubePrefetchTarget[],
19
+ apiKey: string,
20
+ onResolved: (trackId: string, videoId: string) => void,
21
+ options: PrefetchYoutubeVideoIdsOptions = {},
22
+ ): Promise<void> {
23
+ const key = apiKey.trim()
24
+ if (!key || targets.length === 0 || readYoutubeDataApiBlocked()) return
25
+
26
+ const maxConcurrent =
27
+ typeof options.maxConcurrent === 'number' && options.maxConcurrent > 0
28
+ ? Math.floor(options.maxConcurrent)
29
+ : 3
30
+ const signal = options.signal
31
+ let index = 0
32
+
33
+ const worker = async (): Promise<void> => {
34
+ while (index < targets.length) {
35
+ if (signal?.aborted) return
36
+ const i = index
37
+ index += 1
38
+ const target = targets[i]
39
+ if (readYoutubeDataApiBlocked()) return
40
+ let videoId: string | null = null
41
+ try {
42
+ videoId = await searchYoutubeVideoId(target.query, key, signal)
43
+ } catch {
44
+ continue
45
+ }
46
+ if (signal?.aborted || readYoutubeDataApiBlocked() || !videoId) continue
47
+ onResolved(target.trackId, videoId)
48
+ }
49
+ }
50
+
51
+ const workers = Array.from({ length: Math.min(maxConcurrent, targets.length) }, () =>
52
+ worker(),
53
+ )
54
+ await Promise.all(workers)
55
+ }
@@ -0,0 +1,16 @@
1
+ const STORAGE_KEY = 'muzical.youtubeApiKey'
2
+
3
+ /**
4
+ * Loads the YouTube Data API key from localStorage.
5
+ */
6
+ export default function readStoredYoutubeApiKey(): string {
7
+ if (typeof window === 'undefined') return ''
8
+ try {
9
+ const raw = window.localStorage.getItem(STORAGE_KEY)
10
+ return typeof raw === 'string' ? raw.trim() : ''
11
+ } catch {
12
+ return ''
13
+ }
14
+ }
15
+
16
+ export { STORAGE_KEY as YOUTUBE_API_KEY_STORAGE_KEY }
@@ -0,0 +1,12 @@
1
+ let blocked = false
2
+
3
+ /**
4
+ * Whether Data API v3 lookups are disabled (e.g. after quota exhaustion).
5
+ */
6
+ export default function readYoutubeDataApiBlocked(): boolean {
7
+ return blocked
8
+ }
9
+
10
+ export function setYoutubeDataApiBlockedInternal(next: boolean): void {
11
+ blocked = next
12
+ }
@@ -0,0 +1,60 @@
1
+ import isYoutubeQuotaErrorMessage from '@/lib/youtube/is-youtube-quota-error-message'
2
+ import markYoutubeDataApiBlocked from '@/lib/youtube/mark-youtube-data-api-blocked'
3
+ import readYoutubeDataApiBlocked from '@/lib/youtube/read-youtube-data-api-blocked'
4
+
5
+ type YoutubeSearchResponse = {
6
+ items?: Array<{
7
+ id?: { videoId?: string }
8
+ }>
9
+ error?: { message?: string; errors?: Array<{ reason?: string }> }
10
+ }
11
+
12
+ const videoIdCache = new Map<string, string>()
13
+
14
+ /**
15
+ * Resolves a YouTube video id for a search query via YouTube Data API v3.
16
+ */
17
+ export default async function searchYoutubeVideoId(
18
+ query: string,
19
+ apiKey: string,
20
+ signal?: AbortSignal,
21
+ ): Promise<string | null> {
22
+ const q = query.trim()
23
+ const key = apiKey.trim()
24
+ if (!q || !key) return null
25
+ if (readYoutubeDataApiBlocked()) return null
26
+
27
+ const cacheKey = q.toLowerCase()
28
+ const cached = videoIdCache.get(cacheKey)
29
+ if (cached) return cached
30
+
31
+ const url = new URL('https://www.googleapis.com/youtube/v3/search')
32
+ url.searchParams.set('part', 'snippet')
33
+ url.searchParams.set('type', 'video')
34
+ url.searchParams.set('maxResults', '5')
35
+ url.searchParams.set('videoEmbeddable', 'true')
36
+ url.searchParams.set('q', q)
37
+ url.searchParams.set('key', key)
38
+
39
+ const response = await fetch(url, { signal })
40
+ const body = (await response.json()) as YoutubeSearchResponse
41
+ if (!response.ok) {
42
+ const message = body.error?.message?.trim() ?? ''
43
+ const quotaReason = body.error?.errors?.some((e) => e.reason === 'quotaExceeded') ?? false
44
+ if (quotaReason || isYoutubeQuotaErrorMessage(message)) {
45
+ markYoutubeDataApiBlocked()
46
+ return null
47
+ }
48
+ return null
49
+ }
50
+
51
+ for (const item of body.items ?? []) {
52
+ const id = item.id?.videoId?.trim()
53
+ if (id) {
54
+ videoIdCache.set(cacheKey, id)
55
+ return id
56
+ }
57
+ }
58
+
59
+ return null
60
+ }
@@ -0,0 +1,19 @@
1
+ import readYoutubeDataApiBlocked from '@/lib/youtube/read-youtube-data-api-blocked'
2
+
3
+ /**
4
+ * True when playback should use the iframe search playlist instead of a resolved video id.
5
+ */
6
+ export default function shouldUseYoutubeSearchPlayback(
7
+ youtubeQuery: string | undefined,
8
+ youtubeVideoId: string | undefined,
9
+ hasApiKey: boolean,
10
+ forceSearchFallback: boolean,
11
+ ): boolean {
12
+ const query = youtubeQuery?.trim()
13
+ if (!query) return false
14
+ if (youtubeVideoId?.trim()) return false
15
+ if (forceSearchFallback) return true
16
+ if (readYoutubeDataApiBlocked()) return true
17
+ if (!hasApiKey) return true
18
+ return false
19
+ }
@@ -0,0 +1,18 @@
1
+ import { YOUTUBE_API_KEY_STORAGE_KEY } from '@/lib/youtube/read-stored-youtube-api-key'
2
+
3
+ /**
4
+ * Persists the YouTube Data API key to localStorage.
5
+ */
6
+ export default function writeStoredYoutubeApiKey(apiKey: string): void {
7
+ if (typeof window === 'undefined') return
8
+ try {
9
+ const trimmed = apiKey.trim()
10
+ if (trimmed) {
11
+ window.localStorage.setItem(YOUTUBE_API_KEY_STORAGE_KEY, trimmed)
12
+ } else {
13
+ window.localStorage.removeItem(YOUTUBE_API_KEY_STORAGE_KEY)
14
+ }
15
+ } catch {
16
+ /* ignore */
17
+ }
18
+ }
package/next.config.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ /* config options here */
5
+ };
6
+
7
+ export default nextConfig;