diff options
| -rw-r--r-- | yt-music-dl-web/src/App.svelte | 2 | ||||
| -rw-r--r-- | yt-music-dl-web/src/features/library/LibraryMatchAlbumDialog.svelte | 299 | ||||
| -rw-r--r-- | yt-music-dl-web/src/features/library/LibraryMetadataSidebar.svelte | 241 | ||||
| -rw-r--r-- | yt-music-dl-web/src/features/library/LibraryView.svelte | 347 | ||||
| -rw-r--r-- | yt-music-dl-web/src/features/library/logic.test.ts | 288 | ||||
| -rw-r--r-- | yt-music-dl-web/src/features/library/logic.ts | 185 |
6 files changed, 1243 insertions, 119 deletions
diff --git a/yt-music-dl-web/src/App.svelte b/yt-music-dl-web/src/App.svelte index 943e81a..ddc3823 100644 --- a/yt-music-dl-web/src/App.svelte +++ b/yt-music-dl-web/src/App.svelte @@ -232,7 +232,7 @@ </div> {/if} {:else if view === "library"} - <LibraryView api={libraryApi} /> + <LibraryView api={libraryApi} metadataApi={metadataApi} /> {:else if view === "settings"} <SettingsView api={settingsApi} /> {/if} diff --git a/yt-music-dl-web/src/features/library/LibraryMatchAlbumDialog.svelte b/yt-music-dl-web/src/features/library/LibraryMatchAlbumDialog.svelte new file mode 100644 index 0000000..9cece49 --- /dev/null +++ b/yt-music-dl-web/src/features/library/LibraryMatchAlbumDialog.svelte @@ -0,0 +1,299 @@ +<!-- + src/features/library/LibraryMatchAlbumDialog.svelte — thin component. + + The "Match Album" modal for the library view: search MusicBrainz for a + release by artist + album, pick one, then match the library tracks against its + track list and update every position/title-confidence track at once via + PATCH /api/library/:id. It is a thin variant of the segment editor's + MatchAlbumDialog.svelte, adapted for Track[] instead of CutDraft: the tracks + are converted to a CutDraft internally (tracksToDraft) for the backend's + match-album endpoint, and the matches are converted back to per-track + UpdateTrackRequests (albumMatchToUpdates). The shared buildReleaseSearch + + matchedSegmentCount are reused via re-exports from ./logic.js. + + The parent renders this only while open (so inputs reset each time). + onapply is async (sequential PATCH calls); the dialog shows an applying state + and closes on success. +--> +<script lang="ts"> + import type { + AlbumMatch, + AlbumMatchResult, + MetadataResult, + ReleaseDetail, + Track, + UpdateTrackRequest, + } from "@yt-music/contract"; + import type { MetadataApi } from "../../adapters/metadataApi.js"; + import { + albumMatchToUpdates, + buildReleaseSearch, + matchedSegmentCount, + tracksToDraft, + type TrackUpdate, + } from "./logic.js"; + + interface Props { + tracks: Track[]; + api: MetadataApi; + onapply: (updates: TrackUpdate[]) => Promise<void>; + onclose: () => void; + } + + let { tracks, api, onapply, onclose }: Props = $props(); + + // Inputs prefilled from the first track's artist + album (reset each open + // via parent {#if}). svelte-ignore state_referenced_locally — intentional: + // capture the initial value to prefill the search box. + // svelte-ignore state_referenced_locally + let artistInput = $state(tracks[0]?.artist ?? ""); + // svelte-ignore state_referenced_locally + let albumInput = $state(tracks[0]?.album ?? ""); + + // Release-search state. + let releaseResults = $state<MetadataResult[]>([]); + let releaseSearching = $state(false); + let releaseSearchError = $state<string | undefined>(undefined); + + // Selected release + its match. + let selected = $state<MetadataResult | null>(null); + let releaseDetail = $state<ReleaseDetail | null>(null); + let matchResult = $state<AlbumMatchResult | null>(null); + let matching = $state(false); + let matchError = $state<string | undefined>(undefined); + + // Applying state: while the batch PATCH calls are in flight. + let applying = $state(false); + let applyError = $state<string | undefined>(undefined); + + let totalTracks = $derived(tracks.length); + let matchedCount = $derived(matchResult ? matchedSegmentCount(matchResult) : 0); + let canApply = $derived(matchResult !== null && releaseDetail !== null && !matching && !applying); + + async function searchReleases() { + const req = buildReleaseSearch(albumInput, artistInput, "", ""); + if (!req.query) return; + releaseSearching = true; + releaseSearchError = undefined; + releaseResults = []; + selected = null; + releaseDetail = null; + matchResult = null; + matchError = undefined; + try { + releaseResults = await api.search(req); + } catch (err) { + releaseSearchError = err instanceof Error ? err.message : String(err); + } finally { + releaseSearching = false; + } + } + + // Pick a release → fetch its detail, then match the library tracks against + // its tracks. The tracks are converted to a CutDraft (tracksToDraft) so the + // backend's match-album endpoint can process them. + async function selectRelease(r: MetadataResult) { + selected = r; + releaseDetail = null; + matchResult = null; + matchError = undefined; + matching = true; + try { + const detail = await api.release(r.id); + releaseDetail = detail; + const draft = tracksToDraft(tracks); + const result = await api.matchAlbum({ draft, releaseId: r.id }); + matchResult = result; + } catch (err) { + matchError = err instanceof Error ? err.message : String(err); + } finally { + matching = false; + } + } + + async function apply() { + if (!canApply || !releaseDetail || !matchResult) return; + applying = true; + applyError = undefined; + try { + const updates = albumMatchToUpdates(tracks, releaseDetail, matchResult); + await onapply(updates); + onclose(); + } catch (err) { + applyError = err instanceof Error ? err.message : String(err); + } finally { + applying = false; + } + } + + function onBackdropClick() { + if (!releaseSearching && !matching && !applying) onclose(); + } + + // presentation-only (no pure meaning) — confidence → friendly label + tone. + function confidenceLabel(c: AlbumMatch["confidence"]): string { + if (c === "position") return "Position"; + if (c === "title") return "Title"; + return "No match"; + } + function confidenceTone(c: AlbumMatch["confidence"]): string { + if (c === "position") return "badge-success"; + if (c === "title") return "badge-info"; + return "badge-ghost"; + } + + function onSearchSubmit(e: SubmitEvent) { + e.preventDefault(); + void searchReleases(); + } +</script> + +<div + class="modal modal-open" + role="dialog" + aria-modal="true" + aria-label="Match album" +> + <div class="modal-box max-w-2xl"> + <div class="flex items-center justify-between"> + <h3 class="text-lg font-bold">Match album</h3> + <button + class="btn btn-ghost btn-xs" + onclick={onclose} + disabled={releaseSearching || matching || applying} + aria-label="Close">✕</button + > + </div> + <p class="mt-1 text-sm text-base-content/60"> + Find a MusicBrainz release and fill every track whose title matches by position or title. + </p> + + <!-- release search --> + <form class="mt-3 grid gap-2 sm:grid-cols-[1fr_1fr_auto]" onsubmit={onSearchSubmit}> + <label class="form-control"> + <span class="label-text mb-1 text-xs">Artist</span> + <input + class="input input-sm" + value={artistInput} + oninput={(e) => (artistInput = e.currentTarget.value)} + placeholder="Artist" + /> + </label> + <label class="form-control"> + <span class="label-text mb-1 text-xs">Album</span> + <input + class="input input-sm" + value={albumInput} + oninput={(e) => (albumInput = e.currentTarget.value)} + placeholder="Album" + /> + </label> + <label class="form-control sm:justify-end"> + <span class="label-text mb-1 text-xs text-transparent sm:invisible">go</span> + <button class="btn btn-sm btn-primary" type="submit" disabled={releaseSearching || applying}> + {#if releaseSearching}<span class="loading loading-spinner loading-xs"></span>{/if} + Search + </button> + </label> + </form> + + {#if releaseSearchError} + <div class="alert alert-error mt-3 py-2 text-sm"> + <span>{releaseSearchError}</span> + </div> + {/if} + + <!-- release results --> + {#if releaseSearching} + <div class="flex items-center gap-2 py-6 text-base-content/60"> + <span class="loading loading-spinner loading-sm"></span> + <span class="text-sm">Searching MusicBrainz releases…</span> + </div> + {:else if releaseResults.length > 0} + <div class="mt-3 max-h-52 overflow-y-auto rounded-box border border-base-300"> + <ul class="divide divide-base-300"> + {#each releaseResults as r (r.id)} + <li> + <button + type="button" + class="flex w-full items-center justify-between gap-2 p-2 text-left transition-colors hover:bg-base-200 {selected?.id === r.id + ? 'bg-base-200' + : ''}" + onclick={() => void selectRelease(r)} + > + <div class="min-w-0"> + <p class="truncate text-sm font-medium">{r.title}</p> + <p class="truncate text-xs text-base-content/60">{r.artist}</p> + </div> + <span class="badge badge-sm badge-ghost tabular-nums">{r.score}%</span> + </button> + </li> + {/each} + </ul> + </div> + {/if} + + <!-- match preview --> + {#if selected} + <div class="mt-3 rounded-box bg-base-200 p-3"> + {#if matching} + <div class="flex items-center gap-2 text-sm text-base-content/60"> + <span class="loading loading-spinner loading-xs"></span> + <span>Fetching release + matching {totalTracks} tracks…</span> + </div> + {:else if matchError} + <div class="alert alert-error py-2 text-sm"> + <span>{matchError}</span> + </div> + {:else if matchResult} + <div class="flex flex-wrap items-center justify-between gap-2"> + <p class="text-sm font-medium"> + {matchedCount} of {matchResult.matches.length} tracks matched + </p> + <span class="text-xs text-base-content/50"> + {totalTracks - matchedCount} left unchanged (no match) + </span> + </div> + <ul class="mt-2 max-h-48 space-y-1 overflow-y-auto"> + {#each matchResult.matches as m (m.segmentIndex)} + <li class="flex items-center gap-2 text-xs"> + <span class="badge badge-sm {confidenceTone(m.confidence)}"> + {confidenceLabel(m.confidence)} + </span> + <span class="truncate text-base-content/60"> + #{tracks[m.segmentIndex]?.track ?? "—"} + {tracks[m.segmentIndex]?.title || "(untitled)"} + </span> + <span class="text-base-content/40">→</span> + <span class="truncate font-medium">{m.track.title}</span> + </li> + {/each} + </ul> + {/if} + </div> + {/if} + + {#if applyError} + <div class="alert alert-error mt-3 py-2 text-sm"> + <span>{applyError}</span> + </div> + {/if} + + <div class="modal-action"> + <button class="btn btn-ghost btn-sm" onclick={onclose} disabled={releaseSearching || matching || applying}> + Cancel + </button> + <button class="btn btn-primary btn-sm" onclick={apply} disabled={!canApply}> + {#if applying}<span class="loading loading-spinner loading-xs"></span>{/if} + Apply {matchedCount} match{matchedCount === 1 ? "" : "es"} + </button> + </div> + </div> + <!-- click the backdrop (not the box) to close --> + <button + class="modal-backdrop" + type="button" + onclick={onBackdropClick} + aria-label="Close"></button + > +</div> diff --git a/yt-music-dl-web/src/features/library/LibraryMetadataSidebar.svelte b/yt-music-dl-web/src/features/library/LibraryMetadataSidebar.svelte new file mode 100644 index 0000000..99d7121 --- /dev/null +++ b/yt-music-dl-web/src/features/library/LibraryMetadataSidebar.svelte @@ -0,0 +1,241 @@ +<!-- + src/features/library/LibraryMetadataSidebar.svelte — thin component. + + The per-track MusicBrainz lookup panel (right side of the library view). It + is a thin variant of the segment editor's MetadataSidebar.svelte, adapted for + a library Track instead of a SegmentDraft: the search request is built by + buildTrackSearch (not buildRecordingSearch), the sidebar view-model is + trackSidebarItems (not sidebarItems), and clicking a result calls onfill + with an UpdateTrackRequest (a PATCH body) instead of EditAction[] (in-memory + draft edits). + + The shared search state machine (reduceSearch), the SidebarItem view-model, + and toSidebarItem are reused via re-exports from ./logic.js (which pulls them + from ../segment-editor/metadata.js). The only side-effect is the injected + MetadataApi.search call (rate-limited server-side to ~1 req/sec, so a search + may take ~1s — a loading state is shown). + + The parent recreates this component via {#key track.id} when the selected + track changes, which resets the search state and re-runs the mount search. + A mount-only $effect runs the initial search with the prefilled query + (track title); untrack keeps the parent's track-list updates from + re-triggering it. Clicking a card calls onfill; on success the parent closes + the sidebar (sets selectedTrackId = null). +--> +<script lang="ts"> + import { untrack } from "svelte"; + import type { Track, UpdateTrackRequest } from "@yt-music/contract"; + import type { MetadataApi } from "../../adapters/metadataApi.js"; + import { + buildTrackSearch, + initialSearchState, + reduceSearch, + toUpdateRequestFromItem, + trackSidebarItems, + type SearchState, + type SidebarItem, + } from "./logic.js"; + + interface Props { + track: Track; + api: MetadataApi; + onfill: (req: UpdateTrackRequest) => Promise<void>; + onclose: () => void; + } + + let { track, api, onfill, onclose }: Props = $props(); + + // Captured once at init (NOT a tracked read in an effect) so the mount search + // uses the track's current title without re-running on track-list updates. + // svelte-ignore state_referenced_locally + // — intentional: the prefilled query is a one-time snapshot of the title. + const initialQuery = track.title; + // Start "searching" when there's a query (the mount effect fires the search), + // else "idle" (just the "Current tags" entry shows). Initialising to + // "searching" directly (rather than setting it inside the effect) means the + // effect never reads `searchState` synchronously — so the async result writes + // can't re-trigger it (which would loop: searchOk → effect → search → …). + let searchState = $state<SearchState>( + initialQuery.trim() + ? { status: "searching", query: initialQuery, results: [] } + : initialSearchState(initialQuery), + ); + + // The full sidebar list: the live "Current tags" entry + the last search's + // MB results. + let items = $derived<SidebarItem[]>(trackSidebarItems(track, searchState.results)); + + // Applying state: while a PATCH is in flight after clicking a result. + let applying = $state(false); + let applyError = $state<string | undefined>(undefined); + + // Mount-only initial search. The body reads NO reactive state synchronously + // (initialQuery is a const; buildTrackSearch reads props under untrack; api + // is a stable reference) → it runs once and is not re-triggered by its own + // async writes to `searchState`. Cleanup cancels the in-flight request when + // the track changes (parent {#key} unmounts this instance). + $effect(() => { + if (!initialQuery.trim()) return; // nothing to query — current-tags entry only + let cancelled = false; + const req = untrack(() => buildTrackSearch(track, initialQuery)); + api.search(req) + .then((results) => { + if (!cancelled) searchState = reduceSearch(searchState, { type: "searchOk", results }); + }) + .catch((err: unknown) => { + if (!cancelled) { + searchState = reduceSearch(searchState, { + type: "searchFailed", + error: err instanceof Error ? err.message : String(err), + }); + } + }); + return () => { + cancelled = true; + }; + }); + + // User-initiated search (Search button / Enter). Invoked from an event + // handler, so reads of track are NOT tracked — no untrack needed here. + async function doSearch(query: string) { + const req = buildTrackSearch(track, query); + if (!req.query) return; + searchState = reduceSearch(searchState, { type: "searchStarted", query: query.trim() }); + try { + const results = await api.search(req); + searchState = reduceSearch(searchState, { type: "searchOk", results }); + } catch (err) { + searchState = reduceSearch(searchState, { + type: "searchFailed", + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // Click a sidebar item → build a PATCH body → call onfill. The "Current + // tags" entry produces an empty body (no-op). On success the parent closes + // the sidebar; on error the sidebar stays open so the user can try another + // result. + async function apply(item: SidebarItem) { + const req = toUpdateRequestFromItem(item); + if (Object.keys(req).length === 0) return; // current tags — no-op + applying = true; + applyError = undefined; + try { + await onfill(req); + // parent closes the sidebar on success (selectedTrackId = null) + } catch (err) { + applyError = err instanceof Error ? err.message : String(err); + } finally { + applying = false; + } + } + + function onSearchSubmit(e: SubmitEvent) { + e.preventDefault(); + void doSearch(searchState.query); + } +</script> + +<div class="card bg-base-100 shadow-sm border border-base-300"> + <div class="card-body gap-3"> + <div class="flex items-center justify-between"> + <h3 class="card-title text-base">Metadata</h3> + <button class="btn btn-ghost btn-xs" onclick={onclose} aria-label="Close metadata panel">✕</button> + </div> + <p class="text-xs text-base-content/60 -mt-2"> + {track.title || "(untitled)"} · click a result to update this track's tags + </p> + + <!-- search box (prefilled with the track title) --> + <form class="join w-full" onsubmit={onSearchSubmit}> + <input + class="input join-item input-sm flex-1" + placeholder="Search MusicBrainz…" + value={searchState.query} + disabled={applying} + oninput={(e) => + (searchState = reduceSearch(searchState, { type: "setQuery", query: e.currentTarget.value }))} + /> + <button + class="btn join-item btn-sm" + type="submit" + disabled={searchState.status === "searching" || applying} + > + {#if searchState.status === "searching"} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Search + {/if} + </button> + </form> + + <!-- rate-limit hint while searching --> + {#if searchState.status === "searching"} + <p class="text-xs text-base-content/50">MusicBrainz is rate-limited (~1 req/sec)…</p> + {/if} + + <!-- search error --> + {#if searchState.status === "error"} + <div class="alert alert-error py-2 text-sm"> + <span>{searchState.error}</span> + </div> + <button class="btn btn-ghost btn-xs self-start" onclick={() => void doSearch(searchState.query)}> + Retry + </button> + {/if} + + <!-- apply error (PATCH failed — sidebar stays open) --> + {#if applyError} + <div class="alert alert-error py-2 text-sm"> + <span>Couldn't update: {applyError}</span> + </div> + {/if} + + <!-- applying hint --> + {#if applying} + <p class="text-xs text-base-content/50"> + <span class="loading loading-spinner loading-xs"></span> + Updating track tags… + </p> + {/if} + + <!-- results list --> + <div class="flex flex-col gap-1.5"> + {#each items as item (item.source + (item.id ?? "current"))} + <button + type="button" + class="card-bordered rounded-box border border-base-300 bg-base-100 p-2 text-left transition-colors hover:bg-base-200 focus:bg-base-200 focus:outline-none {applying + ? 'pointer-events-none opacity-60' + : ''}" + onclick={() => void apply(item)} + > + <div class="flex items-center justify-between gap-2"> + {#if item.source === "youtube"} + <span class="badge badge-sm badge-secondary">Current</span> + {:else} + <span class="badge badge-sm badge-accent">MusicBrainz</span> + {/if} + {#if item.score !== undefined} + <span class="badge badge-sm badge-ghost tabular-nums">{item.score}%</span> + {:else if item.source === "youtube"} + <span class="text-[10px] text-base-content/40">current</span> + {/if} + </div> + <p class="mt-1 truncate text-sm font-medium">{item.title || "(untitled)"}</p> + <p class="truncate text-xs text-base-content/70">{item.artist}</p> + <p class="truncate text-xs text-base-content/50"> + {#if item.album}{item.album}{:else}<span class="italic">no album</span>{/if} + {#if item.trackNumber !== undefined} · #{item.trackNumber}{/if} + </p> + </button> + {/each} + + {#if searchState.status === "results" && searchState.results.length === 0} + <p class="py-4 text-center text-sm text-base-content/50"> + No MusicBrainz matches. The "Current" entry above keeps the existing tags. + </p> + {/if} + </div> + </div> +</div> diff --git a/yt-music-dl-web/src/features/library/LibraryView.svelte b/yt-music-dl-web/src/features/library/LibraryView.svelte index 3db9c0d..d86927b 100644 --- a/yt-music-dl-web/src/features/library/LibraryView.svelte +++ b/yt-music-dl-web/src/features/library/LibraryView.svelte @@ -1,19 +1,29 @@ <!-- src/features/library/LibraryView.svelte — thin component. - Wires the injected LibraryApi to the pure view-model: it fetches the raw - Track[] on mount (GET /api/library), derives display rows via toRows - (logic.ts), and renders. Rename (PATCH /api/library/:id) edits a track's tags - inline; the diff is built by the pure `toUpdateRequest`, applied optimistically - via `updateTrack`, then replaced by the backend's authoritative response - (which has a NEW id — derived from its path). "Organize library" - (POST /api/library/organize) bulk-moves every file to the current path - template and replaces the whole list. No business logic here — only - loading/error/edit/organize wiring. + Wires the injected LibraryApi + MetadataApi to the pure view-model: it + fetches the raw Track[] on mount (GET /api/library), derives display rows via + toRows (logic.ts), and renders. Rename (PATCH /api/library/:id) edits a + track's tags inline; the diff is built by the pure `toUpdateRequest`, + applied optimistically via `updateTrack`, then replaced by the backend's + authoritative response (which has a NEW id — derived from its path). + "Organize library" (POST /api/library/organize) bulk-moves every file to + the current path template and replaces the whole list. + + MusicBrainz metadata search: a "Lookup" button per track opens a sidebar + (LibraryMetadataSidebar) that searches MusicBrainz and, on click, calls + PATCH /api/library/:id with the new metadata (reusing the existing rename + endpoint — the backend handles tag write + file move). A "Match Album" + button in the header opens a dialog (LibraryMatchAlbumDialog) that batch- + updates all tracks matched to a release. No business logic here — only + loading/error/edit/organize/metadata wiring. --> <script lang="ts"> - import type { Track } from "@yt-music/contract"; + import type { Track, UpdateTrackRequest } from "@yt-music/contract"; import type { LibraryApi } from "../../adapters/libraryApi.js"; + import type { MetadataApi } from "../../adapters/metadataApi.js"; + import LibraryMatchAlbumDialog from "./LibraryMatchAlbumDialog.svelte"; + import LibraryMetadataSidebar from "./LibraryMetadataSidebar.svelte"; import { hasTrackInputError, toRows, @@ -26,8 +36,9 @@ interface Props { api: LibraryApi; + metadataApi: MetadataApi; } - let { api }: Props = $props(); + let { api, metadataApi }: Props = $props(); // The component owns the raw Track[] (the source) and derives display rows // from it (pure toRows). Holding the raw tracks lets the rename diff build a @@ -52,6 +63,17 @@ let organizing = $state(false); let organizeMessage = $state<string | null>(null); + // Metadata sidebar: the currently-selected track (null = panel closed). + // Clicking "Lookup" on a track selects it; the sidebar recreates via + // {#key} on track id so its search state resets per track. + let selectedTrackId = $state<string | null>(null); + let selectedTrack = $derived( + selectedTrackId !== null ? (tracks.find((t) => t.id === selectedTrackId) ?? null) : null, + ); + + // "Match Album" modal. + let matchAlbumOpen = $state(false); + // The track currently being edited (looked up from the source list), the // computed PATCH body, and derived save-gating flags — all pure downstream of // editForm + editingId + tracks. @@ -136,6 +158,50 @@ } } + // Apply MusicBrainz metadata to a track via PATCH /api/library/:id (the + // backend rewrites tags + moves the file). Same optimistic-update pattern + // as saveEdit: reflect locally, then replace with the authoritative response + // (new id + path). On success the sidebar closes (selectedTrackId = null); + // on error the sidebar stays open so the user can try another result. + async function applyMetadata(req: UpdateTrackRequest): Promise<void> { + if (selectedTrackId === null) return; + const oldId = selectedTrackId; + const prev = tracks.find((t) => t.id === oldId); + if (!prev) return; + // Optimistic: reflect the tag edits locally (id + path stay until the + // backend responds with the moved file's new id + path). + tracks = tracks.map((t) => (t.id === oldId ? updateTrack(prev, req) : t)); + try { + const updated = await api.rename(oldId, req); + const stillThere = tracks.some((t) => t.id === oldId); + if (stillThere) { + tracks = tracks.map((t) => (t.id === oldId ? updated : t)); + } else { + tracks = await api.list(); + } + // Close the sidebar on success — the track's id changed (moved file). + selectedTrackId = null; + } catch (err) { + // Revert the optimistic update + re-throw so the sidebar shows the error. + tracks = tracks.map((t) => (t.id === oldId ? prev : t)); + throw err; + } + } + + // Batch-apply an album match: sequentially PATCH each matched track. The + // backend moves files (each PATCH may take a moment), so we do them one at + // a time to avoid concurrent file-move conflicts. After all PATCHes, refetch + // the full list (ids may have changed). Throws on the first failure (the + // dialog shows the error and stays open). + async function applyMatchAlbum( + updates: { id: string; request: UpdateTrackRequest }[], + ): Promise<void> { + for (const { id, request } of updates) { + await api.rename(id, request); + } + tracks = await api.list(); + } + async function organize() { organizing = true; organizeMessage = null; @@ -163,7 +229,14 @@ {/if} <button class="btn btn-sm btn-outline" - disabled={busy || loading || editingId !== null} + disabled={busy || loading || editingId !== null || matchAlbumOpen} + onclick={() => (matchAlbumOpen = true)} + > + Match album + </button> + <button + class="btn btn-sm btn-outline" + disabled={busy || loading || editingId !== null || matchAlbumOpen} onclick={organize} > {#if organizing}<span class="loading loading-spinner loading-xs"></span>{/if} @@ -187,111 +260,148 @@ <div class="alert alert-error"> <span>{error}</span> </div> - {:else if rows.length === 0} - <div class="alert"> - <span>No tracks yet — download something to populate the library.</span> - </div> {:else} - <div class="overflow-x-auto"> - <table class="table table-zebra"> - <thead> - <tr> - <th class="w-12 text-right">#</th> - <th>Title</th> - <th>Artist</th> - <th>Album</th> - <th class="text-right">Duration</th> - <th>Format</th> - <th class="text-right">Actions</th> - </tr> - </thead> - <tbody> - {#each rows as row (row.id)} + <!-- track table + metadata sidebar --> + <div class="grid gap-4 lg:grid-cols-[1fr_20rem]"> + <div class="overflow-x-auto"> + <table class="table table-zebra"> + <thead> <tr> - {#if editingId === row.id} - <!-- edit mode --> - <td> - <input - class="input input-bordered input-xs w-14 text-center" - inputmode="numeric" - placeholder="—" - value={editForm.track} - oninput={(e) => (editForm.track = e.currentTarget.value)} - /> - {#if editTrackError} - <div class="mt-0.5 text-xs text-error">whole no. ≥ 1</div> - {/if} - </td> - <td> - <input - class="input input-bordered input-sm w-full" - value={editForm.title} - oninput={(e) => (editForm.title = e.currentTarget.value)} - /> - </td> - <td> - <input - class="input input-bordered input-sm w-full" - value={editForm.artist} - oninput={(e) => (editForm.artist = e.currentTarget.value)} - /> - </td> - <td> - <input - class="input input-bordered input-sm w-full" - value={editForm.album} - oninput={(e) => (editForm.album = e.currentTarget.value)} - /> - </td> - <td class="text-right tabular-nums text-base-content/50">{row.durationLabel}</td> - <td> - <span class="badge badge-ghost badge-sm uppercase">{row.format}</span> - </td> - <td class="text-right"> - <div class="join"> - <button - class="btn btn-xs btn-primary join-item" - disabled={!canSave} - onclick={saveEdit} - > - {#if renaming}<span class="loading loading-spinner loading-xs"></span>{/if} - Save - </button> - <button - class="btn btn-xs join-item" - disabled={renaming} - onclick={cancelEdit} - > - Cancel - </button> - </div> - </td> - {:else} - <td class="text-right tabular-nums text-base-content/60">{row.trackLabel}</td> - <td class="font-medium">{row.title}</td> - <td>{row.artist}</td> - <td class="text-base-content/70">{row.album}</td> - <td class="text-right tabular-nums">{row.durationLabel}</td> - <td> - <span class="badge badge-ghost badge-sm uppercase">{row.format}</span> - </td> - <td class="text-right"> - <button - class="btn btn-xs btn-ghost" - disabled={editingId !== null || busy} - onclick={() => { - const t = tracks.find((x) => x.id === row.id); - if (t) startEdit(t); - }} - > - Edit - </button> - </td> - {/if} + <th class="w-12 text-right">#</th> + <th>Title</th> + <th>Artist</th> + <th>Album</th> + <th class="text-right">Duration</th> + <th>Format</th> + <th class="text-right">Actions</th> </tr> - {/each} - </tbody> - </table> + </thead> + <tbody> + {#each rows as row (row.id)} + <tr> + {#if editingId === row.id} + <!-- edit mode --> + <td> + <input + class="input input-bordered input-xs w-14 text-center" + inputmode="numeric" + placeholder="—" + value={editForm.track} + oninput={(e) => (editForm.track = e.currentTarget.value)} + /> + {#if editTrackError} + <div class="mt-0.5 text-xs text-error">whole no. ≥ 1</div> + {/if} + </td> + <td> + <input + class="input input-bordered input-sm w-full" + value={editForm.title} + oninput={(e) => (editForm.title = e.currentTarget.value)} + /> + </td> + <td> + <input + class="input input-bordered input-sm w-full" + value={editForm.artist} + oninput={(e) => (editForm.artist = e.currentTarget.value)} + /> + </td> + <td> + <input + class="input input-bordered input-sm w-full" + value={editForm.album} + oninput={(e) => (editForm.album = e.currentTarget.value)} + /> + </td> + <td class="text-right tabular-nums text-base-content/50">{row.durationLabel}</td> + <td> + <span class="badge badge-ghost badge-sm uppercase">{row.format}</span> + </td> + <td class="text-right"> + <div class="join"> + <button + class="btn btn-xs btn-primary join-item" + disabled={!canSave} + onclick={saveEdit} + > + {#if renaming}<span class="loading loading-spinner loading-xs"></span>{/if} + Save + </button> + <button + class="btn btn-xs join-item" + disabled={renaming} + onclick={cancelEdit} + > + Cancel + </button> + </div> + </td> + {:else} + <td class="text-right tabular-nums text-base-content/60">{row.trackLabel}</td> + <td class="font-medium">{row.title}</td> + <td>{row.artist}</td> + <td class="text-base-content/70">{row.album}</td> + <td class="text-right tabular-nums">{row.durationLabel}</td> + <td> + <span class="badge badge-ghost badge-sm uppercase">{row.format}</span> + </td> + <td class="text-right"> + <div class="join"> + <button + class="btn btn-xs btn-ghost join-item {selectedTrackId === row.id + ? 'text-primary' + : ''}" + disabled={editingId !== null || busy || matchAlbumOpen} + onclick={() => { + const t = tracks.find((x) => x.id === row.id); + if (t) + selectedTrackId = selectedTrackId === row.id ? null : row.id; + }} + aria-pressed={selectedTrackId === row.id} + > + Lookup + </button> + <button + class="btn btn-xs btn-ghost join-item" + disabled={editingId !== null || busy || matchAlbumOpen} + onclick={() => { + const t = tracks.find((x) => x.id === row.id); + if (t) startEdit(t); + }} + > + Edit + </button> + </div> + </td> + {/if} + </tr> + {/each} + </tbody> + </table> + </div> + + <!-- metadata sidebar (right column) --> + <div> + {#if selectedTrack} + {#key selectedTrack.id} + <LibraryMetadataSidebar + track={selectedTrack} + api={metadataApi} + onfill={applyMetadata} + onclose={() => (selectedTrackId = null)} + /> + {/key} + {:else} + <div + class="rounded-box border border-dashed border-base-300 p-4 text-center text-sm text-base-content/50" + > + Pick <span class="font-medium text-base-content/70">Lookup</span> on a track to search + MusicBrainz, or use <span class="font-medium text-base-content/70">Match album</span> to fill + all at once. + </div> + {/if} + </div> </div> {#if renameError} @@ -302,3 +412,12 @@ {/if} </div> </div> + +{#if matchAlbumOpen} + <LibraryMatchAlbumDialog + tracks={tracks} + api={metadataApi} + onapply={applyMatchAlbum} + onclose={() => (matchAlbumOpen = false)} + /> +{/if} diff --git a/yt-music-dl-web/src/features/library/logic.test.ts b/yt-music-dl-web/src/features/library/logic.test.ts index 289ba82..908e38f 100644 --- a/yt-music-dl-web/src/features/library/logic.test.ts +++ b/yt-music-dl-web/src/features/library/logic.test.ts @@ -1,6 +1,16 @@ -import type { Track } from "@yt-music/contract"; +import type { + AlbumMatch, + AlbumMatchResult, + CutDraft, + MetadataResult, + ReleaseDetail, + ReleaseTrack, + Track, +} from "@yt-music/contract"; import { describe, expect, it } from "vitest"; import { + albumMatchToUpdates, + buildTrackSearch, EMPTY_ROWS, formatDuration, hasTrackInputError, @@ -9,7 +19,11 @@ import { toTrackEditForm, toTrackRow, toUpdateRequest, + toUpdateRequestFromItem, trackNumberLabel, + trackSidebarItems, + tracksToDraft, + trackToCurrentItem, updateTrack, } from "./logic.js"; @@ -254,4 +268,276 @@ describe("library logic (pure)", () => { expect(next).not.toBe(base); }); }); + + // ─── MusicBrainz metadata search helpers (pure) ──────────────────────────── + + describe("buildTrackSearch", () => { + it("uses the query when non-blank, track artist as the hint", () => { + const t: Track = { ...base, artist: "Track Artist" }; + const req = buildTrackSearch(t, "user query"); + expect(req).toEqual({ query: "user query", artist: "Track Artist", type: "recording" }); + }); + + it("falls back to the track title when the query is blank", () => { + const t: Track = { ...base, title: "Track Title", artist: "Track Artist" }; + const req = buildTrackSearch(t, " "); + expect(req.query).toBe("Track Title"); + expect(req.artist).toBe("Track Artist"); + expect(req.type).toBe("recording"); + }); + + it("omits artist when the track has no artist", () => { + const t: Track = { ...base, artist: "" }; + const req = buildTrackSearch(t, "free text"); + expect(req.query).toBe("free text"); + expect(req.artist).toBeUndefined(); + }); + + it("omits artist when both query fallback and artist are blank", () => { + const t: Track = { ...base, title: "", artist: "" }; + const req = buildTrackSearch(t, ""); + expect(req.query).toBe(""); + expect(req.artist).toBeUndefined(); + }); + }); + + describe("trackToCurrentItem", () => { + it("maps a track with all fields to a sidebar item", () => { + const t: Track = { ...base, track: 5 }; + const item = trackToCurrentItem(t); + expect(item).toEqual({ + source: "youtube", + title: "Song", + artist: "Artist", + album: "Album", + trackNumber: 5, + }); + }); + + it("omits trackNumber when the track has none", () => { + const item = trackToCurrentItem(base); + expect(item.trackNumber).toBeUndefined(); + expect(item.album).toBe("Album"); + }); + }); + + describe("trackSidebarItems", () => { + it("puts the current-tags entry first, then MB results in order", () => { + const t: Track = { ...base, track: 3 }; + const r1: MetadataResult = { + id: "m1", + type: "recording", + title: "MB One", + artist: "A", + score: 80, + }; + const r2: MetadataResult = { + id: "m2", + type: "recording", + title: "MB Two", + artist: "B", + score: 70, + }; + const items = trackSidebarItems(t, [r1, r2]); + expect(items).toHaveLength(3); + expect(items[0]?.source).toBe("youtube"); + expect(items[0]?.title).toBe("Song"); + expect(items[0]?.trackNumber).toBe(3); + expect(items[1]?.title).toBe("MB One"); + expect(items[2]?.title).toBe("MB Two"); + }); + + it("has the current entry even with no MB results", () => { + const items = trackSidebarItems(base, []); + expect(items).toHaveLength(1); + expect(items[0]?.source).toBe("youtube"); + }); + }); + + describe("toUpdateRequestFromItem", () => { + it("returns an empty body for the current-tags entry (no-op)", () => { + const item = trackToCurrentItem(base); + expect(toUpdateRequestFromItem(item)).toEqual({}); + }); + + it("sets title + artist always, album + track when present", () => { + const r: MetadataResult = { + id: "m1", + type: "recording", + title: "MB Title", + artist: "MB Artist", + album: "MB Album", + trackNumber: 9, + score: 88, + }; + const items = trackSidebarItems(base, [r]); + const mbItem = items[1]; + if (!mbItem) throw new Error("expected an MB item"); + const req = toUpdateRequestFromItem(mbItem); + expect(req).toEqual({ + title: "MB Title", + artist: "MB Artist", + album: "MB Album", + track: 9, + }); + }); + + it("omits album when the result has no album", () => { + const r: MetadataResult = { + id: "m1", + type: "recording", + title: "T", + artist: "A", + score: 70, + }; + const items = trackSidebarItems(base, [r]); + const mbItem = items[1]; + if (!mbItem) throw new Error("expected an MB item"); + const req = toUpdateRequestFromItem(mbItem); + expect(req).toEqual({ title: "T", artist: "A" }); + expect("album" in req).toBe(false); + }); + + it("omits track when the result has no trackNumber", () => { + const r: MetadataResult = { + id: "m1", + type: "recording", + title: "T", + artist: "A", + album: "Al", + score: 70, + }; + const items = trackSidebarItems(base, [r]); + const mbItem = items[1]; + if (!mbItem) throw new Error("expected an MB item"); + const req = toUpdateRequestFromItem(mbItem); + expect(req).toEqual({ title: "T", artist: "A", album: "Al" }); + expect("track" in req).toBe(false); + }); + }); + + // ─── Match Album helpers (pure) ──────────────────────────────────────────── + + describe("tracksToDraft", () => { + it("converts each track to a segment, preserving order and id", () => { + const tracks: Track[] = [ + { ...base, id: "t1", title: "A", artist: "X", album: "L", track: 1, duration: 100 }, + { ...base, id: "t2", title: "B", artist: "Y", album: "M", track: 2, duration: 200 }, + ]; + const draft: CutDraft = tracksToDraft(tracks); + expect(draft.segments).toHaveLength(2); + expect(draft.segments[0]?.id).toBe("t1"); + expect(draft.segments[0]?.title).toBe("A"); + expect(draft.segments[0]?.artist).toBe("X"); + expect(draft.segments[0]?.album).toBe("L"); + expect(draft.segments[0]?.trackNumber).toBe(1); + expect(draft.segments[0]?.end).toBe(100); + expect(draft.segments[1]?.id).toBe("t2"); + expect(draft.segments[1]?.trackNumber).toBe(2); + }); + + it("defaults trackNumber to 0 when the track has none", () => { + const draft = tracksToDraft([base]); + expect(draft.segments[0]?.trackNumber).toBe(0); + }); + + it("returns an empty draft for no tracks", () => { + const draft = tracksToDraft([]); + expect(draft.segments).toEqual([]); + }); + }); + + describe("albumMatchToUpdates", () => { + function relTrack(position: number, title: string): ReleaseTrack { + return { position, title, recordingId: `rec-${position}` }; + } + function rel(id: string, title: string, artist: string, tracks: ReleaseTrack[]): ReleaseDetail { + return { id, title, artist, tracks }; + } + function match( + segmentIndex: number, + t: ReleaseTrack, + confidence: AlbumMatch["confidence"], + ): AlbumMatch { + return { segmentIndex, track: t, confidence }; + } + + it("fills every position/title match; skips none", () => { + const tracks: Track[] = [ + { ...base, id: "t1", title: "A", duration: 100 }, + { ...base, id: "t2", title: "B", duration: 200 }, + ]; + const release = rel("rel1", "Album Title", "Album Artist", [ + relTrack(1, "Track One"), + relTrack(2, "Track Two"), + ]); + const result: AlbumMatchResult = { + matches: [ + match(0, relTrack(1, "Track One"), "position"), + match(1, relTrack(2, "Track Two"), "position"), + ], + }; + const updates = albumMatchToUpdates(tracks, release, result); + expect(updates).toHaveLength(2); + expect(updates[0]).toEqual({ + id: "t1", + request: { + title: "Track One", + artist: "Album Artist", + album: "Album Title", + track: 1, + }, + }); + expect(updates[1]).toEqual({ + id: "t2", + request: { + title: "Track Two", + artist: "Album Artist", + album: "Album Title", + track: 2, + }, + }); + }); + + it("skips none-confidence matches", () => { + const tracks: Track[] = [ + { ...base, id: "t1", title: "A", duration: 100 }, + { ...base, id: "t2", title: "B", duration: 200 }, + ]; + const release = rel("rel1", "Album", "Artist", [relTrack(1, "One"), relTrack(2, "Two")]); + const result: AlbumMatchResult = { + matches: [match(0, relTrack(1, "One"), "title"), match(1, relTrack(2, "Two"), "none")], + }; + const updates = albumMatchToUpdates(tracks, release, result); + expect(updates).toHaveLength(1); + expect(updates[0]?.id).toBe("t1"); + }); + + it("skips matches whose segmentIndex is out of range", () => { + const tracks: Track[] = [{ ...base, id: "t1", title: "A", duration: 100 }]; + const release = rel("rel1", "Album", "Artist", [relTrack(1, "A")]); + const result: AlbumMatchResult = { + matches: [match(5, relTrack(1, "A"), "position")], + }; + expect(albumMatchToUpdates(tracks, release, result)).toEqual([]); + }); + + it("returns no updates for an empty match list", () => { + const tracks: Track[] = [{ ...base, id: "t1", title: "A", duration: 100 }]; + const release = rel("rel1", "Album", "Artist", []); + expect(albumMatchToUpdates(tracks, release, { matches: [] })).toEqual([]); + }); + + it("uses release.title + release.artist for album + artist fields", () => { + const tracks: Track[] = [{ ...base, id: "t1", title: "A", duration: 100 }]; + const release = rel("rel1", "The Album", "The Artist", [relTrack(3, "Track")]); + const result: AlbumMatchResult = { + matches: [match(0, relTrack(3, "Track"), "position")], + }; + const updates = albumMatchToUpdates(tracks, release, result); + expect(updates[0]?.request.album).toBe("The Album"); + expect(updates[0]?.request.artist).toBe("The Artist"); + expect(updates[0]?.request.track).toBe(3); + }); + }); }); diff --git a/yt-music-dl-web/src/features/library/logic.ts b/yt-music-dl-web/src/features/library/logic.ts index a02ef8e..e6d3695 100644 --- a/yt-music-dl-web/src/features/library/logic.ts +++ b/yt-music-dl-web/src/features/library/logic.ts @@ -1,14 +1,43 @@ /** - * src/features/library/logic.ts — PURE: library view-model + rename reducers. + * src/features/library/logic.ts — PURE: library view-model + rename reducers + * + MusicBrainz metadata search helpers. * * No DOM, no fetch, no WebSocket — pure (input → output). Unit-tested with * zero mocks (dispatch "pure core" principle). The Svelte component is a thin * wrapper over this: it fetches the raw `Track[]` (via the injected adapter), * derives a display model here, and builds rename PATCH bodies via * `toUpdateRequest` (only changed fields) / applies them optimistically via - * `updateTrack`. + * `updateTrack`. The MusicBrainz sidebar + match-album helpers + * (buildTrackSearch, trackSidebarItems, toUpdateRequestFromItem, tracksToDraft, + * albumMatchToUpdates) mirror the segment editor's metadata.ts but operate on + * a Track and produce UpdateTrackRequest bodies; the shared search state + * machine + SidebarItem view-model are re-exported from metadata.ts. */ -import type { Track, UpdateTrackRequest } from "@yt-music/contract"; +import type { + AlbumArtRef, + AlbumMatchResult, + CutDraft, + MetadataResult, + MetadataSearchRequest, + ReleaseDetail, + Track, + UpdateTrackRequest, +} from "@yt-music/contract"; +import type { SidebarItem } from "../segment-editor/metadata.js"; +import { toSidebarItem } from "../segment-editor/metadata.js"; + +// Re-export the shared search state machine + sidebar view-model from the +// segment editor's pure metadata module, so the library's sidebar + match-album +// components import everything from one place (./logic.js). No runtime circular +// dependency: metadata.ts's only import from logic.ts is `import type`, erased. +export type { SearchEvent, SearchState, SidebarItem } from "../segment-editor/metadata.js"; +export { + buildReleaseSearch, + initialSearchState, + matchedSegmentCount, + reduceSearch, + toSidebarItem, +} from "../segment-editor/metadata.js"; /** * One row in the library browse view. Derived from a `Track` (GLOSSARY: a @@ -146,3 +175,153 @@ export function updateTrack(track: Track, fields: UpdateTrackRequest): Track { ...(fields.track !== undefined ? { track: fields.track } : {}), }; } + +// ─── MusicBrainz metadata search (pure) ────────────────────────────────────── +// +// These mirror the segment editor's metadata.ts helpers (buildRecordingSearch, +// youtubeItem, sidebarItems, fillActions, albumMatchActions) but operate on a +// library Track (not a SegmentDraft) and produce UpdateTrackRequest bodies +// (not EditAction[]). The shared search state machine (SearchState, reduceSearch, +// initialSearchState) + the SidebarItem view-model + toSidebarItem are reused +// directly from ../segment-editor/metadata.js — no duplication. (No runtime +// circular dependency: metadata.ts's only import from logic.ts is +// `import type { EditAction }`, erased at runtime.) + +/** + * Build the recording-search request for a track's metadata sidebar. `query` + * is the search-box text (falling back to the track's title when blank); + * `artist` is the track's artist, omitted when blank. Mirrors + * buildRecordingSearch but for a library Track (no global-artist fallback — + * each track owns its own artist). Pure: (track, query) → request. + */ +export function buildTrackSearch(track: Track, query: string): MetadataSearchRequest { + const q = query.trim() || track.title.trim(); + const artist = track.artist.trim(); + // exactOptionalPropertyTypes: omit `artist` rather than set undefined. + return artist ? { query: q, artist, type: "recording" } : { query: q, type: "recording" }; +} + +/** + * The "Current tags" sidebar entry: the track's existing metadata. Mirrors the + * segment editor's youtubeItem (the "Generated from YouTube" entry) but for a + * library Track. Uses source: "youtube" so the sidebar treats it as the + * non-fillable current entry (toUpdateRequestFromItem returns an empty body + * for it); the library sidebar component renders it with a "Current" badge. + * Pure: (track) → item. + */ +export function trackToCurrentItem(track: Track): SidebarItem { + const item: SidebarItem = { + source: "youtube", + title: track.title, + artist: track.artist, + album: track.album, + }; + if (track.track !== undefined) item.trackNumber = track.track; + return item; +} + +/** + * The full sidebar list for a track: the "Current tags" entry first, then the + * MB recording-search results in the backend's relevance order. Mirrors + * sidebarItems from metadata.ts. Pure: (track, results) → items. + */ +export function trackSidebarItems(track: Track, results: MetadataResult[]): SidebarItem[] { + return [trackToCurrentItem(track), ...results.map(toSidebarItem)]; +} + +/** + * Build a PATCH /api/library/:id body (UpdateTrackRequest) from a clicked + * sidebar item. The "Current tags" entry (source: "youtube") produces an empty + * body (no-op); an MB result sets title + artist always, album + track only + * when the result actually carries them. Mirrors fillActions from metadata.ts + * but produces an UpdateTrackRequest (a PATCH body) instead of EditAction[] + * (in-memory draft edits). Pure: (item) → request. + */ +export function toUpdateRequestFromItem(item: SidebarItem): UpdateTrackRequest { + if (item.source === "youtube") return {}; // no-op — current tags + const req: UpdateTrackRequest = { + title: item.title, + artist: item.artist, + }; + if (item.album !== undefined) req.album = item.album; + if (item.trackNumber !== undefined) req.track = item.trackNumber; + return req; +} + +// ─── Match Album (pure) ────────────────────────────────────────────────────── + +/** + * Convert library tracks to a CutDraft so the backend's match-album endpoint + * (which expects a CutDraft) can match them against a release's track list. + * Each track becomes a minimal SegmentDraft — only the fields the matcher uses + * (title, artist, album, trackNumber) carry real data; albumArt/start/end/ + * removedSegments are dummies (the matcher ignores them). The segment id is + * set to the track id, and the array order is preserved so matchResult + * segmentIndex references align with the tracks array. Pure: (tracks) → draft. + */ +export function tracksToDraft(tracks: Track[]): CutDraft { + const dummyArt: AlbumArtRef = { kind: "video-thumbnail" }; + return { + sourceVideoId: "", + sourceDuration: 0, + globalAlbum: "", + globalArtist: "", + globalAlbumArt: dummyArt, + segments: tracks.map((t) => ({ + id: t.id, + title: t.title, + artist: t.artist, + album: t.album, + // SegmentDraft.trackNumber is a required number; Track.track is + // optional. Default to 0 (the matcher uses segmentIndex for + // position matching, not trackNumber). + trackNumber: t.track ?? 0, + albumArt: dummyArt, + start: 0, + end: t.duration, + removedSegments: [], + })), + }; +} + +/** + * Per-track update produced by an album match: the track's id + the PATCH body + * to apply. Used by the LibraryMatchAlbumDialog's onapply callback. + */ +export interface TrackUpdate { + id: string; + request: UpdateTrackRequest; +} + +/** + * Convert an album match result to per-track UpdateTrackRequests. For every + * match whose `confidence` is "position" or "title", build a PATCH body + * setting title = track.title, artist = release.artist, album = release.title, + * track = track.position. "none"-confidence matches are skipped (those tracks + * are left as-is). Matches reference tracks by `segmentIndex`, which aligns + * with the tracks array (tracksToDraft preserves order). Mirrors + * albumMatchActions from metadata.ts but produces TrackUpdate[] instead of + * EditAction[]. Pure: (tracks, release, matchResult) → updates. + */ +export function albumMatchToUpdates( + tracks: Track[], + release: ReleaseDetail, + matchResult: AlbumMatchResult, +): TrackUpdate[] { + const updates: TrackUpdate[] = []; + for (const match of matchResult.matches) { + if (match.confidence === "none") continue; + const track = tracks[match.segmentIndex]; + if (!track) continue; // index out of range → skip defensively + updates.push({ + id: track.id, + request: { + title: match.track.title, + artist: release.artist, + album: release.title, + track: match.track.position, + }, + }); + } + return updates; +} |
