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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/rfcs/RFC-013-chosen-artist-imagery.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
InstaZDLL marked this conversation as resolved.
- **Date**: 2026-10-23
- **Authors**: the WaveFlow project
- **Depends on**: [RFC-002](RFC-002-waveflow-server-v2.md),
Expand Down
20 changes: 20 additions & 0 deletions migrations-v2/20261023000000_artist_image_choice.sql
Original file line number Diff line number Diff line change
@@ -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);
43 changes: 43 additions & 0 deletions src/api/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>, 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<AppState>,
Path((artist_id, kind)): Path<(Uuid, String)>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<crate::services::ArtistDetail>, 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<AppState>,
Path((artist_id, kind)): Path<(Uuid, String)>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
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)
}
6 changes: 6 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions src/portrait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,23 @@ pub struct Portrait {
pub bytes: Vec<u8>,
}

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<u8>) -> Option<Self> {
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 {
Expand Down
209 changes: 209 additions & 0 deletions src/services/artist_images.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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<Uuid, ServiceError> {
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<String>, 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<u8>,
) -> 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(())
}
}
3 changes: 3 additions & 0 deletions src/services/artists.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,14 @@ impl DomainServices {
.map(album_from_row)
.collect::<Result<Vec<_>, _>>()?;
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,
})
}

Expand Down
9 changes: 6 additions & 3 deletions src/services/artwork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?
Expand Down
Loading
Loading