summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--yt-music-dl-web/src/App.svelte2
-rw-r--r--yt-music-dl-web/src/features/library/LibraryMatchAlbumDialog.svelte299
-rw-r--r--yt-music-dl-web/src/features/library/LibraryMetadataSidebar.svelte241
-rw-r--r--yt-music-dl-web/src/features/library/LibraryView.svelte347
-rw-r--r--yt-music-dl-web/src/features/library/logic.test.ts288
-rw-r--r--yt-music-dl-web/src/features/library/logic.ts185
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;
+}