Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/rfcs/RFC-013-chosen-artist-imagery.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
- **Status**: Proposed
- **Implemented by**: in progress, in the order of "The order of the work"
below: [#308](https://github.com/InstaZDLL/waveflow-server/pull/308) (this
RFC) and [#309](https://github.com/InstaZDLL/waveflow-server/pull/309)
RFC), [#309](https://github.com/InstaZDLL/waveflow-server/pull/309)
(serving the choice: `artist_image_choice`, the effective portrait,
`banner_hash`, the upload and removal routes). The
`banner_hash`, the upload and removal routes) and
[#310](https://github.com/InstaZDLL/waveflow-server/pull/310) (drawing it in
the web client: the banner behind the artist header, and "Change
portrait" / "Change banner" for owners and managers). The
*Status* field above never flips in this project; this line is the one that
says what is running.
- **Date**: 2026-10-23
Expand Down
130 changes: 130 additions & 0 deletions webapp/e2e/studio-nocturne.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5331,3 +5331,133 @@ test("closes a folder's form when another folder is opened", async ({ page }) =>
await expect(page.getByLabel("Folder name")).toHaveCount(0);
expect(state.writes).toEqual([]);
});

/**
* A stand-in for the artist routes of RFC-013 with state: the page re-reads
* after every write, and what it shows must be what the write did.
*/
async function serveArtistImages(page: Page) {
const state = {
portrait: null as string | null,
banner: null as string | null,
writes: [] as Array<{ method: string; kind: string; bytes: number }>,
refuseNext: false,
};
const detail = () => ({
...artistDetail,
library_id: "library-1",
artwork_hash: state.portrait ?? "f".repeat(64),
...(state.banner ? { banner_hash: state.banner } : {}),
...(state.portrait || state.banner
? {
image_choice: {
...(state.portrait ? { portrait: { source: "upload", set_at: 1 } } : {}),
...(state.banner ? { banner: { source: "upload", set_at: 1 } } : {}),
},
}
: {}),
});
await page.route("**/api/v2/artists/artist-1", (route) =>
route.fulfill({ json: detail() }),
);
await page.route("**/api/v2/artists/artist-1/image/*", async (route) => {
const request = route.request();
const kind = new URL(request.url()).pathname.split("/").at(-1) ?? "";
const bytes = request.postDataBuffer()?.length ?? 0;
state.writes.push({ method: request.method(), kind, bytes });
if (request.method() === "DELETE") {
if (kind === "portrait") state.portrait = null;
else state.banner = null;
await route.fulfill({ status: 204, body: "" });
return;
}
if (state.refuseNext) {
state.refuseNext = false;
await route.fulfill({ status: 422, json: { error: "validation" } });
return;
}
const hash = (kind === "portrait" ? "b" : "c").repeat(64);
if (kind === "portrait") state.portrait = hash;
else state.banner = hash;
await route.fulfill({ json: detail() });
});
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);
await page.route("**/api/v2/artwork/*", (route) =>
route.fulfill({ contentType: "image/png", body: png }),
);
return { state, png };
}

/**
* Choosing an artist's banner and portrait by hand (RFC-013 step 3): the file
* goes as its own bytes, the header draws the banner behind it, a chosen
* portrait says so, and each can go back to automatic.
*/
test("changes an artist's banner and portrait from the artist page", async ({
page,
}) => {
const { state, png } = await serveArtistImages(page);
await page.goto("/artists/artist-1");
const hero = page.locator(".artist-hero");
await expect(hero.getByRole("button", { name: "Change portrait" })).toBeVisible();
await expect(hero.getByRole("button", { name: "Change banner" })).toBeVisible();
await expect(hero.getByRole("button", { name: "No banner" })).toHaveCount(0);
// With no banner, the portrait is the backdrop, blurred.
await expect(hero.locator(".artist-hero-ambient")).toBeAttached();

await hero.getByLabel("Change banner").setInputFiles({
name: "banner.png",
mimeType: "image/png",
buffer: png,
});
await expect(hero.getByRole("status")).toHaveText("Banner changed.");
await expect(hero.locator("img.artist-banner")).toBeVisible();
await expect(hero.locator(".artist-hero-ambient")).toHaveCount(0);
expect(state.writes).toEqual([{ method: "PUT", kind: "banner", bytes: png.length }]);
await expectNoWcagViolations(page);

await hero.getByLabel("Change portrait").setInputFiles({
name: "portrait.png",
mimeType: "image/png",
buffer: png,
});
await expect(hero.getByRole("status")).toHaveText("Portrait changed.");
await expect(hero.getByText("Portrait chosen by hand")).toBeVisible();

// A file the server refuses is said, and nothing on screen changes.
state.refuseNext = true;
await hero.getByLabel("Change portrait").setInputFiles({
name: "notes.png",
mimeType: "image/png",
buffer: Buffer.from("not an image"),
});
await expect(hero.getByRole("alert")).toHaveText("Choose a JPEG, PNG or WebP image.");

// Back to automatic: no banner, and the fetched portrait again.
await hero.getByRole("button", { name: "No banner" }).click();
await expect(hero.getByRole("status")).toHaveText("Banner removed.");
await expect(hero.locator("img.artist-banner")).toHaveCount(0);
await hero.getByRole("button", { name: "Automatic portrait" }).click();
await expect(hero.getByRole("status")).toHaveText("Back to the automatic portrait.");
await expect(hero.getByText("Portrait chosen by hand")).toHaveCount(0);
expect(state.writes.slice(-2)).toEqual([
{ method: "DELETE", kind: "banner", bytes: 0 },
{ method: "DELETE", kind: "portrait", bytes: 0 },
]);
});

/** A listener sees the chosen banner and is offered nothing to change. */
test("shows a listener the artist's banner with nothing to change", async ({
page,
}) => {
libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }];
const { state } = await serveArtistImages(page);
state.banner = "c".repeat(64);
await page.goto("/artists/artist-1");
const hero = page.locator(".artist-hero");
await expect(hero.locator("img.artist-banner")).toBeVisible();
await expect(hero.getByRole("button", { name: /Change/ })).toHaveCount(0);
});
42 changes: 41 additions & 1 deletion webapp/src/api/catalog.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { call, collect, scoped } from "./client";
import { call, collect, request, scoped } from "./client";

export type Album = {
id: string;
Expand Down Expand Up @@ -84,10 +84,25 @@ export type ArtistEnrichment = {
similar: { artists: Artist[]; source: EnrichmentSource } | null;
};

/** Which of an artist's two hand-chosen images (RFC-013). */
export type ArtistImageKind = "portrait" | "banner";

/** Where a chosen image came from, and when it was chosen. */
export type ImageChoice = {
source: "upload" | "deezer";
set_at: number;
};

export type ArtistDetail = Artist & {
/** The library the artist belongs to: whose owners may choose its images. */
library_id: string;
albums: Album[];
/** Always present on the native page; optional so an older server parses. */
enrichment?: ArtistEnrichment;
/** The banner chosen by hand, absent when none was. */
banner_hash?: string;
/** What was chosen by hand, kind by kind; absent when nothing was. */
image_choice?: { portrait?: ImageChoice; banner?: ImageChoice };
};

export type SearchResult = {
Expand Down Expand Up @@ -189,6 +204,31 @@ export const listArtists = (libraryId?: string) =>
export const getArtist = (id: string) =>
call<ArtistDetail>(`/api/v2/artists/${id}`);

/**
* Chooses an artist's portrait or banner from an image file (RFC-013): sent as
* its own bytes, recognised by the server from them. Answers the artist page.
*/
export const setArtistImage = (
artistId: string,
kind: ArtistImageKind,
file: Blob,
) =>
call<ArtistDetail>(`/api/v2/artists/${artistId}/image/${kind}`, {
method: "PUT",
body: file,
headers: { "content-type": "application/octet-stream" },
});

/** Goes back to the automatic image: the fetched portrait, or no banner. */
export const removeArtistImage = async (
artistId: string,
kind: ArtistImageKind,
): Promise<void> => {
await request(`/api/v2/artists/${artistId}/image/${kind}`, {
method: "DELETE",
});
};

/**
* The albums credited to an artist, without the artist's page.
*
Expand Down
3 changes: 3 additions & 0 deletions webapp/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type {
Artist,
ArtistDetail,
ArtistEnrichment,
ArtistImageKind,
EnrichmentSource,
Genre,
SearchResult,
Expand All @@ -45,8 +46,10 @@ export {
listGenres,
listRandomSongs,
listSongs,
removeArtistImage,
SONG_PAGE,
search,
setArtistImage,
} from "./catalog";
export type { SessionUser, WebSession } from "./client";
export {
Expand Down
1 change: 1 addition & 0 deletions webapp/src/api/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {

const artist = (state?: ArtistEnrichment["state"]): ArtistDetail => ({
id: "artist-1",
library_id: "library-1",
name: "Björk",
album_count: 0,
artwork_hash: null,
Expand Down
158 changes: 158 additions & 0 deletions webapp/src/components/artist-image-controls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { useQueryClient } from "@tanstack/react-query";
import { useRef, useState } from "react";

import {
ApiError,
type ArtistDetail,
type ArtistImageKind,
removeArtistImage,
setArtistImage,
} from "../api";
import { artistQuery } from "../api/queries";
import { type TranslationKey, useI18n } from "../state/i18n";
import { Icon } from "./icons";

/** What the server takes: recognised from the bytes, these three only. */
const ACCEPT = "image/jpeg,image/png,image/webp";

const LABELS: Record<
ArtistImageKind,
{
change: TranslationKey;
reset: TranslationKey;
changed: TranslationKey;
reverted: TranslationKey;
}
> = {
portrait: {
change: "artistImage.changePortrait",
reset: "artistImage.resetPortrait",
changed: "artistImage.portraitChanged",
reverted: "artistImage.portraitReverted",
},
banner: {
change: "artistImage.changeBanner",
reset: "artistImage.resetBanner",
changed: "artistImage.bannerChanged",
reverted: "artistImage.bannerReverted",
},
};

/**
* Change an artist's portrait or banner, or go back to the automatic one
* (RFC-013). Drawn only for the library's owners and managers; the server
* refuses anyone else regardless.
*
* A file is sent as it is: no crop, no resize. The banner is framed by the
* page at a fixed position, as the desktop frames it.
*/
export function ArtistImageControls({ artist }: { artist: ArtistDetail }) {
const { t } = useI18n();
const client = useQueryClient();
const pickers = {
portrait: useRef<HTMLInputElement>(null),
banner: useRef<HTMLInputElement>(null),
};
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<{ error: boolean; text: string } | null>(
null,
);

async function settle(next?: ArtistDetail) {
// The answer to the write is the page as it now reads; holding it first
// keeps the old picture off the screen while everything else re-reads.
if (next) client.setQueryData(artistQuery(artist.id).queryKey, next);
await Promise.all([
client.invalidateQueries({ queryKey: ["artist", artist.id] }),
client.invalidateQueries({ queryKey: ["artists"] }),
]);
}

async function choose(kind: ArtistImageKind, file: File) {
setBusy(true);
setNotice(null);
try {
await settle(await setArtistImage(artist.id, kind, file));
setNotice({ error: false, text: t(LABELS[kind].changed) });
} catch (error) {
setNotice({
error: true,
text: t(
error instanceof ApiError && error.status === 413
? "artistImage.tooLarge"
: error instanceof ApiError && error.status === 422
? "artistImage.notAnImage"
: "artistImage.failed",
),
});
} finally {
setBusy(false);
}
}

async function reset(kind: ArtistImageKind) {
setBusy(true);
setNotice(null);
try {
await removeArtistImage(artist.id, kind);
await settle();
setNotice({ error: false, text: t(LABELS[kind].reverted) });
} catch {
setNotice({ error: true, text: t("artistImage.failed") });
} finally {
setBusy(false);
}
}

const kinds: ArtistImageKind[] = ["portrait", "banner"];
return (
<div className="artist-image-controls">
<div className="artist-image-buttons">
{kinds.map((kind) => (
<span key={kind} className="artist-image-pair">
<button
type="button"
className="artist-image-button"
disabled={busy}
onClick={() => pickers[kind].current?.click()}
>
<Icon name="edit" size={15} />
{t(LABELS[kind].change)}
</button>
{artist.image_choice?.[kind] ? (
<button
type="button"
className="artist-image-button subtle"
disabled={busy}
onClick={() => void reset(kind)}
>
{t(LABELS[kind].reset)}
</button>
) : null}
<input
ref={pickers[kind]}
type="file"
accept={ACCEPT}
hidden
aria-label={t(LABELS[kind].change)}
onChange={(event) => {
const file = event.target.files?.[0];
// Cleared, so the same file chosen again is a change too.
event.target.value = "";
if (file) void choose(kind, file);
}}
/>
</span>
))}
</div>
{notice ? (
<p
className={notice.error ? "error" : "muted artist-image-notice"}
role={notice.error ? "alert" : "status"}
>
{notice.text}
</p>
) : null}
</div>
);
}
Loading
Loading