diff --git a/docs/rfcs/RFC-013-chosen-artist-imagery.md b/docs/rfcs/RFC-013-chosen-artist-imagery.md
index 8450f614..3b7c529a 100644
--- a/docs/rfcs/RFC-013-chosen-artist-imagery.md
+++ b/docs/rfcs/RFC-013-chosen-artist-imagery.md
@@ -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
diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts
index 459dc406..3b111d7d 100644
--- a/webapp/e2e/studio-nocturne.spec.ts
+++ b/webapp/e2e/studio-nocturne.spec.ts
@@ -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);
+});
diff --git a/webapp/src/api/catalog.ts b/webapp/src/api/catalog.ts
index 73bb4c84..d085b387 100644
--- a/webapp/src/api/catalog.ts
+++ b/webapp/src/api/catalog.ts
@@ -1,4 +1,4 @@
-import { call, collect, scoped } from "./client";
+import { call, collect, request, scoped } from "./client";
export type Album = {
id: string;
@@ -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 = {
@@ -189,6 +204,31 @@ export const listArtists = (libraryId?: string) =>
export const getArtist = (id: string) =>
call(`/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(`/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 => {
+ await request(`/api/v2/artists/${artistId}/image/${kind}`, {
+ method: "DELETE",
+ });
+};
+
/**
* The albums credited to an artist, without the artist's page.
*
diff --git a/webapp/src/api/index.ts b/webapp/src/api/index.ts
index 5d391f51..9e33f9cb 100644
--- a/webapp/src/api/index.ts
+++ b/webapp/src/api/index.ts
@@ -26,6 +26,7 @@ export type {
Artist,
ArtistDetail,
ArtistEnrichment,
+ ArtistImageKind,
EnrichmentSource,
Genre,
SearchResult,
@@ -45,8 +46,10 @@ export {
listGenres,
listRandomSongs,
listSongs,
+ removeArtistImage,
SONG_PAGE,
search,
+ setArtistImage,
} from "./catalog";
export type { SessionUser, WebSession } from "./client";
export {
diff --git a/webapp/src/api/queries.test.ts b/webapp/src/api/queries.test.ts
index 4b226b13..7d5fd2d5 100644
--- a/webapp/src/api/queries.test.ts
+++ b/webapp/src/api/queries.test.ts
@@ -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,
diff --git a/webapp/src/components/artist-image-controls.tsx b/webapp/src/components/artist-image-controls.tsx
new file mode 100644
index 00000000..850e4606
--- /dev/null
+++ b/webapp/src/components/artist-image-controls.tsx
@@ -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(null),
+ banner: useRef(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 (
+
+
+ {kinds.map((kind) => (
+
+
+ {artist.image_choice?.[kind] ? (
+
+ ) : null}
+ {
+ 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);
+ }}
+ />
+
+ ))}
+
+ {notice ? (
+
+ {notice.text}
+
+ ) : null}
+
+ );
+}
diff --git a/webapp/src/pages/artist-page.tsx b/webapp/src/pages/artist-page.tsx
index 562bde2a..c7483f06 100644
--- a/webapp/src/pages/artist-page.tsx
+++ b/webapp/src/pages/artist-page.tsx
@@ -8,10 +8,12 @@ import {
enrichmentRetryDelay,
} from "../api/queries";
import { AlbumGrid } from "../components/album-grid";
+import { ArtistImageControls } from "../components/artist-image-controls";
import { Artwork } from "../components/artwork";
import { Loading } from "../components/page-shell";
import { SongTable } from "../components/song-table";
import { useI18n } from "../state/i18n";
+import { mayCorrectTracks, useLibraryScope } from "../state/library-scope";
/** A provider as a reader knows it, rather than as the setting spells it. */
const PROVIDER_NAMES: Record = {
@@ -96,6 +98,7 @@ function ArtistEnrichmentSection({
export function ArtistPage({ artistId }: { artistId: string }) {
const { t, locale } = useI18n();
const client = useQueryClient();
+ const { libraries } = useLibraryScope();
const query = artistQuery(artistId);
const { data: value, error } = useQuery(query);
/**
@@ -112,6 +115,14 @@ export function ArtistPage({ artistId }: { artistId: string }) {
// The artist row carries the portrait once the server has linked it; the
// enrichment names it as soon as it is cached, which may be a visit sooner.
const portrait = value.artwork_hash ?? image?.artwork_hash ?? null;
+ // The banner chosen by hand (RFC-013), or else the portrait, blurred, as the
+ // desktop draws it: a header that carries the artist even with no banner.
+ const banner = value.banner_hash ?? null;
+ const backdrop = banner ?? portrait;
+ // Offered where the server would take it: owners and managers.
+ const mayChoose = mayCorrectTracks(
+ libraries.find((library) => library.id === value.library_id),
+ );
// Counted exactly as `artistQuery` counts, so the note goes when the asking
// does.
const asked = client.getQueryState(query.queryKey);
@@ -122,7 +133,16 @@ export function ArtistPage({ artistId }: { artistId: string }) {
) !== false;
return (
-
+
+ {backdrop ? (
+
+ ) : null}
{t("artists.libraryCount", { count: value.album_count })}