From cfad6c34110138f4acd252a0ac80f7a99757c9d5 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 23 Oct 2026 21:59:09 +0200 Subject: [PATCH 1/2] feat: choose an artist's portrait and banner by hand RFC-013 step 2. artist_image_choice holds a portrait and a banner per artist, set by owners and managers from an image's bytes (JPEG, PNG or WebP, 10 MiB, recognised from the bytes) and removed to go back to the automatic image. The chosen portrait leads every reader through the shared artist projection, the artwork route and Subsonic's coverArt, and outlives RFC-011's fill, which only rewrites the artist's own column. The banner is native only, as banner_hash, with image_choice saying what was chosen. The sweep keeps what a choice names, access is checked before the file is written, and every change records the artist event. Signed-off-by: InstaZDLL --- docs/rfcs/RFC-013-chosen-artist-imagery.md | 8 +- .../20261023000000_artist_image_choice.sql | 20 + src/api/catalog.rs | 43 +++ src/api/mod.rs | 6 + src/lib.rs | 4 + src/portrait.rs | 17 + src/services/artist_images.rs | 209 +++++++++++ src/services/artists.rs | 3 + src/services/artwork.rs | 9 +- src/services/catalog.rs | 8 +- src/services/mod.rs | 42 ++- tests/native_api.rs | 347 ++++++++++++++++++ 12 files changed, 708 insertions(+), 8 deletions(-) create mode 100644 migrations-v2/20261023000000_artist_image_choice.sql create mode 100644 src/services/artist_images.rs diff --git a/docs/rfcs/RFC-013-chosen-artist-imagery.md b/docs/rfcs/RFC-013-chosen-artist-imagery.md index 08369b2e..f18c6b9e 100644 --- a/docs/rfcs/RFC-013-chosen-artist-imagery.md +++ b/docs/rfcs/RFC-013-chosen-artist-imagery.md @@ -1,8 +1,12 @@ # 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. +- **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 the pull request serving the choice (`artist_image_choice`, the + effective portrait, `banner_hash`, the upload and removal routes). The + *Status* field above never flips in this project; this line is the one that + says what is running. - **Date**: 2026-10-23 - **Authors**: the WaveFlow project - **Depends on**: [RFC-002](RFC-002-waveflow-server-v2.md), diff --git a/migrations-v2/20261023000000_artist_image_choice.sql b/migrations-v2/20261023000000_artist_image_choice.sql new file mode 100644 index 00000000..1d821aad --- /dev/null +++ b/migrations-v2/20261023000000_artist_image_choice.sql @@ -0,0 +1,20 @@ +-- An artist's portrait and banner, chosen by hand (RFC-013 decision 2). +-- +-- A table of its own and never `artist.artwork_hash`: RFC-011's portrait fill +-- rewrites that column whenever it differs, so a choice stored there would be +-- replaced by the provider's the next time anyone opened the page. Keyed by +-- the artist row, which is per library: each library's owner chooses for +-- their own. The artwork it names is kept by the sweep for as long as it is +-- named; no `ON DELETE` on that key, so a sweep that forgot would fail rather +-- than leave a choice pointing at nothing. +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; + +CREATE INDEX artist_image_choice_artwork ON artist_image_choice(artwork_hash); diff --git a/src/api/catalog.rs b/src/api/catalog.rs index 5ed70fe2..47138006 100644 --- a/src/api/catalog.rs +++ b/src/api/catalog.rs @@ -347,3 +347,46 @@ pub async fn list_songs_by_genre( .map(Json) .map_err(service_error) } + +/// Chooses an artist's `portrait` or `banner` from an image's bytes +/// (RFC-013): JPEG, PNG or WebP, at most 10 MiB, recognised from the bytes. +/// Owners and managers; anyone else gets the same `404` as a stranger. +#[utoipa::path(put, path = "/api/v2/artists/{artist_id}/image/{kind}", tag = "catalog", params(("artist_id" = Uuid, Path), ("kind" = String, Path, description = "portrait or banner")), request_body(content = Vec, content_type = "application/octet-stream"), responses((status = 200, body = crate::services::ArtistDetail), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 413, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn put_artist_image( + State(state): State, + Path((artist_id, kind)): Path<(Uuid, String)>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Write).await?; + let kind = crate::services::ArtistImageKind::parse(&kind).ok_or(ApiError::NotFound)?; + state + .services + .choose_artist_image(user.id, artist_id, kind, body.to_vec()) + .await + .map_err(service_error)?; + state + .services + .artist_page(user.id, artist_id) + .await + .map(Json) + .map_err(service_error) +} + +/// Goes back to the automatic image: the portrait the files or a provider +/// gave, or no banner. `404` when nothing was chosen. +#[utoipa::path(delete, path = "/api/v2/artists/{artist_id}/image/{kind}", tag = "catalog", params(("artist_id" = Uuid, Path), ("kind" = String, Path, description = "portrait or banner")), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn delete_artist_image( + State(state): State, + Path((artist_id, kind)): Path<(Uuid, String)>, + headers: HeaderMap, +) -> Result { + let user = authenticated(&state, &headers, Access::Write).await?; + let kind = crate::services::ArtistImageKind::parse(&kind).ok_or(ApiError::NotFound)?; + state + .services + .remove_artist_image(user.id, artist_id, kind) + .await + .map(|()| StatusCode::NO_CONTENT) + .map_err(service_error) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 8c013364..8288dd9c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -180,6 +180,12 @@ pub fn router(state: AppState) -> Router { .route("/api/v2/albums/{album_id}", get(get_album)) .route("/api/v2/artists", get(list_artists)) .route("/api/v2/artists/{artist_id}", get(get_artist)) + .route( + "/api/v2/artists/{artist_id}/image/{kind}", + put(put_artist_image).delete(delete_artist_image).layer( + axum::extract::DefaultBodyLimit::max(crate::portrait::MAX_IMAGE_BYTES), + ), + ) .route( "/api/v2/artists/{artist_id}/top-songs", get(list_artist_top_songs), diff --git a/src/lib.rs b/src/lib.rs index e10ea417..72c5f762 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -159,6 +159,8 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize { api::get_album, api::list_artists, api::get_artist, + api::put_artist_image, + api::delete_artist_image, api::list_artist_top_songs, api::list_artist_albums, api::search_catalog, @@ -308,6 +310,8 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize { services::DiskFolder, services::DiskTrack, services::FiledFolder, + services::ImageChoices, + services::ImageChoice, services::FiledTree, services::FolderPatch, services::FolderRemoved, diff --git a/src/portrait.rs b/src/portrait.rs index 10e99017..17cd2048 100644 --- a/src/portrait.rs +++ b/src/portrait.rs @@ -110,6 +110,23 @@ pub struct Portrait { pub bytes: Vec, } +impl Portrait { + /// An image someone sent (RFC-013 decision 4), checked as a fetched one is: + /// JPEG, PNG or WebP from the bytes, within [`MAX_IMAGE_BYTES`]. `None` + /// for anything else — a name or a declared type is never asked. + pub fn from_bytes(bytes: Vec) -> Option { + if bytes.is_empty() || bytes.len() > MAX_IMAGE_BYTES { + return None; + } + let format = sniff(&bytes)?; + Some(Self { + hash: blake3::hash(&bytes).to_hex().to_string(), + format, + bytes, + }) + } +} + /// Why a portrait was not fetched. Carries nothing from the URL or the answer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Refusal { diff --git a/src/services/artist_images.rs b/src/services/artist_images.rs new file mode 100644 index 00000000..e6b7a9ee --- /dev/null +++ b/src/services/artist_images.rs @@ -0,0 +1,209 @@ +//! An artist's portrait and banner, chosen by hand (RFC-013). +//! +//! A choice lives in `artist_image_choice`, never in `artist.artwork_hash`, +//! which RFC-011's portrait fill rewrites. It belongs to the library: set by +//! those who may correct its metadata, seen by every member. Every write takes +//! the writer gate and records the artist library event (RFC-007), as a +//! fetched portrait does, so clients re-read the artist on their own. + +use std::str::FromStr; + +use sqlx::{Row, Sqlite, Transaction}; +use uuid::Uuid; + +use super::{DomainServices, ImageChoice, ImageChoices, ServiceError}; +use crate::authentication::now_ms; +use crate::database::LibraryRole; +use crate::portrait::Portrait; + +/// Which of the two images. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArtistImageKind { + Portrait, + Banner, +} + +impl ArtistImageKind { + /// `None` for anything but `portrait` and `banner`, so a route can answer + /// an unknown kind as it answers an unknown path. + pub fn parse(value: &str) -> Option { + match value { + "portrait" => Some(Self::Portrait), + "banner" => Some(Self::Banner), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Portrait => "portrait", + Self::Banner => "banner", + } + } +} + +/// The artist's library, if the caller may choose its images: `NotFound` for +/// an artist they cannot see and `Forbidden` for one they may only read, both +/// answered as the same `404`. +async fn chooser_library( + tx: &mut Transaction<'_, Sqlite>, + user_id: Uuid, + artist_id: Uuid, +) -> Result { + let row = sqlx::query( + "SELECT ar.library_id, m.role FROM artist ar \ + JOIN library_member m ON m.library_id=ar.library_id \ + WHERE m.user_id=? AND ar.id=?", + ) + .bind(user_id.to_string()) + .bind(artist_id.to_string()) + .fetch_optional(&mut **tx) + .await? + .ok_or(ServiceError::NotFound)?; + let role = LibraryRole::from_str(row.try_get::<&str, _>("role")?) + .map_err(|_| ServiceError::Invalid)?; + if !role.may_write_metadata() { + return Err(ServiceError::Forbidden); + } + Uuid::parse_str(row.try_get::<&str, _>("library_id")?).map_err(|_| ServiceError::Invalid) +} + +async fn record_artist_change( + tx: &mut Transaction<'_, Sqlite>, + library_id: Uuid, + artist_id: Uuid, + now: i64, +) -> Result<(), sqlx::Error> { + crate::catalog::record_library_event( + tx, + crate::catalog::LibraryChange { + library_id, + entity_type: "artist", + entity_id: artist_id, + action: "upsert", + payload: serde_json::json!({}), + changed_at: now, + origin_device_id: None, + }, + ) + .await +} + +impl DomainServices { + /// The chosen banner and what was chosen, for the artist page. Read after + /// access to the artist was established by the caller. + pub(super) async fn artist_image_choices( + &self, + artist_id: Uuid, + ) -> Result<(Option, ImageChoices), ServiceError> { + let rows = sqlx::query( + "SELECT kind, artwork_hash, source, set_at FROM artist_image_choice \ + WHERE artist_id=?", + ) + .bind(artist_id.to_string()) + .fetch_all(self.db.pool()) + .await?; + let mut banner = None; + let mut choices = ImageChoices::default(); + for row in rows { + let choice = ImageChoice { + source: row.try_get("source")?, + set_at: row.try_get("set_at")?, + }; + match row.try_get::<&str, _>("kind")? { + "portrait" => choices.portrait = Some(choice), + "banner" => { + banner = Some(row.try_get("artwork_hash")?); + choices.banner = Some(choice); + } + _ => {} + } + } + Ok((banner, choices)) + } + + /// Choose an artist's portrait or banner from an image someone sent. + /// + /// `Invalid` for bytes that are not JPEG, PNG or WebP within 10 MiB — + /// recognised from the bytes, never from a name. The file is stored once + /// per content, before the writer gate is taken (it is file I/O); the row + /// and the choice are written under it, with the artist event. + pub async fn choose_artist_image( + &self, + user_id: Uuid, + artist_id: Uuid, + kind: ArtistImageKind, + bytes: Vec, + ) -> Result<(), ServiceError> { + let image = Portrait::from_bytes(bytes).ok_or(ServiceError::Invalid)?; + // Asked before the file is written, so someone who may not choose + // cannot fill the store with images nothing will ever name. Asked again + // under the gate below, where the answer is the one the write keeps. + { + let mut read = self.db.pool().begin().await?; + chooser_library(&mut read, user_id, artist_id).await?; + } + crate::portrait::store(&self.artwork_dir, &image) + .await + .map_err(|_| ServiceError::Unavailable)?; + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + let library_id = chooser_library(&mut tx, user_id, artist_id).await?; + let now = now_ms(); + sqlx::query( + "INSERT INTO artwork (hash, format, source, byte_size, created_at) \ + VALUES (?, ?, 'upload', ?, ?) ON CONFLICT (hash) DO NOTHING", + ) + .bind(&image.hash) + .bind(image.format) + .bind(image.bytes.len() as i64) + .bind(now) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO artist_image_choice \ + (artist_id, kind, artwork_hash, source, set_by, set_at) \ + VALUES (?, ?, ?, 'upload', ?, ?) \ + ON CONFLICT(artist_id, kind) DO UPDATE SET \ + artwork_hash=excluded.artwork_hash, source=excluded.source, \ + set_by=excluded.set_by, set_at=excluded.set_at", + ) + .bind(artist_id.to_string()) + .bind(kind.as_str()) + .bind(&image.hash) + .bind(user_id.to_string()) + .bind(now) + .execute(&mut *tx) + .await?; + record_artist_change(&mut tx, library_id, artist_id, now).await?; + tx.commit().await?; + Ok(()) + } + + /// Go back to the automatic image: for the portrait, whatever the files or + /// a provider gave; for the banner, none. The image itself stays in the + /// store until the sweep finds nothing naming it. `NotFound` when nothing + /// was chosen. + pub async fn remove_artist_image( + &self, + user_id: Uuid, + artist_id: Uuid, + kind: ArtistImageKind, + ) -> Result<(), ServiceError> { + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + let library_id = chooser_library(&mut tx, user_id, artist_id).await?; + let removed = sqlx::query("DELETE FROM artist_image_choice WHERE artist_id=? AND kind=?") + .bind(artist_id.to_string()) + .bind(kind.as_str()) + .execute(&mut *tx) + .await? + .rows_affected(); + if removed == 0 { + return Err(ServiceError::NotFound); + } + record_artist_change(&mut tx, library_id, artist_id, now_ms()).await?; + tx.commit().await?; + Ok(()) + } +} diff --git a/src/services/artists.rs b/src/services/artists.rs index 859a5a8f..97635cb4 100644 --- a/src/services/artists.rs +++ b/src/services/artists.rs @@ -51,11 +51,14 @@ impl DomainServices { .map(album_from_row) .collect::, _>>()?; attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; + let (banner_hash, image_choice) = self.artist_image_choices(artist_id).await?; Ok(ArtistDetail { artist: summary.artist, album_count: summary.album_count, albums, enrichment: None, + banner_hash, + image_choice, }) } diff --git a/src/services/artwork.rs b/src/services/artwork.rs index cdd15103..aa259dc7 100644 --- a/src/services/artwork.rs +++ b/src/services/artwork.rs @@ -208,8 +208,10 @@ impl DomainServices { Ok(swept) } - /// Drops every `artwork` row no artist, album or track names, and says how - /// many went. + /// Drops every `artwork` row no artist, album, track or hand-chosen + /// artist image (RFC-013) names, and says how many went. The choice + /// table's key has no `ON DELETE`: forgetting it here would fail the + /// sweep rather than strand a choice. /// /// Three columns rather than the canvas store's one link table, and all /// three are `ON DELETE SET NULL` — which is the reason this is needed at @@ -223,7 +225,8 @@ impl DomainServices { "DELETE FROM artwork WHERE \ NOT EXISTS (SELECT 1 FROM artist WHERE artist.artwork_hash = artwork.hash) \ AND NOT EXISTS (SELECT 1 FROM album WHERE album.artwork_hash = artwork.hash) \ - AND NOT EXISTS (SELECT 1 FROM track WHERE track.artwork_hash = artwork.hash)", + AND NOT EXISTS (SELECT 1 FROM track WHERE track.artwork_hash = artwork.hash) \ + AND NOT EXISTS (SELECT 1 FROM artist_image_choice c WHERE c.artwork_hash = artwork.hash)", ) .execute(self.db.pool()) .await? diff --git a/src/services/catalog.rs b/src/services/catalog.rs index 64fb84e6..a1f5f6ec 100644 --- a/src/services/catalog.rs +++ b/src/services/catalog.rs @@ -232,11 +232,15 @@ impl DomainServices { SELECT 1 FROM track t JOIN library_member m ON m.library_id=t.library_id WHERE t.artwork_hash=a.hash AND m.user_id=? \ UNION SELECT 1 FROM album al JOIN library_member m ON m.library_id=al.library_id WHERE al.artwork_hash=a.hash AND m.user_id=? \ UNION SELECT 1 FROM artist ar JOIN library_member m ON m.library_id=ar.library_id WHERE ar.artwork_hash=a.hash AND m.user_id=? \ + UNION SELECT 1 FROM artist_image_choice c JOIN artist ar ON ar.id=c.artist_id JOIN library_member m ON m.library_id=ar.library_id WHERE c.artwork_hash=a.hash AND m.user_id=? \ ) UNION ALL SELECT a.hash, a.format FROM track t JOIN library_member m ON m.library_id=t.library_id JOIN artwork a ON a.hash=t.artwork_hash WHERE t.id=? AND m.user_id=? \ UNION ALL SELECT a.hash, a.format FROM album al JOIN library_member m ON m.library_id=al.library_id JOIN artwork a ON a.hash=al.artwork_hash WHERE al.id=? AND m.user_id=? \ - UNION ALL SELECT a.hash, a.format FROM artist ar JOIN library_member m ON m.library_id=ar.library_id JOIN artwork a ON a.hash=ar.artwork_hash WHERE ar.id=? AND m.user_id=? LIMIT 1", + UNION ALL SELECT a.hash, a.format FROM artist ar JOIN library_member m ON m.library_id=ar.library_id JOIN artwork a ON a.hash=COALESCE((SELECT c.artwork_hash FROM artist_image_choice c WHERE c.artist_id=ar.id AND c.kind='portrait'), ar.artwork_hash) WHERE ar.id=? AND m.user_id=? LIMIT 1", ) - .bind(id).bind(user_id.to_string()).bind(user_id.to_string()).bind(user_id.to_string()) + // A chosen image (RFC-013) is served to the members of the library + // whose artist names it, and an artist's id resolves to its effective + // portrait, the chosen one first. + .bind(id).bind(user_id.to_string()).bind(user_id.to_string()).bind(user_id.to_string()).bind(user_id.to_string()) .bind(id).bind(user_id.to_string()).bind(id).bind(user_id.to_string()).bind(id).bind(user_id.to_string()) .fetch_optional(self.db.pool()).await?; row.map(|row| Ok::<_, sqlx::Error>((row.try_get("hash")?, row.try_get("format")?))) diff --git a/src/services/mod.rs b/src/services/mod.rs index 7285d6e8..4dc787a0 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -191,7 +191,13 @@ macro_rules! artist_select { }; (@columns $extra:expr) => { concat!( - "SELECT ar.id, ar.library_id, ar.name, ar.artwork_hash, ar.musicbrainz_id, \ + // The effective portrait (RFC-013 decision 3): a hand-chosen one + // first, then whatever the files or a provider gave the artist. + "SELECT ar.id, ar.library_id, ar.name, \ + COALESCE((SELECT c.artwork_hash FROM artist_image_choice c \ + WHERE c.artist_id=ar.id AND c.kind='portrait'), \ + ar.artwork_hash) AS artwork_hash, \ + ar.musicbrainz_id, \ ar.sort_name, us.starred_at, ur.rating AS user_rating, \ (SELECT group_concat(role) FROM \ (SELECT ars.role FROM artist_role_stats ars \ @@ -966,6 +972,38 @@ pub struct ArtistDetail { /// the prefill decision 6 refuses. #[serde(skip_serializing_if = "Option::is_none")] pub enrichment: Option, + /// The banner chosen for this artist by hand (RFC-013 decision 3), absent + /// when none was. There is no automatic banner: a client without one + /// draws what it drew before. + #[serde(skip_serializing_if = "Option::is_none")] + pub banner_hash: Option, + /// What was chosen by hand, kind by kind, so a client can say "chosen" + /// rather than "from Deezer". Absent when nothing was. + #[serde(skip_serializing_if = "ImageChoices::is_empty")] + pub image_choice: ImageChoices, +} + +/// The hand-chosen images of one artist (RFC-013). +#[derive(Debug, Clone, Default, Serialize, ToSchema)] +pub struct ImageChoices { + #[serde(skip_serializing_if = "Option::is_none")] + pub portrait: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub banner: Option, +} + +impl ImageChoices { + pub fn is_empty(&self) -> bool { + self.portrait.is_none() && self.banner.is_none() + } +} + +/// Where a chosen image came from and when. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ImageChoice { + /// `upload` or `deezer`. + pub source: String, + pub set_at: i64, } /// What enrichment holds about one artist, merged across providers in the @@ -1355,6 +1393,7 @@ impl From for ServiceError { mod admin; mod albums; mod artist_enrichment; +mod artist_images; mod artists; mod artwork; mod bookmarks; @@ -1379,6 +1418,7 @@ mod sync; mod track_metadata; pub(crate) mod uploads; +pub use artist_images::ArtistImageKind; /// Crate-visible only: the adapter builds the browser-less journey's address /// from it, and nothing outside this binary has a use for it. pub(crate) use lastfm::LASTFM_AUTHORIZE_URL; diff --git a/tests/native_api.rs b/tests/native_api.rs index 30fa1d4c..538480b5 100644 --- a/tests/native_api.rs +++ b/tests/native_api.rs @@ -5237,3 +5237,350 @@ async fn numbered_names_sort_by_value_in_both_trees() { let filed = get(folders_uri).await; assert_eq!(names(&filed, "folders"), ["vol 1", "Vol 2", "Vol 10"]); } + +/// Artist images chosen by hand (RFC-013 step 2), through the router: a chosen +/// portrait leads every reader and outlives the provider's fill, a banner +/// rides on the artist alone, the sweep keeps both, and only owners and +/// managers choose. The images are bytes with the right magic numbers: the +/// server recognises a format from the bytes and never decodes an image. +/// Passwords are drawn at run time, for CodeQL. +#[tokio::test] +async fn chosen_artist_images_lead_every_reader_and_outlive_the_portrait_fill() { + let (_temp, config, state) = test_app().await; + let password = uuid::Uuid::new_v4().to_string(); + let hash = security::hash_password(&password).unwrap(); + let mut accounts = Vec::new(); + for name in ["picture-owner", "picture-listener", "picture-stranger"] { + accounts.push( + state + .db + .create_account(name, &hash, AccountRole::User, now_ms()) + .await + .unwrap(), + ); + } + let (owner, listener) = (accounts[0], accounts[1]); + let root = std::fs::canonicalize(&config.data_dir).unwrap(); + let library_id = state + .db + .create_library( + owner, + "Pictures", + &root, + LibraryVisibility::Private, + now_ms(), + ) + .await + .unwrap(); + state + .db + .add_library_member( + owner, + library_id, + listener, + waveflow_server::database::LibraryRole::Listener, + now_ms(), + ) + .await + .unwrap(); + let scan_id = state + .db + .create_scan_job(library_id, Some(owner), "manual") + .await + .unwrap(); + state.db.start_scan_job(scan_id, 1, false).await.unwrap(); + let mut input = browse_input(50_000, "Hyperballad", "Post", "Björk", Some(1), Some(1)); + input.relative_path = "Björk/Post/01.flac".into(); + state + .db + .apply_catalog_track(library_id, scan_id, &input, None, false) + .await + .unwrap(); + state.db.finish_scan_job(scan_id, 0).await.unwrap(); + + // What RFC-011's fill does: an artwork row from a provider, linked on the + // artist's own column. + let provider_portrait = |seed: &str| format!("{:0>64}", seed); + let link_provider_portrait = |hash: String| { + let pool = state.db.pool().clone(); + async move { + sqlx::query( + "INSERT INTO artwork (hash, format, source, byte_size, created_at) \ + VALUES (?, 'jpg', 'deezer', 1, 0) ON CONFLICT (hash) DO NOTHING", + ) + .bind(&hash) + .execute(&pool) + .await + .unwrap(); + sqlx::query("UPDATE artist SET artwork_hash = ? WHERE name = 'Björk'") + .bind(&hash) + .execute(&pool) + .await + .unwrap(); + } + }; + let deezer = provider_portrait("d1"); + link_provider_portrait(deezer.clone()).await; + + let router = waveflow_server::app(&config, state.clone()); + let owner_token = login_token(&router, "picture-owner", &password).await; + let listener_token = login_token(&router, "picture-listener", &password).await; + let stranger_token = login_token(&router, "picture-stranger", &password).await; + let send = |method: Method, uri: String, token: &str, body: Vec| { + let router = router.clone(); + let token = token.to_owned(); + async move { + router + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap() + } + }; + let artists = + json_body(send(Method::GET, "/api/v2/artists".into(), &owner_token, vec![]).await).await; + let artist = artists + .as_array() + .unwrap() + .iter() + .find(|artist| artist["name"] == "Björk") + .unwrap()["id"] + .as_str() + .unwrap() + .to_owned(); + let artist_uri = format!("/api/v2/artists/{artist}"); + let image_uri = |kind: &str| format!("{artist_uri}/image/{kind}"); + let png = |seed: u8| { + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + bytes.extend(std::iter::repeat_n(seed, 64)); + bytes + }; + let jpeg = |seed: u8| { + let mut bytes = vec![0xff, 0xd8, 0xff]; + bytes.extend(std::iter::repeat_n(seed, 64)); + bytes + }; + let hash_of = |bytes: &[u8]| blake3::hash(bytes).to_hex().to_string(); + let events = || { + let pool = state.db.pool().clone(); + let artist = artist.clone(); + async move { + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM library_event WHERE entity_type = 'artist' AND entity_id = ?", + ) + .bind(artist) + .fetch_one(&pool) + .await + .unwrap() + } + }; + + // Before any choice: the provider's portrait, no banner, nothing chosen. + let before = json_body(send(Method::GET, artist_uri.clone(), &owner_token, vec![]).await).await; + assert_eq!(before["artwork_hash"], deezer.as_str()); + assert!(omits(&before, "banner_hash")); + assert!(omits(&before, "image_choice")); + + // A chosen portrait leads, says it was chosen, and tells clients so. + let events_before = events().await; + let portrait = png(1); + let chosen = send( + Method::PUT, + image_uri("portrait"), + &owner_token, + portrait.clone(), + ) + .await; + assert_eq!(chosen.status(), StatusCode::OK); + let chosen = json_body(chosen).await; + assert_eq!(chosen["artwork_hash"], hash_of(&portrait).as_str()); + assert_eq!(chosen["image_choice"]["portrait"]["source"], "upload"); + assert!(omits(&chosen["image_choice"], "banner")); + assert_eq!(events().await, events_before + 1); + + // A banner rides on the artist alone; the portrait stays. + let banner = jpeg(2); + let with_banner = json_body( + send( + Method::PUT, + image_uri("banner"), + &owner_token, + banner.clone(), + ) + .await, + ) + .await; + assert_eq!(with_banner["banner_hash"], hash_of(&banner).as_str()); + assert_eq!(with_banner["artwork_hash"], hash_of(&portrait).as_str()); + + // Every reader: the listing, the artist the Subsonic façade renders from, + // and the artwork route by hash and by the artist's id. + let listed = json_body( + send( + Method::GET, + "/api/v2/artists".into(), + &listener_token, + vec![], + ) + .await, + ) + .await; + let listed = listed + .as_array() + .unwrap() + .iter() + .find(|row| row["name"] == "Björk") + .unwrap() + .clone(); + assert_eq!(listed["artwork_hash"], hash_of(&portrait).as_str()); + let artist_id = uuid::Uuid::parse_str(&artist).unwrap(); + let detail = state.services.artist(owner, artist_id).await.unwrap(); + assert_eq!( + detail.artist.artwork_hash.as_deref(), + Some(hash_of(&portrait).as_str()) + ); + for (id, expected) in [(hash_of(&banner), &banner), (artist.clone(), &portrait)] { + let served = send( + Method::GET, + format!("/api/v2/artwork/{id}"), + &listener_token, + vec![], + ) + .await; + assert_eq!(served.status(), StatusCode::OK, "{id}"); + let bytes = axum::body::to_bytes(served.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&bytes[..], &expected[..], "{id}"); + } + assert_eq!( + send( + Method::GET, + format!("/api/v2/artwork/{}", hash_of(&banner)), + &stranger_token, + vec![] + ) + .await + .status(), + StatusCode::NOT_FOUND + ); + + // The provider's fill rewrites the artist's own column; the choice holds. + let refreshed = provider_portrait("d2"); + link_provider_portrait(refreshed.clone()).await; + let after_fill = + json_body(send(Method::GET, artist_uri.clone(), &owner_token, vec![]).await).await; + assert_eq!(after_fill["artwork_hash"], hash_of(&portrait).as_str()); + + // The sweep keeps what a choice names, row and file. + state.services.sweep_artwork_store().await.unwrap(); + for image in [&portrait, &banner] { + let named: Option = sqlx::query_scalar("SELECT hash FROM artwork WHERE hash = ?") + .bind(hash_of(image)) + .fetch_optional(state.db.pool()) + .await + .unwrap(); + assert!(named.is_some()); + } + let served = send( + Method::GET, + format!("/api/v2/artwork/{}", hash_of(&banner)), + &owner_token, + vec![], + ) + .await; + assert_eq!(served.status(), StatusCode::OK); + + // Only owners and managers choose; everyone else is answered as a stranger. + for token in [&listener_token, &stranger_token] { + assert_eq!( + send(Method::PUT, image_uri("portrait"), token, png(3)) + .await + .status(), + StatusCode::NOT_FOUND + ); + assert_eq!( + send(Method::DELETE, image_uri("banner"), token, vec![]) + .await + .status(), + StatusCode::NOT_FOUND + ); + } + assert_eq!( + send(Method::GET, artist_uri.clone(), &stranger_token, vec![]) + .await + .status(), + StatusCode::NOT_FOUND + ); + // A refused upload leaves nothing in the store: access is asked before the + // file is written, not only under the gate after it. + assert!(!config + .artwork_dir + .join(format!("{}.png", hash_of(&png(3)))) + .exists()); + // Not an image, too large, or no such kind. + assert_eq!( + send( + Method::PUT, + image_uri("portrait"), + &owner_token, + b"not an image".to_vec() + ) + .await + .status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + let mut huge = png(4); + huge.resize(10 * 1024 * 1024 + 1, 0); + assert_eq!( + send(Method::PUT, image_uri("portrait"), &owner_token, huge) + .await + .status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + assert_eq!( + send(Method::PUT, image_uri("cover"), &owner_token, png(5)) + .await + .status(), + StatusCode::NOT_FOUND + ); + // Exactly at the cap is still an image. + let mut largest = png(6); + largest.resize(10 * 1024 * 1024, 0); + assert_eq!( + send(Method::PUT, image_uri("banner"), &owner_token, largest) + .await + .status(), + StatusCode::OK + ); + + // Back to automatic: the provider's latest portrait, and no banner. + let events_before = events().await; + for kind in ["portrait", "banner"] { + assert_eq!( + send(Method::DELETE, image_uri(kind), &owner_token, vec![]) + .await + .status(), + StatusCode::NO_CONTENT, + "{kind}" + ); + } + assert_eq!(events().await, events_before + 2); + let automatic = + json_body(send(Method::GET, artist_uri.clone(), &owner_token, vec![]).await).await; + assert_eq!(automatic["artwork_hash"], refreshed.as_str()); + assert!(omits(&automatic, "banner_hash")); + assert!(omits(&automatic, "image_choice")); + assert_eq!( + send(Method::DELETE, image_uri("portrait"), &owner_token, vec![]) + .await + .status(), + StatusCode::NOT_FOUND + ); +} From 26ca73a174817d27c4b7f11715fed784b58c53da Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 23 Oct 2026 22:11:25 +0200 Subject: [PATCH 2/2] docs: RFC-013 names #309 in its Implemented by line Signed-off-by: InstaZDLL --- docs/rfcs/RFC-013-chosen-artist-imagery.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/rfcs/RFC-013-chosen-artist-imagery.md b/docs/rfcs/RFC-013-chosen-artist-imagery.md index f18c6b9e..8450f614 100644 --- a/docs/rfcs/RFC-013-chosen-artist-imagery.md +++ b/docs/rfcs/RFC-013-chosen-artist-imagery.md @@ -3,8 +3,9 @@ - **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 the pull request serving the choice (`artist_image_choice`, the - effective portrait, `banner_hash`, the upload and removal routes). The + RFC) and [#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 *Status* field above never flips in this project; this line is the one that says what is running. - **Date**: 2026-10-23