From 588f6d9e314d12ed6432d918442b261f3602392c Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 23 Oct 2026 19:18:31 +0200 Subject: [PATCH 1/7] docs: RFC-013, a chosen artist portrait and banner Owners and managers choose an artist's portrait and a banner by hand, as the desktop does locally: a file, or for the portrait a Deezer artist the server fetches. The choice lives in its own table, never in artist.artwork_hash, which RFC-011's portrait fill rewrites; the artwork sweep keeps what it names; every reader shows the chosen portrait first, Subsonic's coverArt included without a change of shape. Split into three pull requests after this one. Signed-off-by: InstaZDLL --- docs/rfcs/RFC-013-chosen-artist-imagery.md | 247 +++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 docs/rfcs/RFC-013-chosen-artist-imagery.md diff --git a/docs/rfcs/RFC-013-chosen-artist-imagery.md b/docs/rfcs/RFC-013-chosen-artist-imagery.md new file mode 100644 index 00000000..ef37b31c --- /dev/null +++ b/docs/rfcs/RFC-013-chosen-artist-imagery.md @@ -0,0 +1,247 @@ +# RFC-013 — Chosen artist imagery: a portrait and a banner, set by hand + +- **Status**: Proposed +- **Implemented by**: not yet. The *Status* field above never flips in this + project; this line is the one that will say what is running. +- **Date**: 2026-10-23 +- **Authors**: the WaveFlow project +- **Depends on**: [RFC-002](RFC-002-waveflow-server-v2.md), + [RFC-007](RFC-007-library-event-stream.md), + [RFC-011](RFC-011-metadata-enrichment.md) +- **Amends**: RFC-011 decision 11, which refuses any route that writes artist + imagery into the server. This document opens exactly one such route, for a + library's owners and managers, and states why it is not the ingestion surface + decision 11 refused. + +## Problem + +An artist page on the web client shows a portrait, or a grey record when there +is none, and a plain header. The portrait comes from the files' artwork or, +since RFC-011 decision 8, from Deezer. Both are automatic, and both are +sometimes wrong: a provider matches the wrong "Ashreveal", a band's best +picture is not the one Deezer ranks first, a collaboration credited as +"Agust D, Ryuichi Sakamoto, WOOSUNG" has no picture anywhere. + +The desktop app already answers this, on the local machine only: + +- a **portrait** picker, from a Deezer search or a local file, with a way back + to the automatic one; +- a **banner** — the wide image behind the artist page's header — picked from + fanart suggestions or a local file, also with a way back. + +Neither reaches the server, and the server has neither concept: no route sets +an artist's image, and nothing is called a banner. The owner of a library asks +for the same two controls on the server's web client. This document decides +what they write, where, and what every reader then shows. + +## Decision 1 — a chosen image belongs to the library, not to one listener + +A portrait or banner set by hand is **seen by every member of the library** and +set by those who may correct its metadata: `LibraryRole::may_write_metadata`, +owner and manager. A listener sees it and cannot change it, and is refused with +the same `404` a foreign library would give (CLAUDE.md, "404 blurs +everything"). + +That is the same rule as a tag correction and as filing (RFC-012 decision 3), +for the same reason: it changes how the library is presented to everyone in it, +and nothing in a file. A per-listener picture would be a private skin over a +shared catalogue, which nobody asked for. + +It is **not** the shared enrichment cache. RFC-011 decision 4 keeps +`metadata_artist` instance-wide and free of account data, and says anything +account-specific "belongs in a different table". A choice is library-specific +rather than account-specific, but the conclusion is the same: its own table, +keyed by the library's artist, never a row in the cache. + +## Decision 2 — its own table, never `artist.artwork_hash` + +```sql +CREATE TABLE artist_image_choice ( + artist_id TEXT NOT NULL REFERENCES artist(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('portrait', 'banner')), + artwork_hash TEXT NOT NULL REFERENCES artwork(hash), + source TEXT NOT NULL CHECK (source IN ('upload', 'deezer')), + set_by TEXT NOT NULL, + set_at INTEGER NOT NULL, + PRIMARY KEY (artist_id, kind) +) STRICT; +``` + +**Not the artist row's own `artwork_hash`.** RFC-011 decision 8 writes that +column, and `link_portrait_in` rewrites it for every library on every fill and +on every repair at read time — whenever it differs. A chosen portrait stored +there would be replaced by the provider's the next time anyone opened the +page. Two columns that two writers fight over is the bug; two places with one +writer each is the design. + +**Keyed by the artist row**, which is per library: the same performer in two +libraries can carry two different choices, one per owner. Artist ids are +derived (CLAUDE.md, "Identifiers and time"), so a choice survives every +ordinary rescan. It does **not** survive a change of `WAVEFLOW_PID_ARTIST`, +which re-identifies every artist: the choice goes with the old id, as album +favourites already do. That is stated here rather than discovered. + +**The artwork store keeps what a choice names.** The sweep in +`src/services/artwork.rs` deletes an `artwork` row no artist, album or track +references. It learns a fourth reference — `artist_image_choice` — or it would +delete a chosen image on its next pass. This is the line most likely to be +forgotten, so it is in this document. + +## Decision 3 — what every reader shows + +**The portrait** a reader sees is the chosen one if there is one, otherwise +what the server already shows: + +```text +effective portrait = chosen portrait ?? artist.artwork_hash +``` + +One SQL expression, `COALESCE(choice.artwork_hash, a.artwork_hash)`, used +everywhere an artist's artwork is read today: + +- the native API's artist items and the artist page (`artwork_hash`, and the + provenance the page prints under a provider's portrait, which must not claim + a chosen image came from Deezer); +- cover-art resolution in `src/services/catalog.rs`, so + `GET /api/v2/artwork/{id}` serves it under the same membership check; +- the Subsonic façade's `coverArt` on an artist (`src/subsonic/nodes.rs`). + +**The Subsonic contract is unchanged in shape.** `coverArt` already exists on +every artist and already names whatever image the server holds; a chosen +portrait changes which image it names, not what the element looks like. That +is data, like a tag correction changing a title, and the frozen v2.0-beta +contract (RFC-002) is a contract about shape. No Subsonic element is added for +the banner: the protocol has none, and inventing one is exactly what the +freeze forbids. + +**The banner** exists only on the native API, as `banner_hash` on the artist +detail — absent when nothing is chosen. There is no automatic banner in this +document: RFC-011 configures no fanart provider, and inventing one here would +be a second enrichment RFC hidden inside this one. A client with no banner +draws what it draws today; the web client may blur the portrait behind the +header, as the desktop does, and that is a client's choice, not a field. + +## Decision 4 — how an image arrives + +Two sources, both ending in the same content-addressed `artwork` store +(`data/artwork/.`, RFC-011 decision 8 and RFC-009) that portraits +already use. + +**A file the owner sends.** The body is the image's bytes, as a lyrics upload +is (RFC-012 decision 6): no multipart, no base64. + +- JPEG, PNG or WebP, recognised **from the bytes**, never from a name or a + `Content-Type`; +- at most **10 MiB**, the cap `src/portrait.rs` already applies to a fetched + portrait, enforced by the route's own body limit so a larger body answers + `413` before it is read; +- stored once per content: the hash names the file, so the same image chosen + for two artists is one file. + +Nothing is resized or re-encoded. A client that wants a thumbnail asks for the +artwork as it does for any other. + +**A Deezer artist the owner picks** (portrait only). This is the desktop's +"Search Deezer" tab, and it must not become what RFC-011 decision 8 refused: a +browser that loads an image from a third party tells that party who is looking. +So the server does the looking: + +- `GET /artists/{id}/image/candidates?q=` asks Deezer's artist search, through + the client RFC-011 already uses, and answers up to 20 candidates — a Deezer + id, a name, a fan count — **without picture URLs**; +- each candidate's picture is fetched by the server through `src/portrait.rs`'s + narrow fetcher (https, host allowlist, public addresses only, pinned + resolution, 10 MiB) into the artwork store, and the candidate carries its + `artwork_hash`. The client shows previews from the server, never from Deezer. + Previews nobody chooses are swept like any unreferenced artwork; +- choosing one writes the choice with `source = 'deezer'` from a candidate the + server fetched. The client sends a Deezer id; it never sends a URL, so no + user input ever names a target the server dereferences — #217's line holds. + +Candidates are offered only where `deezer` is among +`WAVEFLOW_METADATA_PROVIDERS`. An instance that never opted into Deezer makes +no request to it because someone opened a picker: the operator's switch is the +only switch. + +## Decision 5 — a way back + +Removing a choice returns the artist to the automatic image: for the portrait, +whatever `artist.artwork_hash` holds; for the banner, none. The image itself +is not deleted by the removal; the sweep collects it when nothing names it any +more. + +## Decision 6 — clients learn of it as they learn of a portrait + +Setting or removing a choice records the **artist** library event +(RFC-007) for that artist, with no payload — exactly the event RFC-011 +decision 8 records when a fetched portrait arrives. A client that re-reads the +artist on that event sees the change; nothing new is needed on the wire. + +## Why this is not the ingestion route RFC-011 refused + +RFC-011 decision 11 says there is "no ingestion route by which a desktop +client or a companion process could push enrichment into the server": an +authenticated write path for machine-produced metadata, with provenance and +tenancy questions and no client to use it. + +This is narrower on every count. It writes **one image per artist per kind**, +chosen by a **person** in the library's metadata role, recorded with who chose +it and when, scoped to **one library**, and never into the shared cache. Bios, +similar artists and everything else decision 11 covered stay unwritable. + +## The surface + +All under `/api/v2`, tenancy-checked in the queries; `404` for an artist the +caller may not see, and for a write by someone who may not make it. + +| Route | What | +| --- | --- | +| `GET /artists/{id}` | Gains `banner_hash` (absent when none), and `image_choice` per kind (`source`, `set_at`) so a client can say "chosen by hand" instead of "from Deezer". `artwork_hash` becomes the effective portrait. | +| `PUT /artists/{id}/image/{kind}` | `kind` is `portrait` or `banner`. The body is the image's bytes. `200` with the artist; `413` past 10 MiB; `422` for bytes that are not JPEG, PNG or WebP. | +| `PUT /artists/{id}/image/portrait/deezer` | `{ "deezer_id": … }`, one of the candidates. `422` for an id the candidates did not offer; `404` where Deezer is not configured. | +| `GET /artists/{id}/image/candidates?q=` | Up to 20 Deezer artists, each with a server-held preview `artwork_hash`. `404` where Deezer is not configured. | +| `DELETE /artists/{id}/image/{kind}` | Back to automatic. `204`; `404` when nothing was chosen. | + +Every write goes through `DomainServices` (CLAUDE.md, "One set of domain +services"), so the desktop can reach the same rules when it chooses to. + +## What this RFC changes elsewhere + +- **RFC-011**: decision 11 gains a sentence naming this document as the one + write path it does not cover. +- **The artwork sweep** keeps hashes named by `artist_image_choice`. +- **The Subsonic façade**: `coverArt` on an artist names the effective portrait. + No element is added or removed. +- **The desktop app** (`InstaZDLL/WaveFlow`): nothing in this repository. Its + local pickers keep writing its local profile. Pushing them to the server, or + showing a server artist's chosen banner, is the desktop's own change, made + against the routes above. +- **Migrations**: one new dated file, `artist_image_choice`. None edits an + applied one. + +## The order of the work + +Each step is its own pull request, shippable without the next: + +1. **This RFC.** +2. **The choice, served**: the table and its migration, the sweep's fourth + reference, the effective portrait in every reader, `banner_hash` and + `image_choice` on the artist, the upload and removal routes, and the artist + event. +3. **The web client**: a banner behind the artist page's header (the chosen + one, or the portrait blurred), and, for owners and managers, "Change + portrait" and "Change banner" with a file and "Use the automatic one". +4. **Deezer candidates**: the candidates route with server-held previews, the + pick route, and the "Search Deezer" tab of the portrait picker. + +## What is still open + +- **An automatic banner.** TheAudioDB's fanart, which the desktop suggests, + would need a provider RFC-011 does not configure — its own key, its own host + allowlist, its own entry in `WAVEFLOW_METADATA_PROVIDERS`. +- **Cropping.** Neither picker crops; the client frames the banner at a fixed + position, as the desktop does. A focal point stored beside the choice is a + later, additive change. +- **Choices for an artist present in several libraries.** Each library's owner + chooses for their own; nothing copies a choice across. Whether an instance + operator should be able to set one everywhere is not decided here. From c1c62d9c83d6a8128b606d56ea219e441d99d60f Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 23 Oct 2026 19:27:44 +0200 Subject: [PATCH 2/7] docs: RFC-013, bound the Deezer search and keep what it offered The candidates route was open to any member and unbounded, at up to twenty fetches a search; it is now for owners and managers, keeps RFC-011's per-provider pace, allows one search per account at a time and answers a repeated query from what it holds. The pick named a Deezer id with nothing to check it against: artist_image_offer remembers each offer for an hour, the pick stores the offered preview only from a live offer of that artist, and the sweep keeps previews a live offer names. Signed-off-by: InstaZDLL --- docs/rfcs/RFC-013-chosen-artist-imagery.md | 79 +++++++++++++++++++--- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/docs/rfcs/RFC-013-chosen-artist-imagery.md b/docs/rfcs/RFC-013-chosen-artist-imagery.md index ef37b31c..3fbcdfbf 100644 --- a/docs/rfcs/RFC-013-chosen-artist-imagery.md +++ b/docs/rfcs/RFC-013-chosen-artist-imagery.md @@ -84,8 +84,9 @@ favourites already do. That is stated here rather than discovered. **The artwork store keeps what a choice names.** The sweep in `src/services/artwork.rs` deletes an `artwork` row no artist, album or track references. It learns a fourth reference — `artist_image_choice` — or it would -delete a chosen image on its next pass. This is the line most likely to be -forgotten, so it is in this document. +delete a chosen image on its next pass, and a fifth, the unexpired Deezer +offers of decision 4. This is the line most likely to be forgotten, so it is in +this document. ## Decision 3 — what every reader shows @@ -151,13 +152,68 @@ So the server does the looking: id, a name, a fan count — **without picture URLs**; - each candidate's picture is fetched by the server through `src/portrait.rs`'s narrow fetcher (https, host allowlist, public addresses only, pinned - resolution, 10 MiB) into the artwork store, and the candidate carries its - `artwork_hash`. The client shows previews from the server, never from Deezer. - Previews nobody chooses are swept like any unreferenced artwork; + resolution, 10 MiB) into the artwork store, at Deezer's small picture size, + and the candidate carries its `artwork_hash`. The client shows previews from + the server, never from Deezer; - choosing one writes the choice with `source = 'deezer'` from a candidate the server fetched. The client sends a Deezer id; it never sends a URL, so no user input ever names a target the server dereferences — #217's line holds. +### Who may search, and how much + +A search costs the server a request to Deezer and up to twenty image fetches +and writes, so it is not open to whoever may read the page: + +- **Owners and managers only**, the same `may_write_metadata` gate as the + choice it serves; anyone else gets the `404` of decision 1. A listener has no + picker to open, so this refuses nothing a client offers. +- **Deezer's pace is RFC-011's.** Every request to Deezer — the search and each + preview — keeps the per-provider pace RFC-011 decision 3 sets + (`WAVEFLOW_METADATA_RATE_PER_SEC`) and `src/enrichment.rs` enforces, shared + with enrichment. A picker cannot make the server ask Deezer faster than + the operator allowed, however often it is opened. +- **One search at a time per account.** A second search while the first is + still fetching answers `429`; the client waits for the first. +- **A repeated search is answered from what the server holds.** The same + query for the same artist within an offer's life (below) returns the offer + already stored and fetches nothing. + +### What the server remembers between the offer and the pick + +The pick names a Deezer id and nothing else, so the server has to remember +what it offered, or it could neither refuse an id it never offered nor find +the preview to store. That memory is a table of its own: + +```sql +CREATE TABLE artist_image_offer ( + artist_id TEXT NOT NULL REFERENCES artist(id) ON DELETE CASCADE, + deezer_id INTEGER NOT NULL, + query TEXT NOT NULL, + artwork_hash TEXT NOT NULL REFERENCES artwork(hash), + offered_by TEXT NOT NULL, + offered_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (artist_id, deezer_id) +) STRICT; +``` + +- **An offer is the artist's, so it is the library's**: the artist row is per + library, and any owner or manager of that library may pick from it, not only + the person who searched. +- **It lives one hour.** Long enough to look through twenty faces and choose; + short enough that the table holds what is on someone's screen and not a + history of searches. +- **The pick checks it.** `PUT …/image/portrait/deezer` accepts a Deezer id + only if an unexpired offer for that artist holds it, and stores the offer's + `artwork_hash` as the choice — the image the owner saw, fetched once, not + fetched again. An id with no live offer answers `422`, and so does one for + another artist: the key is the artist, so an offer made in one library is + never found from another. +- **The sweep respects it.** A preview named by an unexpired offer is kept; an + expired offer is deleted by the same pass, and its preview then goes the way + of any image nothing names — unless it was chosen, in which case the choice + already holds it. + Candidates are offered only where `deezer` is among `WAVEFLOW_METADATA_PROVIDERS`. An instance that never opted into Deezer makes no request to it because someone opened a picker: the operator's switch is the @@ -198,8 +254,8 @@ caller may not see, and for a write by someone who may not make it. | --- | --- | | `GET /artists/{id}` | Gains `banner_hash` (absent when none), and `image_choice` per kind (`source`, `set_at`) so a client can say "chosen by hand" instead of "from Deezer". `artwork_hash` becomes the effective portrait. | | `PUT /artists/{id}/image/{kind}` | `kind` is `portrait` or `banner`. The body is the image's bytes. `200` with the artist; `413` past 10 MiB; `422` for bytes that are not JPEG, PNG or WebP. | -| `PUT /artists/{id}/image/portrait/deezer` | `{ "deezer_id": … }`, one of the candidates. `422` for an id the candidates did not offer; `404` where Deezer is not configured. | -| `GET /artists/{id}/image/candidates?q=` | Up to 20 Deezer artists, each with a server-held preview `artwork_hash`. `404` where Deezer is not configured. | +| `PUT /artists/{id}/image/portrait/deezer` | `{ "deezer_id": … }`, from an unexpired offer for this artist; the offer's preview becomes the portrait, fetched again never. `422` for an id with no live offer; `404` where Deezer is not configured. | +| `GET /artists/{id}/image/candidates?q=` | Owners and managers only. Up to 20 Deezer artists, each with a server-held preview `artwork_hash`, stored as an offer for an hour. `429` while the same account's previous search is still fetching; `404` where Deezer is not configured. | | `DELETE /artists/{id}/image/{kind}` | Back to automatic. `204`; `404` when nothing was chosen. | Every write goes through `DomainServices` (CLAUDE.md, "One set of domain @@ -216,7 +272,8 @@ services"), so the desktop can reach the same rules when it chooses to. local pickers keep writing its local profile. Pushing them to the server, or showing a server artist's chosen banner, is the desktop's own change, made against the routes above. -- **Migrations**: one new dated file, `artist_image_choice`. None edits an +- **Migrations**: one new dated file with `artist_image_choice`, and one with + `artist_image_offer` in step 4. None edits an applied one. ## The order of the work @@ -231,8 +288,10 @@ Each step is its own pull request, shippable without the next: 3. **The web client**: a banner behind the artist page's header (the chosen one, or the portrait blurred), and, for owners and managers, "Change portrait" and "Change banner" with a file and "Use the automatic one". -4. **Deezer candidates**: the candidates route with server-held previews, the - pick route, and the "Search Deezer" tab of the portrait picker. +4. **Deezer candidates**: `artist_image_offer` and its place in the sweep, the + candidates route with server-held previews and its limits, the pick route + checked against the offer, and the "Search Deezer" tab of the portrait + picker. ## What is still open From 0860598eabb66299ae8f334874bcc1db0e28a6e1 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 23 Oct 2026 19:43:09 +0200 Subject: [PATCH 3/7] docs: RFC-013, a search and its candidates, and the pick serialized with the sweep artist_image_offer kept neither a candidate's name nor its fans, and its key let only one search offer a given Deezer artist. A search and its ordered candidates now have a table each, so a repeated query is answered whole and two queries may offer the same artist; the pick uses the most recent live search that offered the id. The pick and the sweep both run under the writer gate in one transaction each, the sweep only takes searches already expired, and the foreign key from a choice to its artwork turns any slip into a 422 rather than a dangling hash. Signed-off-by: InstaZDLL --- docs/rfcs/RFC-013-chosen-artist-imagery.md | 96 ++++++++++++++++------ 1 file changed, 69 insertions(+), 27 deletions(-) diff --git a/docs/rfcs/RFC-013-chosen-artist-imagery.md b/docs/rfcs/RFC-013-chosen-artist-imagery.md index 3fbcdfbf..798ca322 100644 --- a/docs/rfcs/RFC-013-chosen-artist-imagery.md +++ b/docs/rfcs/RFC-013-chosen-artist-imagery.md @@ -84,8 +84,8 @@ favourites already do. That is stated here rather than discovered. **The artwork store keeps what a choice names.** The sweep in `src/services/artwork.rs` deletes an `artwork` row no artist, album or track references. It learns a fourth reference — `artist_image_choice` — or it would -delete a chosen image on its next pass, and a fifth, the unexpired Deezer -offers of decision 4. This is the line most likely to be forgotten, so it is in +delete a chosen image on its next pass, and a fifth, the candidates of a live +Deezer search (decision 4). This is the line most likely to be forgotten, so it is in this document. ## Decision 3 — what every reader shows @@ -175,44 +175,86 @@ and writes, so it is not open to whoever may read the page: - **One search at a time per account.** A second search while the first is still fetching answers `429`; the client waits for the first. - **A repeated search is answered from what the server holds.** The same - query for the same artist within an offer's life (below) returns the offer + query for the same artist within a search's life (below) returns the search already stored and fetches nothing. ### What the server remembers between the offer and the pick The pick names a Deezer id and nothing else, so the server has to remember what it offered, or it could neither refuse an id it never offered nor find -the preview to store. That memory is a table of its own: +the preview to store. It remembers a **search** and its **candidates**, in two +tables, so that a repeated search can be answered whole and two searches may +offer the same Deezer artist: ```sql -CREATE TABLE artist_image_offer ( +CREATE TABLE artist_image_search ( + id TEXT PRIMARY KEY, artist_id TEXT NOT NULL REFERENCES artist(id) ON DELETE CASCADE, + -- The query as typed, trimmed and case-folded: "Björk " and "björk" are + -- one search. + query_key TEXT NOT NULL, + searched_by TEXT NOT NULL, + searched_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + -- Set when every candidate's preview is stored. A search still fetching + -- is the one that makes a second search by the same account answer 429. + complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)) +) STRICT; + +CREATE TABLE artist_image_candidate ( + search_id TEXT NOT NULL REFERENCES artist_image_search(id) ON DELETE CASCADE, + -- Deezer's order, so the answer reads as the search did. + position INTEGER NOT NULL, deezer_id INTEGER NOT NULL, - query TEXT NOT NULL, + name TEXT NOT NULL, + fans INTEGER, artwork_hash TEXT NOT NULL REFERENCES artwork(hash), - offered_by TEXT NOT NULL, - offered_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - PRIMARY KEY (artist_id, deezer_id) + PRIMARY KEY (search_id, position), + UNIQUE (search_id, deezer_id) ) STRICT; ``` -- **An offer is the artist's, so it is the library's**: the artist row is per +- **A search is the artist's, so it is the library's**: the artist row is per library, and any owner or manager of that library may pick from it, not only the person who searched. - **It lives one hour.** Long enough to look through twenty faces and choose; - short enough that the table holds what is on someone's screen and not a + short enough that the tables hold what is on someone's screen and not a history of searches. +- **A repeated search is answered from it.** The same `query_key` for the same + artist, while a complete search for it is alive, returns that search's + candidates in their order — id, name, fans, preview — and asks Deezer + nothing. A different query makes a search of its own, even where the two + return the same artist. - **The pick checks it.** `PUT …/image/portrait/deezer` accepts a Deezer id - only if an unexpired offer for that artist holds it, and stores the offer's + only if a live search of that artist offered it, and stores that candidate's `artwork_hash` as the choice — the image the owner saw, fetched once, not - fetched again. An id with no live offer answers `422`, and so does one for - another artist: the key is the artist, so an offer made in one library is - never found from another. -- **The sweep respects it.** A preview named by an unexpired offer is kept; an - expired offer is deleted by the same pass, and its preview then goes the way - of any image nothing names — unless it was chosen, in which case the choice - already holds it. + fetched again. When several live searches offered it, the most recent one's + candidate is used; their previews are the same picture at the same size, so + this decides nothing visible. An id no live search offered answers `422`, + and so does one offered for another artist: a search belongs to the artist, + so a search made in one library is never found from another. + +### The pick and the sweep never interleave + +The sweep deletes expired searches and then any `artwork` row nothing names; +the pick reads a live candidate and then writes a choice that names its +preview. Run side by side, the sweep could delete a preview between the pick's +read and its write. They cannot, for the reason every write here is safe: both +take the process-wide **writer gate** (CLAUDE.md, "SQLite discipline") and run +in **one transaction** each. + +- The pick, under the gate and inside its transaction, reads the candidate with + `expires_at` compared to the transaction's own clock reading, and inserts the + choice. The sweep cannot run in between, and it deletes only searches whose + `expires_at` has already passed — so a search live when the pick read it was + not swept while the pick ran. +- The sweep, under the gate, deletes expired searches (their candidates go by + cascade) and then unreferenced artwork, in that order and in one transaction, + so a preview a choice already names is never taken by the second step. +- If anything ever slipped through, the foreign key from + `artist_image_choice.artwork_hash` to `artwork` fails the insert rather than + storing a dangling hash; the route answers `422`, as for an expired search, + and the owner searches again. Candidates are offered only where `deezer` is among `WAVEFLOW_METADATA_PROVIDERS`. An instance that never opted into Deezer makes @@ -254,8 +296,8 @@ caller may not see, and for a write by someone who may not make it. | --- | --- | | `GET /artists/{id}` | Gains `banner_hash` (absent when none), and `image_choice` per kind (`source`, `set_at`) so a client can say "chosen by hand" instead of "from Deezer". `artwork_hash` becomes the effective portrait. | | `PUT /artists/{id}/image/{kind}` | `kind` is `portrait` or `banner`. The body is the image's bytes. `200` with the artist; `413` past 10 MiB; `422` for bytes that are not JPEG, PNG or WebP. | -| `PUT /artists/{id}/image/portrait/deezer` | `{ "deezer_id": … }`, from an unexpired offer for this artist; the offer's preview becomes the portrait, fetched again never. `422` for an id with no live offer; `404` where Deezer is not configured. | -| `GET /artists/{id}/image/candidates?q=` | Owners and managers only. Up to 20 Deezer artists, each with a server-held preview `artwork_hash`, stored as an offer for an hour. `429` while the same account's previous search is still fetching; `404` where Deezer is not configured. | +| `PUT /artists/{id}/image/portrait/deezer` | `{ "deezer_id": … }`, offered by a live search of this artist; that candidate's preview becomes the portrait, never fetched again. `422` for an id no live search offered; `404` where Deezer is not configured. | +| `GET /artists/{id}/image/candidates?q=` | Owners and managers only. Up to 20 Deezer artists, each with a server-held preview `artwork_hash`, stored as a search for an hour; the same query answered from it while it lives. `429` while the same account's previous search is still fetching; `404` where Deezer is not configured. | | `DELETE /artists/{id}/image/{kind}` | Back to automatic. `204`; `404` when nothing was chosen. | Every write goes through `DomainServices` (CLAUDE.md, "One set of domain @@ -273,7 +315,7 @@ services"), so the desktop can reach the same rules when it chooses to. showing a server artist's chosen banner, is the desktop's own change, made against the routes above. - **Migrations**: one new dated file with `artist_image_choice`, and one with - `artist_image_offer` in step 4. None edits an + `artist_image_search` and `artist_image_candidate` in step 4. None edits an applied one. ## The order of the work @@ -288,10 +330,10 @@ Each step is its own pull request, shippable without the next: 3. **The web client**: a banner behind the artist page's header (the chosen one, or the portrait blurred), and, for owners and managers, "Change portrait" and "Change banner" with a file and "Use the automatic one". -4. **Deezer candidates**: `artist_image_offer` and its place in the sweep, the - candidates route with server-held previews and its limits, the pick route - checked against the offer, and the "Search Deezer" tab of the portrait - picker. +4. **Deezer candidates**: the search and candidate tables and their place in + the sweep, the candidates route with server-held previews and its limits, + the pick route checked against a live search, and the "Search Deezer" tab + of the portrait picker. ## What is still open From a43eb8991c54fb42f7c4f57583ccbc271c46153f Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 23 Oct 2026 19:52:52 +0200 Subject: [PATCH 4/7] docs: RFC-013, a failed search is deleted, and Deezer off closes the pick An incomplete search had no way out: a failed fetch or a restart left it at complete = 0 and its account answered 429 for the rest of the hour. A failing search is now deleted by the request that made it, and boot deletes any left incomplete, so 0 means a fetch in flight. The pick read the provider switch only at search time; it is now read at every call, so removing deezer closes both routes at once, and a portrait already chosen stays. Signed-off-by: InstaZDLL --- docs/rfcs/RFC-013-chosen-artist-imagery.md | 27 +++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/rfcs/RFC-013-chosen-artist-imagery.md b/docs/rfcs/RFC-013-chosen-artist-imagery.md index 798ca322..76db73c6 100644 --- a/docs/rfcs/RFC-013-chosen-artist-imagery.md +++ b/docs/rfcs/RFC-013-chosen-artist-imagery.md @@ -196,8 +196,9 @@ CREATE TABLE artist_image_search ( searched_by TEXT NOT NULL, searched_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, - -- Set when every candidate's preview is stored. A search still fetching - -- is the one that makes a second search by the same account answer 429. + -- Set when every candidate's preview is stored. 0 means a request is + -- fetching for it now: a failed search is deleted, and boot deletes any + -- left at 0. It is what makes a second search by the account answer 429. complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)) ) STRICT; @@ -233,6 +234,18 @@ CREATE TABLE artist_image_candidate ( this decides nothing visible. An id no live search offered answers `422`, and so does one offered for another artist: a search belongs to the artist, so a search made in one library is never found from another. +- **A search that does not finish is not left half-done.** `complete = 0` + means one thing only: a request is fetching for it right now. So: + - a search whose Deezer query or any preview fetch fails is **deleted** by + the request that made it, before that request answers its error. Its + candidates go by cascade, and the previews already stored become + unreferenced artwork for the sweep; + - at **boot**, every search with `complete = 0` is deleted: nothing can be + fetching for it across a restart, and leaving it would answer `429` to its + account for the rest of the hour; + - the `429` of one search at a time therefore only ever reflects a search + that is really in flight, and the next search after a failure starts at + once. ### The pick and the sweep never interleave @@ -261,6 +274,14 @@ Candidates are offered only where `deezer` is among no request to it because someone opened a picker: the operator's switch is the only switch. +**The switch is read at every call, not at the search.** An operator who +removes `deezer` and restarts closes both routes at once: the candidates route +and the pick answer `404`, even for a search that is still live. A search +made under the old setting does not outlive the operator's decision by up to +an hour; its rows simply expire and are swept like any other. A portrait +already chosen from Deezer stays: it is the library's image now, stored on the +server, and no request to Deezer is needed to show it. + ## Decision 5 — a way back Removing a choice returns the artist to the automatic image: for the portrait, @@ -296,7 +317,7 @@ caller may not see, and for a write by someone who may not make it. | --- | --- | | `GET /artists/{id}` | Gains `banner_hash` (absent when none), and `image_choice` per kind (`source`, `set_at`) so a client can say "chosen by hand" instead of "from Deezer". `artwork_hash` becomes the effective portrait. | | `PUT /artists/{id}/image/{kind}` | `kind` is `portrait` or `banner`. The body is the image's bytes. `200` with the artist; `413` past 10 MiB; `422` for bytes that are not JPEG, PNG or WebP. | -| `PUT /artists/{id}/image/portrait/deezer` | `{ "deezer_id": … }`, offered by a live search of this artist; that candidate's preview becomes the portrait, never fetched again. `422` for an id no live search offered; `404` where Deezer is not configured. | +| `PUT /artists/{id}/image/portrait/deezer` | `{ "deezer_id": … }`, offered by a live search of this artist; that candidate's preview becomes the portrait, never fetched again. `422` for an id no live search offered; `404` where Deezer is not configured now, a live search notwithstanding. | | `GET /artists/{id}/image/candidates?q=` | Owners and managers only. Up to 20 Deezer artists, each with a server-held preview `artwork_hash`, stored as a search for an hour; the same query answered from it while it lives. `429` while the same account's previous search is still fetching; `404` where Deezer is not configured. | | `DELETE /artists/{id}/image/{kind}` | Back to automatic. `204`; `404` when nothing was chosen. | From 0c4eebe4e79514df27f43058c4de04a6163e0e1d Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 23 Oct 2026 20:02:41 +0200 Subject: [PATCH 5/7] docs: RFC-013, the sweep never takes a search still fetching The sweep deleted searches past expires_at whatever their state, so a search fetching for longer than its life could vanish under the request writing its candidates. expires_at is now NULL while a search fetches and set an hour after it completes; the sweep only ever takes searches with an expiry that has passed, and an incomplete one leaves only by its own request or at boot. Signed-off-by: InstaZDLL --- docs/rfcs/RFC-013-chosen-artist-imagery.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/rfcs/RFC-013-chosen-artist-imagery.md b/docs/rfcs/RFC-013-chosen-artist-imagery.md index 76db73c6..afcd86bc 100644 --- a/docs/rfcs/RFC-013-chosen-artist-imagery.md +++ b/docs/rfcs/RFC-013-chosen-artist-imagery.md @@ -195,7 +195,8 @@ CREATE TABLE artist_image_search ( query_key TEXT NOT NULL, searched_by TEXT NOT NULL, searched_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, + -- An hour from the moment the search completed; NULL while it fetches. + expires_at INTEGER, -- Set when every candidate's preview is stored. 0 means a request is -- fetching for it now: a failed search is deleted, and boot deletes any -- left at 0. It is what makes a second search by the account answer 429. @@ -218,9 +219,11 @@ CREATE TABLE artist_image_candidate ( - **A search is the artist's, so it is the library's**: the artist row is per library, and any owner or manager of that library may pick from it, not only the person who searched. -- **It lives one hour.** Long enough to look through twenty faces and choose; - short enough that the tables hold what is on someone's screen and not a - history of searches. +- **It lives one hour from the moment it completes.** Long enough to look + through twenty faces and choose; short enough that the tables hold what is + on someone's screen and not a history of searches. The hour starts when the + last preview is stored, so however long the fetching took, it never eats + into the time to choose. - **A repeated search is answered from it.** The same `query_key` for the same artist, while a complete search for it is alive, returns that search's candidates in their order — id, name, fans, preview — and asks Deezer @@ -264,6 +267,12 @@ in **one transaction** each. - The sweep, under the gate, deletes expired searches (their candidates go by cascade) and then unreferenced artwork, in that order and in one transaction, so a preview a choice already names is never taken by the second step. +- **The sweep never touches a search that is still fetching.** An incomplete + search has no `expires_at` to pass, so the sweep cannot delete it from under + the request writing its candidates. It has exactly two ways out, both named + above: its own request completes it or deletes it, or boot deletes it. The + previews an incomplete search has stored so far are named by its candidates, + so the sweep keeps them too until one of those happens. - If anything ever slipped through, the foreign key from `artist_image_choice.artwork_hash` to `artwork` fails the insert rather than storing a dangling hash; the route answers `422`, as for an expired search, From 9a1a44f3e942f97dc336db7c59aee706f55a6446 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 23 Oct 2026 20:12:45 +0200 Subject: [PATCH 6/7] docs: RFC-013, a search outlives an abandoned request, and a deadline backs it up A client that left mid-search dropped the handler with the request, so neither completion nor deletion ran and the search stayed incomplete, answering 429 to its account until the next restart. The fetching now runs in a task of its own that completes or deletes the search whatever the request does, and a fetch deadline twice the worst case counted from searched_at stops a search a panic left behind from counting for the 429; the sweep deletes it then. Boot still clears what is left. Signed-off-by: InstaZDLL --- docs/rfcs/RFC-013-chosen-artist-imagery.md | 44 ++++++++++++++-------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/docs/rfcs/RFC-013-chosen-artist-imagery.md b/docs/rfcs/RFC-013-chosen-artist-imagery.md index afcd86bc..cfb9a897 100644 --- a/docs/rfcs/RFC-013-chosen-artist-imagery.md +++ b/docs/rfcs/RFC-013-chosen-artist-imagery.md @@ -197,9 +197,10 @@ CREATE TABLE artist_image_search ( searched_at INTEGER NOT NULL, -- An hour from the moment the search completed; NULL while it fetches. expires_at INTEGER, - -- Set when every candidate's preview is stored. 0 means a request is - -- fetching for it now: a failed search is deleted, and boot deletes any - -- left at 0. It is what makes a second search by the account answer 429. + -- Set when every candidate's preview is stored. 0 means a task is + -- fetching for it now: a failed search is deleted, a search past its + -- fetch deadline is swept, and boot deletes any left at 0. It is what + -- makes a second search by the account answer 429. complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)) ) STRICT; @@ -238,17 +239,29 @@ CREATE TABLE artist_image_candidate ( and so does one offered for another artist: a search belongs to the artist, so a search made in one library is never found from another. - **A search that does not finish is not left half-done.** `complete = 0` - means one thing only: a request is fetching for it right now. So: + means one thing only: a task is fetching for it right now. So: + - **the fetching is not the request's.** The route records the search and + hands the fetching to a task of its own, then waits for it. A client that + closes the tab or loses the network drops the request — and axum drops + the handler with it, mid-await, running none of its remaining code — but + not the task, which goes on to complete the search or delete it. The next + picker that asks the same query finds it complete; - a search whose Deezer query or any preview fetch fails is **deleted** by - the request that made it, before that request answers its error. Its - candidates go by cascade, and the previews already stored become - unreferenced artwork for the sweep; + that task. Its candidates go by cascade, and the previews already stored + become unreferenced artwork for the sweep; + - **a deadline backs the task up.** A fetch cannot run forever: it is at most + one search and twenty previews, each bounded by + `WAVEFLOW_METADATA_REQUEST_TIMEOUT_SECS` and spaced by the provider's pace. + Twice that worst case, counted from `searched_at`, is the search's fetch + deadline. Past it, an incomplete search no longer counts for the `429`, and + the sweep deletes it. A task that is still running cannot be past it, so + this removes only what a panic or a bug left behind — never a search that + is actually fetching; - at **boot**, every search with `complete = 0` is deleted: nothing can be - fetching for it across a restart, and leaving it would answer `429` to its - account for the rest of the hour; + fetching for it across a restart; - the `429` of one search at a time therefore only ever reflects a search - that is really in flight, and the next search after a failure starts at - once. + that is really in flight, and the next search after a failure or an + abandoned page starts at once. ### The pick and the sweep never interleave @@ -269,10 +282,11 @@ in **one transaction** each. so a preview a choice already names is never taken by the second step. - **The sweep never touches a search that is still fetching.** An incomplete search has no `expires_at` to pass, so the sweep cannot delete it from under - the request writing its candidates. It has exactly two ways out, both named - above: its own request completes it or deletes it, or boot deletes it. The - previews an incomplete search has stored so far are named by its candidates, - so the sweep keeps them too until one of those happens. + the task writing its candidates. Its ways out are the ones named above: its + task completes it or deletes it, the sweep deletes it once past its fetch + deadline — which no running task can be — or boot deletes it. The previews + an incomplete search has stored so far are named by its candidates, so the + sweep keeps them too until one of those happens. - If anything ever slipped through, the foreign key from `artist_image_choice.artwork_hash` to `artwork` fails the insert rather than storing a dangling hash; the route answers `422`, as for an expired search, From 40f74cfd4af200656cc9de23412874887dc432f5 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 23 Oct 2026 21:29:46 +0200 Subject: [PATCH 7/7] docs: RFC-013, the fetch deadline is one the task obeys The deadline was computed from request timeouts and the provider's pace, but the pace is shared with enrichment, so waiting for a slot was unbounded and a task could outlive its deadline and be swept mid-write. Each search now has a fixed five-minute deadline that its task runs under as a whole, waits and writes included; its completing write checks the deadline inside the writer-gated transaction. No task runs or writes past it, so the sweep only ever takes what a panic left behind. Signed-off-by: InstaZDLL --- docs/rfcs/RFC-013-chosen-artist-imagery.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/rfcs/RFC-013-chosen-artist-imagery.md b/docs/rfcs/RFC-013-chosen-artist-imagery.md index cfb9a897..08369b2e 100644 --- a/docs/rfcs/RFC-013-chosen-artist-imagery.md +++ b/docs/rfcs/RFC-013-chosen-artist-imagery.md @@ -249,14 +249,20 @@ CREATE TABLE artist_image_candidate ( - a search whose Deezer query or any preview fetch fails is **deleted** by that task. Its candidates go by cascade, and the previews already stored become unreferenced artwork for the sweep; - - **a deadline backs the task up.** A fetch cannot run forever: it is at most - one search and twenty previews, each bounded by - `WAVEFLOW_METADATA_REQUEST_TIMEOUT_SECS` and spaced by the provider's pace. - Twice that worst case, counted from `searched_at`, is the search's fetch - deadline. Past it, an incomplete search no longer counts for the `429`, and - the sweep deletes it. A task that is still running cannot be past it, so - this removes only what a panic or a bug left behind — never a search that - is actually fetching; + - **a deadline the task itself obeys.** Every search gets a fetch deadline, + `searched_at` plus five minutes, and its task runs under that deadline as + a whole — waiting for the provider's pace, which it shares with enrichment + and cannot predict, the requests, and the writes alike + (`tokio::time::timeout_at`). A task that reaches the deadline stops and + deletes its search as a failed one. Its last write, completing the search, + happens under the writer gate and checks the deadline inside that + transaction: past it, nothing is completed, and the search is deleted + instead. So no task ever runs, or writes, beyond its deadline; + - **past the deadline, the search is fair game.** An incomplete search past + its deadline no longer counts for the `429`, and the sweep — which takes + the same writer gate — deletes it. Since its task has stopped by then, this + removes only what a panic or a bug left behind, never a search still + fetching; - at **boot**, every search with `complete = 0` is deleted: nothing can be fetching for it across a restart; - the `429` of one search at a time therefore only ever reflects a search