From 6d719656b10a67638952a30d61d7aa4fa6bb3ae5 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 17 Oct 2026 10:08:17 +0200 Subject: [PATCH 1/2] fix(web): keep a track listed twice as two rows with their own rating A playlist stores positions, so one track can appear on it twice. SongTable keyed its rows and named each rating's radio group by the track id alone: the two copies shared a React key, and their radios formed one group, so the browser kept a single checked star and the first copy showed no rating. Both now include the row's position. Signed-off-by: InstaZDLL --- webapp/e2e/studio-nocturne.spec.ts | 36 ++++++++++++++++++++++++++++ webapp/src/components/song-table.tsx | 7 ++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 636a5e3..55368bf 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -1506,6 +1506,42 @@ test("saves a playlist as an .m3u8 the next player can read", async ({ expect(exportAsks).toEqual(["/api/v2/playlists/playlist-1/export"]); }); +/** + * A playlist holds a position, not a set: the same track can be on it twice. + * Each row's rating is a radio group named after the row, and a name built + * from the track alone put both copies in one group — the browser keeps one + * checked radio per group, so the first copy showed no rating at all. + */ +test("shows the same track twice in a playlist, each with its rating", async ({ + page, +}) => { + await page.route("**/api/v2/playlists", (route) => + route.fulfill({ + json: [ + { + ...playlists[0], + songs: [ + song(1, "Hidden Place", 5, true), + song(2, "Cocoon", 0, false), + song(1, "Hidden Place", 5, true), + ], + }, + ], + }), + ); + await page.goto("/playlists"); + await expect(page.getByRole("heading", { name: "Evening" })).toBeVisible(); + + const copies = page.getByRole("group", { name: "Rating: Hidden Place" }); + await expect(copies).toHaveCount(2); + await expect( + copies.first().getByRole("radio", { name: "5 stars" }), + ).toBeChecked(); + await expect( + copies.last().getByRole("radio", { name: "5 stars" }), + ).toBeChecked(); +}); + /** * An artist page carries what this account played, and nothing when it played * nothing. diff --git a/webapp/src/components/song-table.tsx b/webapp/src/components/song-table.tsx index 838b927..b46b736 100644 --- a/webapp/src/components/song-table.tsx +++ b/webapp/src/components/song-table.tsx @@ -131,7 +131,10 @@ export function SongTable({ const starred = stars[song.id] ?? song.starred_at !== null; const active = player.current?.id === song.id; return ( - + {numbering === "track" ? (song.track ?? position + 1) @@ -160,7 +163,7 @@ export function SongTable({
void rate(song, rating)} /> From 853a5974c61745826f10824f462d4b9b40129c1d Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 17 Oct 2026 10:17:16 +0200 Subject: [PATCH 2/2] fix(web): tell a repeated track's rows apart by copy, not by index Biome refuses an array index in a key (noArrayIndexKey), and an index also moves every row after an insertion. Each row is now named by the track id and how many copies of it came before, which is stable under any change that does not touch that track. Signed-off-by: InstaZDLL --- webapp/src/components/song-table.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/song-table.tsx b/webapp/src/components/song-table.tsx index b46b736..44fcc4f 100644 --- a/webapp/src/components/song-table.tsx +++ b/webapp/src/components/song-table.tsx @@ -123,6 +123,15 @@ export function SongTable({ } } + // A playlist can hold the same track twice, so the track id alone does not + // name a row: each copy is told apart by how many came before it. + const seen = new Map(); + const rowIds = songs.map((song) => { + const copy = seen.get(song.id) ?? 0; + seen.set(song.id, copy + 1); + return `${song.id}#${copy}`; + }); + return (
@@ -132,7 +141,7 @@ export function SongTable({ const active = player.current?.id === song.id; return (
@@ -163,7 +172,7 @@ export function SongTable({
void rate(song, rating)} />