diff --git a/docs/rfcs/RFC-013-chosen-artist-imagery.md b/docs/rfcs/RFC-013-chosen-artist-imagery.md index 08369b2e..8450f614 100644 --- a/docs/rfcs/RFC-013-chosen-artist-imagery.md +++ b/docs/rfcs/RFC-013-chosen-artist-imagery.md @@ -1,8 +1,13 @@ # 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 [#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 - **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 + ); +}