diff options
| author | Adam Malczewski <[email protected]> | 2026-07-01 22:16:09 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-07-01 22:16:52 +0900 |
| commit | b476f7703c594e9c8ff1b1ecc77f8e2493ef04b4 (patch) | |
| tree | 22971336f0a7a1ec68f7b9b4f24c80d159ea1118 /yt-music-dl-web | |
| parent | 6402a83fe83a0b1a9f5959ff7f45d07d3e309c6e (diff) | |
| download | yt-walkerman-main.tar.gz yt-walkerman-main.zip | |
Diffstat (limited to 'yt-music-dl-web')
7 files changed, 161 insertions, 6 deletions
diff --git a/yt-music-dl-web/backend-handoff.md b/yt-music-dl-web/backend-handoff.md index 324e52b..6a8dad3 100644 --- a/yt-music-dl-web/backend-handoff.md +++ b/yt-music-dl-web/backend-handoff.md @@ -89,6 +89,18 @@ through the existing `onaction` channel as a batch of `EditAction`s — no new callback prop. Loading states shown throughout (MB is rate-limited to ~1 req/sec server-side; each search may take ~1s). + - **Phase 8 (album art):** the `MetadataResult.artUrl` (Cover Art Archive + front-cover URL) now flows through the sidebar view-model end-to-end. + `SidebarItem` gained `artUrl?: string`, mapped by `toSidebarItem`. + Click-to-fill (`fillActions`) emits an `editSegmentAlbumArt` action with a + url-kind `AlbumArtRef` when the selected result has `artUrl` — the + existing reducer already handles it (sets `segment.albumArt`). Both + sidebar components (`MetadataSidebar.svelte`, + `LibraryMetadataSidebar.svelte`) render a 20x20 thumbnail (`<img + src={item.artUrl} loading="lazy">`) next to result titles when `artUrl` + is present — visual confirmation before clicking. The library sidebar's + `toUpdateRequestFromItem` includes `artUrl` in the `UpdateTrackRequest` + PATCH body so the backend downloads + embeds the cover. ## Pinned contract version @@ -116,6 +128,14 @@ `/api/metadata/*` endpoints (search, release/:mbid, match-album) as specified, including the 502 `{ error }` error envelope (read by `metadataApi.ts`). +- **Phase 8 consumed the backend's new optional `artUrl` fields verbatim** + (backend commit `6402a83`) — `MetadataResult.artUrl?: string` and + `UpdateTrackRequest.artUrl?: string`. No FE-side contract change was + required; the `file:` dep resolved the backend's latest `src/` directly. + The FE added `artUrl` to the `SidebarItem` view-model, the `fillActions` + click-to-fill (emits `editSegmentAlbumArt` with a url-kind `AlbumArtRef`), + the `toUpdateRequestFromItem` PATCH body, and thumbnail rendering in both + sidebar components. ## Open asks for the backend @@ -189,6 +209,18 @@ `searchState = reduceSearch(searchState, …)`). Match-album sends the CURRENT draft to the backend; because the dialog is modal (no edits occur mid-dialog), the backend's `segmentIndex` references stay aligned with the draft the FE applies against. +- **Phase 8:** the `artUrl` is OPTIONAL on `MetadataResult` (a recording with no + releases has none), so every code path guards with `!== undefined` / + `{#if item.artUrl}`. The `SidebarItem` view-model carries `artUrl` alongside + the existing optional fields (`album`, `trackNumber`, `score`, `id`), + conditionally included (same `exactOptionalPropertyTypes` discipline). The + `editSegmentAlbumArt` action (and its reducer case) already existed — Phase 8 + just emits it from `fillActions` for the first time. The thumbnail `<img>` + uses `loading="lazy"` so off-screen CAA images don't fire until scrolled into + view; the CAA URL returns a 307→JPEG, so a plain `<img src>` works with no + proxy. `updateTrack` (the optimistic local update) does NOT apply `artUrl` — + it is not a `Track` field; the backend handles it as a side-effect + (download + embed + re-tag + move) and returns the authoritative track. ## Likely next asks diff --git a/yt-music-dl-web/src/features/library/LibraryMetadataSidebar.svelte b/yt-music-dl-web/src/features/library/LibraryMetadataSidebar.svelte index 99d7121..aef4165 100644 --- a/yt-music-dl-web/src/features/library/LibraryMetadataSidebar.svelte +++ b/yt-music-dl-web/src/features/library/LibraryMetadataSidebar.svelte @@ -222,7 +222,17 @@ <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> + <div class="mt-1 flex items-center gap-1.5"> + {#if item.artUrl} + <img + src={item.artUrl} + alt="" + class="h-5 w-5 shrink-0 rounded object-cover" + loading="lazy" + /> + {/if} + <p class="truncate text-sm font-medium">{item.title || "(untitled)"}</p> + </div> <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} 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 4f84bef..388d76e 100644 --- a/yt-music-dl-web/src/features/library/logic.test.ts +++ b/yt-music-dl-web/src/features/library/logic.test.ts @@ -418,6 +418,37 @@ describe("library logic (pure)", () => { expect(req).toEqual({ title: "T", artist: "A", album: "Al" }); expect("track" in req).toBe(false); }); + + it("includes artUrl when the result has one", () => { + const r: MetadataResult = { + id: "m1", + type: "recording", + title: "T", + artist: "A", + score: 70, + artUrl: "https://coverartarchive.org/release/abc/front", + }; + const items = trackSidebarItems(base, [r]); + const mbItem = items[1]; + if (!mbItem) throw new Error("expected an MB item"); + const req = toUpdateRequestFromItem(mbItem); + expect(req.artUrl).toBe("https://coverartarchive.org/release/abc/front"); + }); + + it("omits artUrl when the result has none", () => { + 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("artUrl" in req).toBe(false); + }); }); // ─── Match Album helpers (pure) ──────────────────────────────────────────── diff --git a/yt-music-dl-web/src/features/library/logic.ts b/yt-music-dl-web/src/features/library/logic.ts index fc29b37..4d3676c 100644 --- a/yt-music-dl-web/src/features/library/logic.ts +++ b/yt-music-dl-web/src/features/library/logic.ts @@ -234,9 +234,11 @@ export function trackSidebarItems(track: Track, results: MetadataResult[]): Side * 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. + * when the result actually carries them. When the result has a Cover Art + * Archive `artUrl`, it is included so the backend downloads + embeds the + * front cover. 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 @@ -246,6 +248,7 @@ export function toUpdateRequestFromItem(item: SidebarItem): UpdateTrackRequest { }; if (item.album !== undefined) req.album = item.album; if (item.trackNumber !== undefined) req.track = item.trackNumber; + if (item.artUrl !== undefined) req.artUrl = item.artUrl; return req; } diff --git a/yt-music-dl-web/src/features/segment-editor/MetadataSidebar.svelte b/yt-music-dl-web/src/features/segment-editor/MetadataSidebar.svelte index 8b3b956..45944e7 100644 --- a/yt-music-dl-web/src/features/segment-editor/MetadataSidebar.svelte +++ b/yt-music-dl-web/src/features/segment-editor/MetadataSidebar.svelte @@ -182,7 +182,17 @@ <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> + <div class="mt-1 flex items-center gap-1.5"> + {#if item.artUrl} + <img + src={item.artUrl} + alt="" + class="h-5 w-5 shrink-0 rounded object-cover" + loading="lazy" + /> + {/if} + <p class="truncate text-sm font-medium">{item.title || "(untitled)"}</p> + </div> <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} diff --git a/yt-music-dl-web/src/features/segment-editor/metadata.test.ts b/yt-music-dl-web/src/features/segment-editor/metadata.test.ts index b917195..c4ff8dc 100644 --- a/yt-music-dl-web/src/features/segment-editor/metadata.test.ts +++ b/yt-music-dl-web/src/features/segment-editor/metadata.test.ts @@ -265,6 +265,23 @@ describe("sidebarItems (pure)", () => { expect(item.score).toBe(50); }); + it("toSidebarItem maps artUrl when the result has one", () => { + const r: MetadataResult = { + id: "m1", + type: "recording", + title: "T", + artist: "A", + score: 70, + artUrl: "https://coverartarchive.org/release/abc/front", + }; + expect(toSidebarItem(r).artUrl).toBe("https://coverartarchive.org/release/abc/front"); + }); + + it("toSidebarItem omits artUrl when the result has none", () => { + const r: MetadataResult = { id: "m1", type: "recording", title: "T", artist: "A", score: 70 }; + expect(toSidebarItem(r).artUrl).toBeUndefined(); + }); + it("youtubeItem reflects the segment's current parsed fields", () => { const item = youtubeItem(seg("s1", "Title", "Artist", "Album", 5)); expect(item).toEqual({ @@ -329,10 +346,50 @@ describe("fillActions (pure)", () => { expect(actions.some((a) => a.type === "editSegmentTrackNumber")).toBe(false); }); + it("emits editSegmentAlbumArt when the result has artUrl", () => { + const r: MetadataResult = { + id: "m1", + type: "recording", + title: "T", + artist: "A", + score: 70, + artUrl: "https://coverartarchive.org/release/abc/front", + }; + const actions = fillActions("s1", toSidebarItem(r)); + expect(actions).toContainEqual({ + type: "editSegmentAlbumArt", + segmentId: "s1", + albumArt: { kind: "url", url: "https://coverartarchive.org/release/abc/front" }, + }); + }); + + it("omits editSegmentAlbumArt when the result has no artUrl", () => { + const r: MetadataResult = { id: "m1", type: "recording", title: "T", artist: "A", score: 70 }; + const actions = fillActions("s1", toSidebarItem(r)); + expect(actions.some((a) => a.type === "editSegmentAlbumArt")).toBe(false); + }); + it("fillFromResult matches fillActions(toSidebarItem)", () => { const r = mbResult("m1", "T", "A", "Al", 2, 90); expect(fillFromResult("s1", r)).toEqual(fillActions("s1", toSidebarItem(r))); }); + + it("fillFromResult carries artUrl through to an editSegmentAlbumArt action", () => { + const r: MetadataResult = { + id: "m1", + type: "recording", + title: "T", + artist: "A", + score: 80, + artUrl: "https://coverartarchive.org/release/xyz/front", + }; + expect(fillFromResult("s1", r)).toEqual(fillActions("s1", toSidebarItem(r))); + expect(fillFromResult("s1", r)).toContainEqual({ + type: "editSegmentAlbumArt", + segmentId: "s1", + albumArt: { kind: "url", url: "https://coverartarchive.org/release/xyz/front" }, + }); + }); }); // ─── match-album → actions ─────────────────────────────────────────────────── diff --git a/yt-music-dl-web/src/features/segment-editor/metadata.ts b/yt-music-dl-web/src/features/segment-editor/metadata.ts index 530893a..7838686 100644 --- a/yt-music-dl-web/src/features/segment-editor/metadata.ts +++ b/yt-music-dl-web/src/features/segment-editor/metadata.ts @@ -137,6 +137,8 @@ export interface SidebarItem { score?: number; /** MusicBrainz MBID; absent for the YouTube entry. */ id?: string; + /** Cover Art Archive front-cover URL; absent when the result has none. */ + artUrl?: string; } /** The "Generated from YouTube" entry: the segment's current parsed fields. Pure. */ @@ -163,6 +165,7 @@ export function toSidebarItem(result: MetadataResult): SidebarItem { }; if (result.album !== undefined) item.album = result.album; if (result.trackNumber !== undefined) item.trackNumber = result.trackNumber; + if (result.artUrl !== undefined) item.artUrl = result.artUrl; return item; } @@ -183,7 +186,9 @@ export function sidebarItems(segment: SegmentDraft, results: MetadataResult[]): * already mirrors the segment's current parsed values) → no edits. * - MusicBrainz result: sets title + artist always; album + trackNumber only * when the result actually carries them (MB omits these from lightweight - * recording search entries). + * recording search entries). When the result has a Cover Art Archive + * `artUrl`, emits an `editSegmentAlbumArt` action with a url-kind + * `AlbumArtRef` so the backend downloads + embeds the front cover. * * Pure: (segmentId, item) → actions. Dispatched by the component through * `onaction`; the composition root folds each over the pure `reduce`. @@ -200,6 +205,13 @@ export function fillActions(segmentId: string, item: SidebarItem): EditAction[] if (item.trackNumber !== undefined) { actions.push({ type: "editSegmentTrackNumber", segmentId, trackNumber: item.trackNumber }); } + if (item.artUrl !== undefined) { + actions.push({ + type: "editSegmentAlbumArt", + segmentId, + albumArt: { kind: "url", url: item.artUrl }, + }); + } return actions; } |
