diff --git a/docs/rfcs/RFC-013-chosen-artist-imagery.md b/docs/rfcs/RFC-013-chosen-artist-imagery.md index 3b7c529a..aa99d203 100644 --- a/docs/rfcs/RFC-013-chosen-artist-imagery.md +++ b/docs/rfcs/RFC-013-chosen-artist-imagery.md @@ -5,10 +5,14 @@ below: [#308](https://github.com/InstaZDLL/waveflow-server/pull/308) (this RFC), [#309](https://github.com/InstaZDLL/waveflow-server/pull/309) (serving the choice: `artist_image_choice`, the effective portrait, - `banner_hash`, the upload and removal routes) and + `banner_hash`, the upload and removal routes), [#310](https://github.com/InstaZDLL/waveflow-server/pull/310) (drawing it in the web client: the banner behind the artist header, and "Change - portrait" / "Change banner" for owners and managers). The + portrait" / "Change banner" for owners and managers) and + [#311](https://github.com/InstaZDLL/waveflow-server/pull/311) (Deezer + candidates: the search and candidate tables, the candidates route with + server-held previews, the pick checked against a live search, and "Search + Deezer" in the web client). The *Status* field above never flips in this project; this line is the one that says what is running. - **Date**: 2026-10-23 @@ -160,9 +164,12 @@ 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, at Deezer's small picture size, - and the candidate carries its `artwork_hash`. The client shows previews from - the server, never from Deezer; + resolution, 10 MiB) into the artwork store, at Deezer's big picture size + (500 pixels) — not the small one, since the preview is what a pick makes the + portrait — and the candidate carries its `artwork_hash`. A hit with no real + picture (Deezer's placeholder) is not offered, nor one whose picture the + fetcher refuses for what it is: there would be nothing to choose. 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. @@ -254,9 +261,10 @@ CREATE TABLE artist_image_candidate ( 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 - that task. Its candidates go by cascade, and the previews already stored - become unreferenced artwork for the sweep; + - a search whose Deezer query fails, or whose picture host cannot be + reached for any preview, is **deleted** by that task. Its candidates go by + cascade, and the previews already stored become unreferenced artwork for + the sweep; - **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 @@ -299,8 +307,10 @@ in **one transaction** each. 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. + an incomplete search has stored so far have no row yet: their rows and the + candidates naming them are written together, in the transaction that + completes the search. Until then the files are kept by the sweep's write + grace — an hour, against a fetch deadline of five minutes. - 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, @@ -355,7 +365,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 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. | +| `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; `422` for an empty query; `503` when Deezer or a picture host could not be reached. Each preview is served by `GET /artwork/{hash}` to the owners and managers of the artist's library. | | `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 @@ -365,7 +375,9 @@ services"), so the desktop can reach the same rules when it chooses to. - **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 artwork sweep** keeps hashes named by `artist_image_choice`, and from + step 4 those named by `artist_image_candidate`, once it has deleted the + spent searches in the same transaction. - **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 diff --git a/migrations-v2/20261024000000_artist_image_search.sql b/migrations-v2/20261024000000_artist_image_search.sql new file mode 100644 index 00000000..ed73ccc8 --- /dev/null +++ b/migrations-v2/20261024000000_artist_image_search.sql @@ -0,0 +1,45 @@ +-- A Deezer search for an artist's portrait, and what it offered (RFC-013 +-- decision 4). +-- +-- The pick names a Deezer id and nothing else, so the server remembers what it +-- offered: a search per artist and query, and its candidates in Deezer's +-- order, each with the preview the server fetched. Keyed by the artist row, +-- which is per library, so any owner or manager of that library may pick from +-- it and no other library ever finds it. +CREATE TABLE artist_image_search ( + id TEXT PRIMARY KEY NOT NULL, + 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, + -- 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 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)), + CHECK ((complete = 1) = (expires_at IS NOT NULL)) +) STRICT; + +CREATE INDEX artist_image_search_artist ON artist_image_search(artist_id, query_key); +CREATE INDEX artist_image_search_account ON artist_image_search(searched_by, complete); + +-- The preview names its artwork with no `ON DELETE`, as a choice does: the +-- sweep keeps what a candidate names, and one that forgot would fail rather +-- than leave a candidate pointing at nothing. +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, + name TEXT NOT NULL, + fans INTEGER, + artwork_hash TEXT NOT NULL REFERENCES artwork(hash), + PRIMARY KEY (search_id, position), + UNIQUE (search_id, deezer_id) +) STRICT; + +CREATE INDEX artist_image_candidate_artwork ON artist_image_candidate(artwork_hash); diff --git a/src/api/catalog.rs b/src/api/catalog.rs index 47138006..cd8a23f1 100644 --- a/src/api/catalog.rs +++ b/src/api/catalog.rs @@ -390,3 +390,60 @@ pub async fn delete_artist_image( .map(|()| StatusCode::NO_CONTENT) .map_err(service_error) } + +#[derive(Debug, Deserialize)] +pub struct CandidateQuery { + pub q: String, +} + +/// Deezer's artists for `q`, offered as this artist's portrait (RFC-013): up +/// to 20, each with a preview the server fetched and holds, and no picture +/// URL. Stored as a search for an hour, and the same query answered from it +/// while it lives. Owners and managers; `404` for anyone else and where +/// Deezer is not configured; `429` while the account's previous search is +/// still fetching; `503` when Deezer could not be reached. +#[utoipa::path(get, path = "/api/v2/artists/{artist_id}/image/candidates", tag = "catalog", params(("artist_id" = Uuid, Path), ("q" = String, Query)), responses((status = 200, body = [crate::services::ArtistImageCandidate]), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse), (status = 429, body = ErrorResponse), (status = 503, body = ErrorResponse)))] +pub async fn list_artist_image_candidates( + State(state): State, + Path(artist_id): Path, + Query(query): Query, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Write).await?; + state + .services + .artist_image_candidates(user.id, artist_id, &query.q) + .await + .map(Json) + .map_err(service_error) +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct DeezerPortraitPick { + /// A Deezer artist id offered by a live search of this artist. + pub deezer_id: i64, +} + +/// Chooses as the portrait the preview a live search of this artist offered +/// for `deezer_id`, never fetched again. `422` for an id no live search of +/// this artist offered; `404` where Deezer is not configured now. +#[utoipa::path(put, path = "/api/v2/artists/{artist_id}/image/portrait/deezer", tag = "catalog", params(("artist_id" = Uuid, Path)), request_body = DeezerPortraitPick, responses((status = 200, body = crate::services::ArtistDetail), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn put_artist_deezer_portrait( + State(state): State, + Path(artist_id): Path, + headers: HeaderMap, + Json(pick): Json, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Write).await?; + state + .services + .choose_deezer_portrait(user.id, artist_id, pick.deezer_id) + .await + .map_err(service_error)?; + state + .services + .artist_page(user.id, artist_id) + .await + .map(Json) + .map_err(service_error) +} diff --git a/src/api/error.rs b/src/api/error.rs index 8330cdea..f90ecad7 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -25,6 +25,8 @@ pub enum ApiError { /// a conflict means mint a new operation id, this one means discard the /// local projection and take a fresh snapshot. CursorExpired, + /// The caller's previous request of the same kind is still running. + TooManyRequests, Unavailable, NotFound, } @@ -63,6 +65,11 @@ impl IntoResponse for ApiError { "cursor_expired", "The cursor precedes the oldest retained event; take a fresh snapshot", ), + Self::TooManyRequests => ( + StatusCode::TOO_MANY_REQUESTS, + "too_many_requests", + "The previous request is still running", + ), Self::Unavailable => ( StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", @@ -98,6 +105,7 @@ pub(super) fn service_error(error: crate::services::ServiceError) -> ApiError { ServiceError::NotFound | ServiceError::Forbidden => ApiError::NotFound, ServiceError::Invalid => ApiError::Validation, ServiceError::Conflict => ApiError::Conflict, + ServiceError::Busy => ApiError::TooManyRequests, ServiceError::Unavailable => ApiError::Unavailable, ServiceError::Database(error) => db_error(error), ServiceError::Security(error) => { diff --git a/src/api/mod.rs b/src/api/mod.rs index 8288dd9c..8b88c56c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -186,6 +186,10 @@ pub fn router(state: AppState) -> Router { axum::extract::DefaultBodyLimit::max(crate::portrait::MAX_IMAGE_BYTES), ), ) + .route( + "/api/v2/artists/{artist_id}/image/portrait/deezer", + put(put_artist_deezer_portrait), + ) .route( "/api/v2/artists/{artist_id}/top-songs", get(list_artist_top_songs), @@ -344,3 +348,25 @@ pub fn router(state: AppState) -> Router { ) .with_state(state) } + +/// The one `/api/v2` route that may outlive the ordinary request timeout. +/// +/// A Deezer portrait search (RFC-013) waits for the task fetching it, which +/// runs for up to its own five-minute deadline at the provider's pace. Under +/// the ordinary thirty seconds the client would get a `408` while the task +/// went on, and its retry a `429` until the task ended. So it is merged beside +/// the ordinary API rather than inside it, as the media routes are, under a +/// timeout of its own just past that deadline: the request answers what the +/// task did. +pub fn search_router(state: AppState) -> Router { + Router::new() + .route( + "/api/v2/artists/{artist_id}/image/candidates", + get(list_artist_image_candidates), + ) + .layer(tower_http::timeout::TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + crate::services::ARTIST_IMAGE_SEARCH_DEADLINE + Duration::from_secs(30), + )) + .with_state(state) +} diff --git a/src/enrichment.rs b/src/enrichment.rs index e62f6eb8..204a1bbe 100644 --- a/src/enrichment.rs +++ b/src/enrichment.rs @@ -28,7 +28,7 @@ use std::{ use dashmap::DashMap; use waveflow_core::metadata::{ - deezer::{DeezerClient, DeezerError}, + deezer::{is_placeholder_artist_picture, DeezerArtistHit, DeezerClient, DeezerError}, lastfm::LastfmClient, name_match::{normalize_name, select_by_name}, LookupError, @@ -140,6 +140,27 @@ pub(crate) struct StoredPortrait { pub(crate) byte_size: i64, } +/// A Deezer artist offered as a portrait (RFC-013 decision 4), its preview +/// already in the artwork store. The row naming the preview is the caller's to +/// write, with whatever names it. +#[derive(Debug)] +pub(crate) struct DeezerCandidate { + pub(crate) deezer_id: i64, + pub(crate) name: String, + pub(crate) fans: Option, + pub(crate) preview: StoredPortrait, +} + +/// Why a search for candidates gave none. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CandidateSearchError { + /// Deezer is not among the operator's providers, or images are off. + NotConfigured, + /// Deezer, or a picture host it named, could not be reached, or a preview + /// could not be stored. + Unreached, +} + /// One neighbour in a provider's cached list. #[derive(Debug, Clone)] pub struct CachedSimilar { @@ -325,6 +346,96 @@ impl Enrichment { .map_or(0, |engine| engine.sources.len()) } + /// Whether a portrait may be searched for on Deezer: the operator named + /// it and left images on. Asked at every call, never remembered by a + /// search (RFC-013 decision 4). + pub(crate) fn searches_deezer(&self) -> bool { + self.engine + .as_ref() + .is_some_and(|engine| engine.deezer().is_some()) + } + + /// Deezer's artists for `query`, in its order, at most `limit` of them, + /// each with its picture fetched and stored as a preview (RFC-013 + /// decision 4). + /// + /// Every request — the search and each picture — keeps Deezer's pace, + /// the one enrichment keeps, so a picker cannot make the server ask + /// faster than the operator allowed. A hit with no real picture is not + /// offered, nor one whose picture the fetcher refuses for what it is: + /// there would be nothing to choose. A picture host that cannot be + /// reached fails the whole search, as the search itself failing does. + pub(crate) async fn deezer_candidates( + &self, + query: &str, + limit: usize, + ) -> Result, CandidateSearchError> { + let engine = self + .engine + .as_ref() + .ok_or(CandidateSearchError::NotConfigured)?; + let (source, client, image_hosts) = + engine.deezer().ok_or(CandidateSearchError::NotConfigured)?; + source.pace.wait().await; + let hits = engine + .bounded(client.search_artist(query)) + .await + .map_err(|verdict| { + tracing::warn!( + provider = source.provider.as_str(), + verdict = %verdict, + "portrait candidates unreached" + ); + CandidateSearchError::Unreached + })?; + let mut candidates: Vec = Vec::new(); + for hit in hits { + if candidates.len() >= limit { + break; + } + if candidates.iter().any(|offered| offered.deezer_id == hit.id) { + continue; + } + let Some(picture) = preview_picture(&hit) else { + continue; + }; + source.pace.wait().await; + let fetched = portrait::fetch( + &picture, + image_hosts, + engine.image_addresses, + engine.request_timeout, + ) + .await; + let preview = match fetched { + Ok(preview) => preview, + Err(refusal) if refusal.is_answer() => continue, + Err(refusal) => { + tracing::warn!( + provider = source.provider.as_str(), + verdict = %refusal, + "portrait candidate preview unreached" + ); + return Err(CandidateSearchError::Unreached); + } + }; + portrait::store(&engine.artwork_dir, &preview) + .await + .map_err(|_| CandidateSearchError::Unreached)?; + candidates.push(DeezerCandidate { + deezer_id: hit.id, + name: hit.name, + fans: hit.nb_fan, + preview: StoredPortrait { + hash: preview.hash, + format: preview.format, + byte_size: preview.bytes.len() as i64, + }, + }); + } + Ok(candidates) + } + /// What the cache holds about the artist called `name`, one row per /// configured provider that has one, in the operator's order — and a fill /// scheduled for every configured provider whose row is missing or stale. @@ -555,6 +666,17 @@ impl Engine { }) } + /// The Deezer source, its client and the hosts its pictures come from. + fn deezer(&self) -> Option<(&Source, &DeezerClient, &[String])> { + self.sources.iter().find_map(|source| match &source.client { + Client::Deezer { + client, + image_hosts, + } => Some((source, client, image_hosts.as_slice())), + Client::LastFm { .. } => None, + }) + } + /// One request, under the request timeout; anything but an answer is a /// verdict. async fn bounded( @@ -688,6 +810,17 @@ impl Pace { } } +/// The picture a candidate is previewed at, and kept at if chosen: Deezer's +/// big size, 500 pixels, since the preview is what becomes the portrait. The +/// largest real one when that size is missing; none for Deezer's placeholder, +/// which every size shares. +fn preview_picture(hit: &DeezerArtistHit) -> Option { + hit.picture_big + .clone() + .filter(|url| !is_placeholder_artist_picture(url)) + .or_else(|| hit.best_picture()) +} + fn duration_ms(duration: Duration) -> i64 { i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) } diff --git a/src/lib.rs b/src/lib.rs index 72c5f762..99b0b882 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -161,6 +161,8 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize { api::get_artist, api::put_artist_image, api::delete_artist_image, + api::list_artist_image_candidates, + api::put_artist_deezer_portrait, api::list_artist_top_songs, api::list_artist_albums, api::search_catalog, @@ -312,6 +314,8 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize { services::FiledFolder, services::ImageChoices, services::ImageChoice, + services::ArtistImageCandidate, + api::DeezerPortraitPick, services::FiledTree, services::FolderPatch, services::FolderRemoved, @@ -710,6 +714,10 @@ pub async fn initialize(config: &Config) -> anyhow::Result { // the background task already runs leaves a window, short and sufficient, // in which listens leave for yesterday's destination. RFC-010 decision 10. services.reconcile_scrobble_links().await?; + // A portrait search left fetching has no task across a restart; left at + // `complete = 0` it would hold its account's next search at 429 until its + // deadline passed (RFC-013 decision 4). + services.forget_unfinished_image_searches().await?; // Off unless the operator named a provider, and then it still makes no // request until somebody asks about an artist (RFC-011 decisions 3 and 6). services.use_enrichment(enrichment::Enrichment::new( @@ -823,6 +831,8 @@ pub fn app(config: &Config, state: AppState) -> Router { // thing in this server that four clients were replayed to establish. let compressible = Router::new() .merge(ordinary) + // A portrait search may take minutes; it keeps a timeout of its own. + .merge(api::search_router(state.clone())) // Anything no API route claimed is served by the embedded web client: // a built asset, or the shell for a client-side route. This is what // the layer is mostly for — the built client leaves raw at roughly diff --git a/src/services/artist_image_search.rs b/src/services/artist_image_search.rs new file mode 100644 index 00000000..dac6fb4a --- /dev/null +++ b/src/services/artist_image_search.rs @@ -0,0 +1,433 @@ +//! Deezer as a source of an artist's portrait, chosen by hand (RFC-013 +//! decision 4). +//! +//! The server does the looking, so a browser never loads a picture from Deezer +//! and tells it who is looking: a search asks Deezer's artist search, fetches +//! each candidate's picture into the artwork store, and answers previews the +//! server holds. The pick names a Deezer id and nothing else, so what was +//! offered is remembered — a search and its candidates — and the pick is +//! checked against it. +//! +//! A search is recorded first and fetched by a task of its own, which a dropped +//! request does not stop. That task runs under a fixed deadline and leaves the +//! search either complete or deleted; `complete = 0` only ever means a task is +//! fetching for it right now, which is what lets it stand for "one search at a +//! time" without a lock that a crash could leave held. + +use std::time::Duration; + +use serde::Serialize; +use sqlx::{Row, Sqlite, Transaction}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::artist_images::{chooser_library, record_artist_change, write_choice}; +use super::{ArtistImageKind, DomainServices, ServiceError}; +use crate::authentication::now_ms; +use crate::enrichment::{DeezerCandidate, Enrichment}; + +/// How many candidates one search offers: two rows of a picker, and twenty +/// image fetches at most behind one request. +const CANDIDATE_LIMIT: usize = 20; + +/// The longest query kept; a name longer than this is not a name. +const MAX_QUERY_CHARS: usize = 200; + +/// How long a search lives once complete: 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. +const SEARCH_LIFE_MS: i64 = 60 * 60 * 1000; + +/// How long a search's task may run, from the moment it was recorded: the +/// pace it shares with enrichment, the requests and the writes alike. +pub const ARTIST_IMAGE_SEARCH_DEADLINE: Duration = Duration::from_secs(5 * 60); +const FETCH_DEADLINE_MS: i64 = 5 * 60 * 1000; + +/// One Deezer artist offered as the portrait, with the preview the server +/// fetched and holds. No picture URL: the client shows the preview. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)] +pub struct ArtistImageCandidate { + pub deezer_id: i64, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub fans: Option, + /// The preview, in the artwork store: `GET /api/v2/artwork/{hash}`. + pub artwork_hash: String, +} + +/// The query Deezer is asked, trimmed, and the key a search is stored under, +/// case-folded too: "Björk " and "björk" are one search. `None` for an empty +/// query or one too long to be a name. +fn query_key(query: &str) -> Option<(String, String)> { + let query = query.trim(); + if query.is_empty() || query.chars().count() > MAX_QUERY_CHARS { + return None; + } + Some((query.to_owned(), query.to_lowercase())) +} + +/// The live, complete search of this artist under this key, if any. +async fn live_search( + tx: &mut Transaction<'_, Sqlite>, + artist_id: Uuid, + key: &str, + now: i64, +) -> Result, sqlx::Error> { + sqlx::query_scalar( + "SELECT id FROM artist_image_search \ + WHERE artist_id=? AND query_key=? AND complete=1 AND expires_at>? \ + ORDER BY expires_at DESC LIMIT 1", + ) + .bind(artist_id.to_string()) + .bind(key) + .bind(now) + .fetch_optional(&mut **tx) + .await +} + +async fn candidates_of( + tx: &mut Transaction<'_, Sqlite>, + search_id: &str, +) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT deezer_id, name, fans, artwork_hash FROM artist_image_candidate \ + WHERE search_id=? ORDER BY position", + ) + .bind(search_id) + .fetch_all(&mut **tx) + .await?; + rows.into_iter() + .map(|row| { + Ok(ArtistImageCandidate { + deezer_id: row.try_get("deezer_id")?, + name: row.try_get("name")?, + fans: row.try_get("fans")?, + artwork_hash: row.try_get("artwork_hash")?, + }) + }) + .collect() +} + +/// Deletes the searches no one can use any more, inside the sweep's +/// transaction and before it looks for unreferenced artwork: the complete ones +/// past their hour, and the incomplete ones past their fetch deadline, which +/// no running task can be. A search still fetching has no `expires_at` to +/// pass and is never touched here. +pub(super) async fn forget_spent_searches( + tx: &mut Transaction<'_, Sqlite>, + now: i64, +) -> Result { + sqlx::query( + "DELETE FROM artist_image_search \ + WHERE (complete=1 AND expires_at<=?) OR (complete=0 AND searched_at<=?)", + ) + .bind(now) + .bind(now.saturating_sub(FETCH_DEADLINE_MS)) + .execute(&mut **tx) + .await + .map(|done| done.rows_affected()) +} + +impl DomainServices { + /// Shortens how long a portrait search's task may run, so a test can see + /// it stop without waiting five minutes. Never shorter is the rule the + /// 429 and the sweep rely on: they count a search as stopped only past + /// [`ARTIST_IMAGE_SEARCH_DEADLINE`], which a shorter task never reaches. + pub fn shorten_image_search_deadline(&self, deadline: Duration) { + let deadline = deadline.min(ARTIST_IMAGE_SEARCH_DEADLINE); + self.image_search_deadline_ms.store( + u64::try_from(deadline.as_millis()).unwrap_or(u64::MAX), + std::sync::atomic::Ordering::Relaxed, + ); + } + + fn image_search_deadline(&self) -> Duration { + Duration::from_millis( + self.image_search_deadline_ms + .load(std::sync::atomic::Ordering::Relaxed), + ) + } + + fn enrichment_engine(&self) -> Enrichment { + self.enrichment + .read() + .unwrap_or_else(|poison| poison.into_inner()) + .clone() + } + + /// Deezer's artists for `query`, offered as this artist's portrait. + /// + /// `NotFound` where Deezer is not configured now, and for anyone who may + /// not choose the artist's images; `Invalid` for an empty query; `Busy` + /// while the same account's previous search is still fetching; + /// `Unavailable` when Deezer or a picture host could not be reached. The + /// same query for the same artist, while a search for it lives, is + /// answered from that search and asks Deezer nothing. + pub async fn artist_image_candidates( + &self, + user_id: Uuid, + artist_id: Uuid, + query: &str, + ) -> Result, ServiceError> { + let engine = self.enrichment_engine(); + if !engine.searches_deezer() { + return Err(ServiceError::NotFound); + } + let (asked, key) = query_key(query).ok_or(ServiceError::Invalid)?; + let (search_id, deadline_ms, deadline) = { + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + chooser_library(&mut tx, user_id, artist_id).await?; + let now = now_ms(); + let task_deadline = self.image_search_deadline(); + let deadline = tokio::time::Instant::now() + task_deadline; + if let Some(search_id) = live_search(&mut tx, artist_id, &key, now).await? { + let candidates = candidates_of(&mut tx, &search_id).await?; + tx.commit().await?; + return Ok(candidates); + } + // Only a search that may still be fetching counts: one past its + // deadline has a task that has already stopped. + let busy: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM artist_image_search \ + WHERE searched_by=? AND complete=0 AND searched_at>?)", + ) + .bind(user_id.to_string()) + .bind(now.saturating_sub(FETCH_DEADLINE_MS)) + .fetch_one(&mut *tx) + .await?; + if busy { + return Err(ServiceError::Busy); + } + let search_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO artist_image_search \ + (id, artist_id, query_key, searched_by, searched_at) \ + VALUES (?, ?, ?, ?, ?)", + ) + .bind(&search_id) + .bind(artist_id.to_string()) + .bind(&key) + .bind(user_id.to_string()) + .bind(now) + .execute(&mut *tx) + .await?; + tx.commit().await?; + let deadline_ms = + now.saturating_add(i64::try_from(task_deadline.as_millis()).unwrap_or(i64::MAX)); + (search_id, deadline_ms, deadline) + }; + // The fetching is not the request's: a client that goes away drops + // this handler mid-await, and the task goes on to complete the search + // or delete it. + let services = self.clone(); + tokio::spawn(async move { + services + .fetch_search(engine, search_id, deadline_ms, deadline, asked) + .await + }) + .await + .map_err(|_| ServiceError::Unavailable)? + } + + /// The task behind one search: everything under its deadline, then the + /// search complete or deleted. + async fn fetch_search( + &self, + engine: Enrichment, + search_id: String, + deadline_ms: i64, + deadline: tokio::time::Instant, + query: String, + ) -> Result, ServiceError> { + let fetched = tokio::time::timeout_at(deadline, async { + let candidates = engine + .deezer_candidates(&query, CANDIDATE_LIMIT) + .await + .map_err(|_| ServiceError::Unavailable)?; + self.complete_search(&search_id, deadline_ms, &candidates) + .await + }) + .await + .unwrap_or(Err(ServiceError::Unavailable)); + if fetched.is_err() { + self.abandon_search(&search_id).await; + } + fetched + } + + /// Stores what a search offered and opens its hour, under the writer gate + /// and in one transaction that first checks the deadline: past it, the + /// search is deleted rather than completed. + async fn complete_search( + &self, + search_id: &str, + deadline_ms: i64, + candidates: &[DeezerCandidate], + ) -> Result, ServiceError> { + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + let now = now_ms(); + if now > deadline_ms { + sqlx::query("DELETE FROM artist_image_search WHERE id=?") + .bind(search_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + return Err(ServiceError::Unavailable); + } + // Nothing to complete when the artist went, and its searches with it. + let completed = sqlx::query( + "UPDATE artist_image_search SET complete=1, expires_at=? \ + WHERE id=? AND complete=0", + ) + .bind(now.saturating_add(SEARCH_LIFE_MS)) + .bind(search_id) + .execute(&mut *tx) + .await? + .rows_affected(); + if completed == 0 { + return Err(ServiceError::NotFound); + } + let mut offered = Vec::with_capacity(candidates.len()); + for (position, candidate) in candidates.iter().enumerate() { + let preview = &candidate.preview; + sqlx::query( + "INSERT INTO artwork (hash, format, source, byte_size, created_at) \ + VALUES (?, ?, 'deezer', ?, ?) ON CONFLICT (hash) DO NOTHING", + ) + .bind(&preview.hash) + .bind(preview.format) + .bind(preview.byte_size) + .bind(now) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO artist_image_candidate \ + (search_id, position, deezer_id, name, fans, artwork_hash) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(search_id) + .bind(position as i64) + .bind(candidate.deezer_id) + .bind(&candidate.name) + .bind(candidate.fans) + .bind(&preview.hash) + .execute(&mut *tx) + .await?; + offered.push(ArtistImageCandidate { + deezer_id: candidate.deezer_id, + name: candidate.name.clone(), + fans: candidate.fans, + artwork_hash: preview.hash.clone(), + }); + } + tx.commit().await?; + Ok(offered) + } + + /// Deletes a search its task could not complete. The previews it stored + /// are named by nothing and go with the next sweep. A failure here is + /// left to the sweep too, which deletes the search once past its deadline. + async fn abandon_search(&self, search_id: &str) { + let _writer = self.db.writer_guard().await; + let deleted = sqlx::query("DELETE FROM artist_image_search WHERE id=? AND complete=0") + .bind(search_id) + .execute(self.db.pool()) + .await; + if let Err(error) = deleted { + tracing::warn!(%error, "could not delete a failed portrait search"); + } + } + + /// Deletes every search left incomplete: run at boot, before anything is + /// served, since nothing can be fetching for one across a restart. + pub async fn forget_unfinished_image_searches(&self) -> Result { + let _writer = self.db.writer_guard().await; + Ok( + sqlx::query("DELETE FROM artist_image_search WHERE complete=0") + .execute(self.db.pool()) + .await? + .rows_affected(), + ) + } + + /// Choose the portrait a live search of this artist offered as + /// `deezer_id`: that candidate's preview, never fetched again. + /// + /// `NotFound` where Deezer is not configured now, a live search + /// notwithstanding, and for anyone who may not choose; `Invalid` for an id + /// no live search of this artist offered. The read and the write share + /// one transaction under the writer gate, which the sweep takes too, so a + /// preview live when it is read cannot be swept before it is named. + pub async fn choose_deezer_portrait( + &self, + user_id: Uuid, + artist_id: Uuid, + deezer_id: i64, + ) -> Result<(), ServiceError> { + if !self.enrichment_engine().searches_deezer() { + return Err(ServiceError::NotFound); + } + 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(); + // The most recent search that offered it: the previews are the same + // picture at the same size, so this decides nothing visible. + let preview: Option = sqlx::query_scalar( + "SELECT c.artwork_hash FROM artist_image_candidate c \ + JOIN artist_image_search s ON s.id=c.search_id \ + WHERE s.artist_id=? AND s.complete=1 AND s.expires_at>? AND c.deezer_id=? \ + ORDER BY s.searched_at DESC LIMIT 1", + ) + .bind(artist_id.to_string()) + .bind(now) + .bind(deezer_id) + .fetch_optional(&mut *tx) + .await?; + let preview = preview.ok_or(ServiceError::Invalid)?; + write_choice( + &mut tx, + artist_id, + ArtistImageKind::Portrait, + &preview, + "deezer", + user_id, + now, + ) + .await + .map_err(|error| match error { + // A preview that slipped out from under the pick fails the key + // rather than storing a dangling hash; the owner searches again. + sqlx::Error::Database(ref database) if database.is_foreign_key_violation() => { + ServiceError::Invalid + } + error => ServiceError::Database(error), + })?; + record_artist_change(&mut tx, library_id, artist_id, now).await?; + tx.commit().await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::query_key; + + #[test] + fn a_query_is_keyed_trimmed_and_case_folded() { + assert_eq!( + query_key(" Björk "), + Some(("Björk".to_owned(), "björk".to_owned())) + ); + assert_eq!(query_key("BJÖRK").map(|(_, key)| key), Some("björk".into())); + } + + #[test] + fn an_empty_or_overlong_query_is_refused() { + assert_eq!(query_key(" "), None); + assert_eq!(query_key(&"a".repeat(201)), None); + assert!(query_key(&"a".repeat(200)).is_some()); + } +} diff --git a/src/services/artist_images.rs b/src/services/artist_images.rs index e6b7a9ee..3ac4350d 100644 --- a/src/services/artist_images.rs +++ b/src/services/artist_images.rs @@ -45,7 +45,7 @@ impl ArtistImageKind { /// 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( +pub(super) async fn chooser_library( tx: &mut Transaction<'_, Sqlite>, user_id: Uuid, artist_id: Uuid, @@ -68,7 +68,7 @@ async fn chooser_library( Uuid::parse_str(row.try_get::<&str, _>("library_id")?).map_err(|_| ServiceError::Invalid) } -async fn record_artist_change( +pub(super) async fn record_artist_change( tx: &mut Transaction<'_, Sqlite>, library_id: Uuid, artist_id: Uuid, @@ -89,6 +89,36 @@ async fn record_artist_change( .await } +/// Writes the choice, replacing whatever was chosen before for that kind. +/// `source` is `upload` or `deezer`, as the table's check allows. +pub(super) async fn write_choice( + tx: &mut Transaction<'_, Sqlite>, + artist_id: Uuid, + kind: ArtistImageKind, + artwork_hash: &str, + source: &str, + user_id: Uuid, + now: i64, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO artist_image_choice \ + (artist_id, kind, artwork_hash, source, set_by, set_at) \ + VALUES (?, ?, ?, ?, ?, ?) \ + 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(artwork_hash) + .bind(source) + .bind(user_id.to_string()) + .bind(now) + .execute(&mut **tx) + .await + .map(drop) +} + impl DomainServices { /// The chosen banner and what was chosen, for the artist page. Read after /// access to the artist was established by the caller. @@ -160,20 +190,15 @@ impl DomainServices { .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", + write_choice( + &mut tx, + artist_id, + kind, + &image.hash, + "upload", + user_id, + now, ) - .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?; diff --git a/src/services/artwork.rs b/src/services/artwork.rs index aa259dc7..afab3f07 100644 --- a/src/services/artwork.rs +++ b/src/services/artwork.rs @@ -221,16 +221,24 @@ impl DomainServices { /// on; there is only asking. async fn forget_unreferenced_artwork(&self) -> Result { let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + // The spent portrait searches first (RFC-013), in the same + // transaction: their previews become unreferenced here, and a preview + // a choice already names is kept by the clause below. + super::artist_image_search::forget_spent_searches(&mut tx, crate::authentication::now_ms()) + .await?; let removed = sqlx::query( "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 artist_image_choice c WHERE c.artwork_hash = artwork.hash)", + AND NOT EXISTS (SELECT 1 FROM artist_image_choice c WHERE c.artwork_hash = artwork.hash) \ + AND NOT EXISTS (SELECT 1 FROM artist_image_candidate c WHERE c.artwork_hash = artwork.hash)", ) - .execute(self.db.pool()) + .execute(&mut *tx) .await? .rows_affected(); + tx.commit().await?; Ok(usize::try_from(removed).unwrap_or(usize::MAX)) } } diff --git a/src/services/catalog.rs b/src/services/catalog.rs index a1f5f6ec..00562817 100644 --- a/src/services/catalog.rs +++ b/src/services/catalog.rs @@ -233,14 +233,16 @@ impl DomainServices { 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 SELECT 1 FROM artist_image_candidate c JOIN artist_image_search s ON s.id=c.search_id JOIN artist ar ON ar.id=s.artist_id JOIN library_member m ON m.library_id=ar.library_id WHERE c.artwork_hash=a.hash AND m.user_id=? AND m.role IN ('owner', 'manager') \ ) 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=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", ) // 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()) + // portrait, the chosen one first. A Deezer preview is served to those + // who may pick it: the owners and managers of the artist's library. + .bind(id).bind(user_id.to_string()).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 4dc787a0..fa584814 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1355,6 +1355,10 @@ pub struct DomainServices { /// [`Self::use_enrichment`], like the scrobbling adapters and for the same /// reason: a test drives it against a provider of its own. enrichment: Arc>, + /// How long a portrait search's task may run, in milliseconds (RFC-013): + /// [`ARTIST_IMAGE_SEARCH_DEADLINE`], except where a test shortens it + /// rather than wait five minutes. + image_search_deadline_ms: Arc, } #[derive(Debug, thiserror::Error)] @@ -1367,6 +1371,10 @@ pub enum ServiceError { Invalid, #[error("conflict")] Conflict, + /// The caller already has one of these in flight and must wait for it: + /// a portrait search on Deezer (RFC-013), one per account at a time. + #[error("too many requests")] + Busy, #[error("service unavailable")] Unavailable, #[error(transparent)] @@ -1393,6 +1401,7 @@ impl From for ServiceError { mod admin; mod albums; mod artist_enrichment; +mod artist_image_search; mod artist_images; mod artists; mod artwork; @@ -1418,6 +1427,7 @@ mod sync; mod track_metadata; pub(crate) mod uploads; +pub use artist_image_search::{ArtistImageCandidate, ARTIST_IMAGE_SEARCH_DEADLINE}; 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. @@ -1492,6 +1502,9 @@ impl DomainServices { enrichment: Arc::new(std::sync::RwLock::new( crate::enrichment::Enrichment::disabled(), )), + image_search_deadline_ms: Arc::new(std::sync::atomic::AtomicU64::new( + u64::try_from(ARTIST_IMAGE_SEARCH_DEADLINE.as_millis()).unwrap_or(u64::MAX), + )), } } diff --git a/tests/enrichment.rs b/tests/enrichment.rs index 60cc4b74..038dfc7d 100644 --- a/tests/enrichment.rs +++ b/tests/enrichment.rs @@ -1400,3 +1400,680 @@ async fn a_cached_portrait_is_linked_again_without_a_second_download() { assert_eq!(portrait_of(&state, owner).await, linked); assert_eq!(listener.fake.images.load(Ordering::SeqCst), 1); } + +// ── Portrait candidates: a Deezer search the server previews (RFC-013) ── + +use waveflow_server::services::ServiceError; + +/// A fake Deezer that offers, for any query, twenty-four artists with a +/// picture of their own each — and two it cannot show among them: one with +/// Deezer's placeholder, one whose picture is not an image. +#[derive(Clone)] +struct Gallery { + searches: Arc, + images: Arc, + /// Pictures asked at any size but Deezer's big one. + other_sizes: Arc, + /// A search waits here for a permit. A test that holds a search in + /// flight starts it with none and adds them when it is done holding. + gate: Arc, + /// Every picture answers 503: a host that cannot be reached. + failing: bool, + origin: Arc, +} + +/// The fake's ids, in the order its search lists them. +const PLACEHOLDER_ID: i64 = 900; +const NOT_AN_IMAGE_ID: i64 = 901; + +async fn gallery_search(State(gallery): State) -> Response { + gallery.searches.fetch_add(1, Ordering::SeqCst); + let _permit = gallery.gate.acquire().await.unwrap(); + let origin = &*gallery.origin; + let mut data = Vec::new(); + for id in 1..=24_i64 { + if id == 3 { + data.push(serde_json::json!({ + "id": PLACEHOLDER_ID, + "name": "Nobody", + "picture_big": format!("{origin}/images/artist//500x500-000000-80-0-0.jpg"), + })); + } + if id == 5 { + data.push(serde_json::json!({ + "id": NOT_AN_IMAGE_ID, + "name": "Broken", + "picture_big": format!("{origin}/img/{NOT_AN_IMAGE_ID}/big.jpg"), + })); + } + data.push(serde_json::json!({ + "id": id, + "name": format!("Björk {id}"), + "nb_fan": id * 1000, + "picture_small": format!("{origin}/img/{id}/small.jpg"), + "picture_big": format!("{origin}/img/{id}/big.jpg"), + "picture_xl": format!("{origin}/img/{id}/xl.jpg"), + })); + } + json(serde_json::json!({ "data": data }).to_string()) +} + +async fn gallery_picture( + State(gallery): State, + axum::extract::Path((id, size)): axum::extract::Path<(i64, String)>, +) -> Response { + gallery.images.fetch_add(1, Ordering::SeqCst); + if size != "big.jpg" { + gallery.other_sizes.fetch_add(1, Ordering::SeqCst); + } + if gallery.failing { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + let bytes = if id == NOT_AN_IMAGE_ID { + b"no picture here".to_vec() + } else { + let mut bytes = portrait_bytes(); + bytes.extend_from_slice(format!("-{id}").as_bytes()); + bytes + }; + ([(header::CONTENT_TYPE, "image/jpeg")], bytes).into_response() +} + +async fn fake_gallery(failing: bool, open: bool) -> (Gallery, String) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); + let gallery = Gallery { + searches: Arc::default(), + images: Arc::default(), + other_sizes: Arc::default(), + gate: Arc::new(tokio::sync::Semaphore::new(if open { 1_000 } else { 0 })), + failing, + origin: Arc::new(base_url.clone()), + }; + let router = Router::new() + .route("/search/artist", get(gallery_search)) + .route("/img/{id}/{size}", get(gallery_picture)) + .with_state(gallery.clone()); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + (gallery, base_url) +} + +fn gallery_engine( + state: &waveflow_server::AppState, + base_url: &str, + settings: &MetadataSettings, +) -> Enrichment { + Enrichment::with_endpoints( + state.db.clone(), + settings, + state.artwork_dir.clone(), + Endpoints { + deezer: base_url.to_owned(), + deezer_image_hosts: vec!["127.0.0.1".to_owned()], + image_addresses: AddressPolicy::AllowLoopback, + ..Endpoints::default() + }, + ) + .unwrap() +} + +/// Björk and Sigur Rós in a library of the owner's, and the services asking +/// `gallery` for portraits. +async fn picker( + failing: bool, + open: bool, +) -> ( + tempfile::TempDir, + waveflow_server::Config, + waveflow_server::AppState, + Gallery, + uuid::Uuid, + uuid::Uuid, +) { + let (temp, config, state) = support::test_app().await; + let owner = account(&state, "picker-owner").await; + library_of(&state, &config, owner, "picked", &["Björk", "Sigur Rós"]).await; + let (gallery, base_url) = fake_gallery(failing, open).await; + state.services.use_enrichment(gallery_engine( + &state, + &base_url, + &settings(vec![Provider::Deezer], &run_key()), + )); + let bjork = artist_id(&state, owner, "Björk").await; + (temp, config, state, gallery, owner, bjork) +} + +async fn searches_held(state: &waveflow_server::AppState) -> Vec<(i64, Option)> { + sqlx::query_as("SELECT complete, expires_at FROM artist_image_search ORDER BY searched_at") + .fetch_all(state.db.pool()) + .await + .unwrap() +} + +/// Waits, a few seconds at most, for the fake to have been asked `count` +/// searches. +async fn until_searched(gallery: &Gallery, count: usize) { + for _ in 0..500 { + if gallery.searches.load(Ordering::SeqCst) >= count { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("the fake was never asked"); +} + +async fn send( + router: &Router, + method: axum::http::Method, + uri: String, + token: &str, + body: Option, +) -> Response { + use tower::ServiceExt; + let request = axum::http::Request::builder() + .method(method) + .uri(uri) + .header("authorization", format!("Bearer {token}")); + let request = match body { + Some(body) => request + .header("content-type", "application/json") + .body(axum::body::Body::from(body.to_string())), + None => request.body(axum::body::Body::empty()), + }; + router.clone().oneshot(request.unwrap()).await.unwrap() +} + +/// RFC-013 decision 4 through the router: twenty candidates in Deezer's order, +/// each previewed from a picture the server fetched at the big size and holds, +/// and no picture URL; the same query answered again from the stored search; +/// the preview served to owners and managers only; a pick accepted only for an +/// id a live search of that artist offered; and both routes closed the moment +/// Deezer is. Passwords are drawn at run time, for CodeQL. +#[tokio::test] +async fn candidates_are_previewed_by_the_server_and_picked_from_a_live_search() { + use axum::http::Method; + use waveflow_server::database::{AccountRole, LibraryRole}; + + let (_temp, config, state, gallery, owner, bjork) = picker(false, true).await; + let password = uuid::Uuid::new_v4().to_string(); + let hash = waveflow_server::security::hash_password(&password).unwrap(); + let now = waveflow_server::authentication::now_ms(); + sqlx::query("UPDATE account SET username='gallery-owner', password_hash=? WHERE id=?") + .bind(&hash) + .bind(owner.to_string()) + .execute(state.db.pool()) + .await + .unwrap(); + let listener = state + .db + .create_account("gallery-listener", &hash, AccountRole::User, now) + .await + .unwrap(); + state + .db + .create_account("gallery-stranger", &hash, AccountRole::User, now) + .await + .unwrap(); + let library: String = sqlx::query_scalar("SELECT id FROM library WHERE name='picked'") + .fetch_one(state.db.pool()) + .await + .unwrap(); + state + .db + .add_library_member( + owner, + library.parse().unwrap(), + listener, + LibraryRole::Listener, + now, + ) + .await + .unwrap(); + let sigur = artist_id(&state, owner, "Sigur Rós").await; + let router = waveflow_server::app(&config, state.clone()); + let owner_token = support::login_token(&router, "gallery-owner", &password).await; + let listener_token = support::login_token(&router, "gallery-listener", &password).await; + let stranger_token = support::login_token(&router, "gallery-stranger", &password).await; + let candidates_uri = |artist: uuid::Uuid, query: &str| { + format!( + "/api/v2/artists/{artist}/image/candidates?q={}", + query.replace(' ', "%20") + ) + }; + let pick_uri = |artist: uuid::Uuid| format!("/api/v2/artists/{artist}/image/portrait/deezer"); + + let offered = send( + &router, + Method::GET, + candidates_uri(bjork, "Björk"), + &owner_token, + None, + ) + .await; + assert_eq!(offered.status(), StatusCode::OK); + let offered = support::json_body(offered).await; + let offered = offered.as_array().unwrap().clone(); + let ids: Vec = offered + .iter() + .map(|candidate| candidate["deezer_id"].as_i64().unwrap()) + .collect(); + assert_eq!(ids, (1..=20).collect::>()); + assert_eq!(offered[0]["name"], "Björk 1"); + assert_eq!(offered[0]["fans"], 1000); + for candidate in &offered { + let mut keys: Vec<_> = candidate.as_object().unwrap().keys().cloned().collect(); + keys.sort(); + assert_eq!(keys, ["artwork_hash", "deezer_id", "fans", "name"]); + } + // Twenty previews and the picture that was not an image; the placeholder + // never asked; every picture at the big size. + assert_eq!(gallery.searches.load(Ordering::SeqCst), 1); + assert_eq!(gallery.images.load(Ordering::SeqCst), 21); + assert_eq!(gallery.other_sizes.load(Ordering::SeqCst), 0); + + let preview = offered[0]["artwork_hash"].as_str().unwrap().to_owned(); + let artwork_uri = format!("/api/v2/artwork/{preview}"); + let served = send( + &router, + Method::GET, + artwork_uri.clone(), + &owner_token, + None, + ) + .await; + assert_eq!(served.status(), StatusCode::OK); + let refused = send(&router, Method::GET, artwork_uri, &listener_token, None).await; + assert_eq!(refused.status(), StatusCode::NOT_FOUND); + + let again = send( + &router, + Method::GET, + candidates_uri(bjork, " BJÖRK "), + &owner_token, + None, + ) + .await; + assert_eq!(again.status(), StatusCode::OK); + assert_eq!( + support::json_body(again).await.as_array().unwrap(), + &offered + ); + assert_eq!(gallery.searches.load(Ordering::SeqCst), 1); + assert_eq!(gallery.images.load(Ordering::SeqCst), 21); + + for token in [&listener_token, &stranger_token] { + let refused = send( + &router, + Method::GET, + candidates_uri(bjork, "Björk"), + token, + None, + ) + .await; + assert_eq!(refused.status(), StatusCode::NOT_FOUND); + } + let empty = send( + &router, + Method::GET, + candidates_uri(bjork, " "), + &owner_token, + None, + ) + .await; + assert_eq!(empty.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let pick = |id: i64| Some(serde_json::json!({ "deezer_id": id })); + // Offered, but for another artist; never offered; not the listener's. + let elsewhere = send(&router, Method::PUT, pick_uri(sigur), &owner_token, pick(3)).await; + assert_eq!(elsewhere.status(), StatusCode::UNPROCESSABLE_ENTITY); + let unoffered = send( + &router, + Method::PUT, + pick_uri(bjork), + &owner_token, + pick(PLACEHOLDER_ID), + ) + .await; + assert_eq!(unoffered.status(), StatusCode::UNPROCESSABLE_ENTITY); + let listened = send( + &router, + Method::PUT, + pick_uri(bjork), + &listener_token, + pick(3), + ) + .await; + assert_eq!(listened.status(), StatusCode::NOT_FOUND); + + let picked = send(&router, Method::PUT, pick_uri(bjork), &owner_token, pick(3)).await; + assert_eq!(picked.status(), StatusCode::OK); + let picked = support::json_body(picked).await; + assert_eq!(picked["artwork_hash"], offered[2]["artwork_hash"]); + assert_eq!(picked["image_choice"]["portrait"]["source"], "deezer"); + assert_eq!(gallery.images.load(Ordering::SeqCst), 21); + + // The operator's switch is read at every call: a live search notwithstanding. + state.services.use_enrichment(Enrichment::disabled()); + let closed = send( + &router, + Method::GET, + candidates_uri(bjork, "Björk"), + &owner_token, + None, + ) + .await; + assert_eq!(closed.status(), StatusCode::NOT_FOUND); + let closed = send(&router, Method::PUT, pick_uri(bjork), &owner_token, pick(4)).await; + assert_eq!(closed.status(), StatusCode::NOT_FOUND); +} + +/// One search at a time per account: a second while the first is fetching +/// answers 429, and the next one after it starts at once. +#[tokio::test] +async fn a_second_search_while_the_first_fetches_is_refused() { + let (_temp, _config, state, gallery, owner, bjork) = picker(false, false).await; + let services = state.services.clone(); + let first = tokio::spawn(async move { + services + .artist_image_candidates(owner, bjork, "Björk") + .await + }); + until_searched(&gallery, 1).await; + + let second = state + .services + .artist_image_candidates(owner, bjork, "Sigur Rós") + .await; + assert!(matches!(second, Err(ServiceError::Busy)), "{second:?}"); + + gallery.gate.add_permits(1_000); + assert_eq!(first.await.unwrap().unwrap().len(), 20); + let third = state + .services + .artist_image_candidates(owner, bjork, "Sigur Rós") + .await; + assert!(third.is_ok(), "{third:?}"); +} + +/// The fetching is not the request's: a request dropped mid-search — a closed +/// tab — leaves the task to complete the search, and the next picker that +/// asks the same query is answered from it. +#[tokio::test] +async fn a_search_whose_request_is_dropped_still_completes() { + let (_temp, _config, state, gallery, owner, bjork) = picker(false, false).await; + let services = state.services.clone(); + let request = tokio::spawn(async move { + services + .artist_image_candidates(owner, bjork, "Björk") + .await + }); + until_searched(&gallery, 1).await; + request.abort(); + assert!(request.await.unwrap_err().is_cancelled()); + + gallery.gate.add_permits(1_000); + let mut completed = false; + for _ in 0..500 { + if searches_held(&state).await.first().map(|search| search.0) == Some(1) { + completed = true; + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(completed, "the search was left incomplete"); + + let again = state + .services + .artist_image_candidates(owner, bjork, "björk") + .await + .unwrap(); + assert_eq!(again.len(), 20); + assert_eq!(gallery.searches.load(Ordering::SeqCst), 1); +} + +/// A search whose previews cannot be fetched is deleted by its own task, and +/// so does not hold the account's next search at 429. +#[tokio::test] +async fn a_failed_search_is_deleted_and_does_not_hold_the_account() { + let (_temp, _config, state, gallery, owner, bjork) = picker(true, true).await; + for _ in 0..2 { + let failed = state + .services + .artist_image_candidates(owner, bjork, "Björk") + .await; + assert!( + matches!(failed, Err(ServiceError::Unavailable)), + "{failed:?}" + ); + assert!(searches_held(&state).await.is_empty()); + } + assert_eq!(gallery.searches.load(Ordering::SeqCst), 2); +} + +/// The task runs under its deadline as a whole: a Deezer that never answers +/// fails the search at the deadline, not at the request timeout, and the +/// search is deleted. The deadline is cut to a second here and the request +/// timeout set to a minute, so which of the two stopped it shows in the time +/// the search took. +#[tokio::test] +async fn a_search_stops_at_its_deadline() { + let (_temp, _config, state, gallery, owner, bjork) = picker(false, false).await; + let patient = MetadataSettings { + request_timeout: Duration::from_secs(60), + ..settings(vec![Provider::Deezer], &run_key()) + }; + state + .services + .use_enrichment(gallery_engine(&state, gallery.origin.as_str(), &patient)); + state + .services + .shorten_image_search_deadline(Duration::from_secs(1)); + let started = std::time::Instant::now(); + let stopped = state + .services + .artist_image_candidates(owner, bjork, "Björk") + .await; + let waited = started.elapsed(); + assert!( + matches!(stopped, Err(ServiceError::Unavailable)), + "{stopped:?}" + ); + assert!(waited < Duration::from_secs(30), "stopped after {waited:?}"); + assert!(searches_held(&state).await.is_empty()); +} + +/// An incomplete search past its deadline has no task behind it: it holds +/// nobody at 429, and the sweep deletes it — while a search still fetching, +/// and the previews of a live one, are left alone. Once the live search +/// expires, its previews go with the sweep, all but the one chosen. +#[tokio::test] +async fn the_sweep_takes_spent_searches_and_their_previews_only() { + let (_temp, _config, state, _gallery, owner, bjork) = picker(false, true).await; + let now = waveflow_server::authentication::now_ms(); + let stale = uuid::Uuid::new_v4().to_string(); + let fetching = uuid::Uuid::new_v4().to_string(); + for (id, by, at) in [ + (&stale, owner.to_string(), now - 6 * 60 * 1000), + (&fetching, uuid::Uuid::new_v4().to_string(), now), + ] { + sqlx::query( + "INSERT INTO artist_image_search (id, artist_id, query_key, searched_by, searched_at) \ + VALUES (?, ?, 'stale', ?, ?)", + ) + .bind(id) + .bind(bjork.to_string()) + .bind(by) + .bind(at) + .execute(state.db.pool()) + .await + .unwrap(); + } + + let offered = state + .services + .artist_image_candidates(owner, bjork, "Björk") + .await + .expect("a search past its deadline holds nobody"); + let previews: Vec = offered + .iter() + .map(|candidate| candidate.artwork_hash.clone()) + .collect(); + let held = |state: &waveflow_server::AppState, previews: Vec| { + let pool = state.db.pool().clone(); + async move { + let mut count = 0; + for hash in previews { + let found: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM artwork WHERE hash=?") + .bind(hash) + .fetch_one(&pool) + .await + .unwrap(); + count += found; + } + count + } + }; + + state.services.sweep_artwork_store().await.unwrap(); + let ids: Vec = + sqlx::query_scalar("SELECT id FROM artist_image_search WHERE complete=0") + .fetch_all(state.db.pool()) + .await + .unwrap(); + assert_eq!(ids, std::slice::from_ref(&fetching)); + assert_eq!(held(&state, previews.clone()).await, 20); + + state + .services + .choose_deezer_portrait(owner, bjork, 2) + .await + .unwrap(); + sqlx::query("UPDATE artist_image_search SET expires_at=? WHERE complete=1") + .bind(now - 1) + .execute(state.db.pool()) + .await + .unwrap(); + let late = state.services.choose_deezer_portrait(owner, bjork, 3).await; + assert!(matches!(late, Err(ServiceError::Invalid)), "{late:?}"); + + state.services.sweep_artwork_store().await.unwrap(); + assert_eq!(searches_held(&state).await, [(0, None)]); + assert_eq!(held(&state, previews.clone()).await, 1); + assert_eq!( + held(&state, vec![offered[1].artwork_hash.clone()]).await, + 1, + "the chosen preview went with the search" + ); +} + +/// Nothing can be fetching for a search across a restart: boot deletes every +/// incomplete one and keeps the complete ones. +#[tokio::test] +async fn boot_deletes_the_searches_left_fetching() { + let (_temp, config, state, _gallery, owner, bjork) = picker(false, true).await; + let now = waveflow_server::authentication::now_ms(); + for (complete, expires_at) in [(0, None), (1, Some(now + 60 * 60 * 1000))] { + sqlx::query( + "INSERT INTO artist_image_search \ + (id, artist_id, query_key, searched_by, searched_at, expires_at, complete) \ + VALUES (?, ?, 'björk', ?, ?, ?, ?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(bjork.to_string()) + .bind(owner.to_string()) + .bind(now) + .bind(expires_at) + .bind(complete) + .execute(state.db.pool()) + .await + .unwrap(); + } + + waveflow_server::initialize(&config).await.unwrap(); + + let held = searches_held(&state).await; + assert_eq!(held.len(), 1); + assert_eq!(held[0].0, 1); +} + +/// Every request to Deezer keeps enrichment's pace, the previews as much as +/// the search: at twenty a second, a search and its twenty-one picture +/// requests cannot take less than a second. +#[tokio::test] +async fn previews_keep_deezer_s_pace() { + let (_temp, _config, state, gallery, owner, bjork) = picker(false, true).await; + let paced = MetadataSettings { + rate_per_sec: 20, + ..settings(vec![Provider::Deezer], &run_key()) + }; + state + .services + .use_enrichment(gallery_engine(&state, gallery.origin.as_str(), &paced)); + let started = std::time::Instant::now(); + state + .services + .artist_image_candidates(owner, bjork, "Björk") + .await + .unwrap(); + assert_eq!(gallery.images.load(Ordering::SeqCst), 21); + assert!( + started.elapsed() >= Duration::from_secs(1), + "twenty-two requests in {:?}", + started.elapsed() + ); +} + +/// A search outlives the ordinary request timeout: the route keeps a timeout +/// of its own past the search's deadline, so a slow Deezer answers the +/// request with what the task found, not a 408 that sends the retry into a +/// 429. The ordinary timeout is cut to a second here, and the search held for +/// two. Passwords are drawn at run time, for CodeQL. +#[tokio::test] +async fn a_search_outlives_the_ordinary_request_timeout() { + let (_temp, config, state, gallery, owner, bjork) = picker(false, false).await; + let password = uuid::Uuid::new_v4().to_string(); + let hash = waveflow_server::security::hash_password(&password).unwrap(); + sqlx::query("UPDATE account SET username='slow-owner', password_hash=? WHERE id=?") + .bind(&hash) + .bind(owner.to_string()) + .execute(state.db.pool()) + .await + .unwrap(); + // Logged in under the ordinary timeout: hashing the password can take + // longer than a second on a loaded machine, and that is not what is tested. + let token = support::login_token( + &waveflow_server::app(&config, state.clone()), + "slow-owner", + &password, + ) + .await; + let impatient = waveflow_server::Config { + request_timeout: Duration::from_secs(1), + ..config.clone() + }; + let router = waveflow_server::app(&impatient, state.clone()); + + let search = { + let router = router.clone(); + tokio::spawn(async move { + send( + &router, + axum::http::Method::GET, + format!("/api/v2/artists/{bjork}/image/candidates?q=Bj%C3%B6rk"), + &token, + None, + ) + .await + }) + }; + until_searched(&gallery, 1).await; + tokio::time::sleep(Duration::from_secs(2)).await; + gallery.gate.add_permits(1_000); + + let answered = search.await.unwrap(); + assert_eq!(answered.status(), StatusCode::OK); + assert_eq!( + support::json_body(answered).await.as_array().unwrap().len(), + 20 + ); +} diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 3b111d7d..453b6518 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -5460,4 +5460,86 @@ test("shows a listener the artist's banner with nothing to change", async ({ const hero = page.locator(".artist-hero"); await expect(hero.locator("img.artist-banner")).toBeVisible(); await expect(hero.getByRole("button", { name: /Change/ })).toHaveCount(0); + await expect(hero.getByRole("button", { name: "Search Deezer" })).toHaveCount(0); +}); + +/** + * Choosing the portrait from Deezer (RFC-013 step 4): the picker searches the + * artist's name as it opens, shows previews the server holds and nothing from + * Deezer itself, says what a refusal means, and a pick sends the Deezer id + * alone. + */ +test("chooses an artist's portrait from a Deezer search", async ({ page }) => { + const { state } = await serveArtistImages(page); + const searches: string[] = []; + const picks: unknown[] = []; + const thirdParty: string[] = []; + let refuseNext = false; + page.on("request", (request) => { + if (/deezer\.com|dzcdn\.net/.test(request.url())) thirdParty.push(request.url()); + }); + await page.route("**/api/v2/artists/artist-1/image/candidates?*", async (route) => { + const query = new URL(route.request().url()).searchParams.get("q") ?? ""; + searches.push(query); + if (refuseNext) { + refuseNext = false; + await route.fulfill({ status: 429, json: { code: "too_many_requests" } }); + return; + } + await route.fulfill({ + json: + query === "Nobody" + ? [] + : [ + { deezer_id: 76, name: "Björk", fans: 1500000, artwork_hash: "d".repeat(64) }, + { deezer_id: 77, name: "Björk Tribute", fans: 1, artwork_hash: "e".repeat(64) }, + ], + }); + }); + await page.route("**/api/v2/artists/artist-1/image/portrait/deezer", async (route) => { + picks.push(route.request().postDataJSON()); + state.portrait = "d".repeat(64); + await route.fulfill({ + json: { + ...artistDetail, + library_id: "library-1", + artwork_hash: state.portrait, + image_choice: { portrait: { source: "deezer", set_at: 1 } }, + }, + }); + }); + + await page.goto("/artists/artist-1"); + const hero = page.locator(".artist-hero"); + await hero.getByRole("button", { name: "Search Deezer" }).click(); + const picker = page.getByRole("dialog", { name: "Choose a portrait from Deezer" }); + await expect(picker).toBeVisible(); + const results = picker.getByRole("list", { name: "Deezer artists" }); + await expect(results.getByRole("button")).toHaveCount(2); + await expect(results.getByText("1 fan", { exact: true })).toBeVisible(); + expect(searches).toEqual(["Björk"]); + await expectNoWcagViolations(page); + + const query = picker.getByRole("searchbox", { name: "Artist name" }); + await query.fill("Nobody"); + await picker.getByRole("button", { name: "Search", exact: true }).click(); + await expect(picker.getByRole("status")).toHaveText( + "Deezer knows no artist by that name.", + ); + + refuseNext = true; + await query.fill("Bjork"); + await picker.getByRole("button", { name: "Search", exact: true }).click(); + await expect(picker.getByRole("alert")).toHaveText( + "Your previous search is still running. Try again in a moment.", + ); + await picker.getByRole("button", { name: "Search", exact: true }).click(); + await expect(results.getByRole("button")).toHaveCount(2); + + await results.getByRole("button").first().click(); + await expect(picker).toHaveCount(0); + await expect(hero.getByRole("status")).toHaveText("Portrait changed."); + expect(picks).toEqual([{ deezer_id: 76 }]); + expect(searches).toEqual(["Björk", "Nobody", "Bjork", "Bjork"]); + expect(thirdParty).toEqual([]); }); diff --git a/webapp/src/api/catalog.ts b/webapp/src/api/catalog.ts index d085b387..e692a9fc 100644 --- a/webapp/src/api/catalog.ts +++ b/webapp/src/api/catalog.ts @@ -219,6 +219,38 @@ export const setArtistImage = ( headers: { "content-type": "application/octet-stream" }, }); +/** + * A Deezer artist offered as the portrait (RFC-013 decision 4). No picture + * URL: the preview is fetched and held by the server, and shown from it. + */ +export type ArtistImageCandidate = { + deezer_id: number; + name: string; + fans?: number; + /** The preview, in the artwork store. */ + artwork_hash: string; +}; + +/** + * Deezer's artists for `query`, previewed by the server. `404` where Deezer + * is not configured, `429` while this account's previous search is still + * fetching. The same query is answered from the stored search for an hour. + */ +export const getArtistImageCandidates = (artistId: string, query: string) => + call( + `/api/v2/artists/${artistId}/image/candidates?q=${encodeURIComponent(query)}`, + ); + +/** + * Makes the preview a live search offered for `deezerId` the portrait. `422` + * once the search has expired. + */ +export const pickDeezerPortrait = (artistId: string, deezerId: number) => + call(`/api/v2/artists/${artistId}/image/portrait/deezer`, { + method: "PUT", + body: JSON.stringify({ deezer_id: deezerId }), + }); + /** Goes back to the automatic image: the fetched portrait, or no banner. */ export const removeArtistImage = async ( artistId: string, diff --git a/webapp/src/api/index.ts b/webapp/src/api/index.ts index 9e33f9cb..3d5d7c20 100644 --- a/webapp/src/api/index.ts +++ b/webapp/src/api/index.ts @@ -26,6 +26,7 @@ export type { Artist, ArtistDetail, ArtistEnrichment, + ArtistImageCandidate, ArtistImageKind, EnrichmentSource, Genre, @@ -38,6 +39,7 @@ export { formatDuration, getAlbum, getArtist, + getArtistImageCandidates, getTrack, listAlbums, listArtistAlbums, @@ -46,6 +48,7 @@ export { listGenres, listRandomSongs, listSongs, + pickDeezerPortrait, removeArtistImage, SONG_PAGE, search, diff --git a/webapp/src/components/artist-image-controls.tsx b/webapp/src/components/artist-image-controls.tsx index 850e4606..0f2c4861 100644 --- a/webapp/src/components/artist-image-controls.tsx +++ b/webapp/src/components/artist-image-controls.tsx @@ -10,6 +10,7 @@ import { } from "../api"; import { artistQuery } from "../api/queries"; import { type TranslationKey, useI18n } from "../state/i18n"; +import { DeezerPortraitPicker } from "./deezer-portrait-picker"; import { Icon } from "./icons"; /** What the server takes: recognised from the bytes, these three only. */ @@ -44,7 +45,8 @@ const LABELS: Record< * refuses anyone else regardless. * * A file is sent as it is: no crop, no resize. The banner is framed by the - * page at a fixed position, as the desktop frames it. + * page at a fixed position, as the desktop frames it. The portrait may also + * come from Deezer, searched and previewed by the server. */ export function ArtistImageControls({ artist }: { artist: ArtistDetail }) { const { t } = useI18n(); @@ -54,6 +56,7 @@ export function ArtistImageControls({ artist }: { artist: ArtistDetail }) { banner: useRef(null), }; const [busy, setBusy] = useState(false); + const [searching, setSearching] = useState(false); const [notice, setNotice] = useState<{ error: boolean; text: string } | null>( null, ); @@ -119,6 +122,20 @@ export function ArtistImageControls({ artist }: { artist: ArtistDetail }) { {t(LABELS[kind].change)} + {kind === "portrait" ? ( + + ) : null} {artist.image_choice?.[kind] ? ( + +
+ setQuery(event.target.value)} + /> + +
+ {busy && candidates === null ? ( +

+ {t("artistImage.deezerSearching")} +

+ ) : null} + {candidates?.length === 0 ? ( +

+ {t("artistImage.deezerNone")} +

+ ) : null} + {candidates?.length ? ( +
    + {candidates.map((candidate) => ( +
  • + +
  • + ))} +
+ ) : null} + {problem ? ( +

+ {t(problem)} +

+ ) : null} + + ); +} diff --git a/webapp/src/state/i18n.tsx b/webapp/src/state/i18n.tsx index 5d9efa97..e834244d 100644 --- a/webapp/src/state/i18n.tsx +++ b/webapp/src/state/i18n.tsx @@ -455,6 +455,19 @@ const en = { "artistImage.notAnImage": "Choose a JPEG, PNG or WebP image.", "artistImage.failed": "That did not work. Try again.", "artistImage.chosen": "Portrait chosen by hand", + "artistImage.searchDeezer": "Search Deezer", + "artistImage.deezerTitle": "Choose a portrait from Deezer", + "artistImage.deezerQuery": "Artist name", + "artistImage.deezerSearch": "Search", + "artistImage.deezerSearching": "Searching Deezer…", + "artistImage.deezerResults": "Deezer artists", + "artistImage.deezerNone": "Deezer knows no artist by that name.", + "artistImage.deezerFans": { one: "{count} fan", other: "{count} fans" }, + "artistImage.deezerUnavailable": + "Searching Deezer is not enabled on this server.", + "artistImage.deezerBusy": + "Your previous search is still running. Try again in a moment.", + "artistImage.deezerExpired": "These results have expired. Search again.", "folders.count": { one: "{count} folder", other: "{count} folders" }, "folders.where": "Folder path", "folders.subfolders": "Folders", @@ -1156,6 +1169,20 @@ const fr: Record = { "artistImage.notAnImage": "Choisissez une image JPEG, PNG ou WebP.", "artistImage.failed": "Cela n’a pas fonctionné. Réessayez.", "artistImage.chosen": "Portrait choisi à la main", + "artistImage.searchDeezer": "Rechercher sur Deezer", + "artistImage.deezerTitle": "Choisir un portrait sur Deezer", + "artistImage.deezerQuery": "Nom de l’artiste", + "artistImage.deezerSearch": "Rechercher", + "artistImage.deezerSearching": "Recherche sur Deezer…", + "artistImage.deezerResults": "Artistes Deezer", + "artistImage.deezerNone": "Deezer ne connaît aucun artiste de ce nom.", + "artistImage.deezerFans": { one: "{count} fan", other: "{count} fans" }, + "artistImage.deezerUnavailable": + "La recherche Deezer n’est pas activée sur ce serveur.", + "artistImage.deezerBusy": + "Votre recherche précédente est encore en cours. Réessayez dans un instant.", + "artistImage.deezerExpired": + "Ces résultats ont expiré. Relancez la recherche.", "folders.count": { one: "{count} dossier", other: "{count} dossiers" }, "folders.where": "Chemin du dossier", "folders.subfolders": "Dossiers", diff --git a/webapp/src/styles/styles.css b/webapp/src/styles/styles.css index f764078a..e8eaa7e7 100644 --- a/webapp/src/styles/styles.css +++ b/webapp/src/styles/styles.css @@ -3182,6 +3182,60 @@ button.danger { font-size: var(--text-sm); } +/* The Deezer picker borrows the folder picker's dialog, wider for a grid of + faces. */ +.deezer-picker[open] { + width: min(52rem, calc(100vw - 2rem)); + max-height: min(48rem, calc(100dvh - 2rem)); +} + +.deezer-search { + display: flex; + gap: var(--space-2); +} + +.deezer-search-input { + flex: 1; + min-width: 0; +} + +.deezer-search-button { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.deezer-candidates { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); + gap: var(--space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.deezer-candidate { + display: grid; + justify-items: center; + gap: var(--space-1); + width: 100%; + padding: var(--space-2); + text-align: center; +} + +.deezer-candidate-preview { + width: 100%; + aspect-ratio: 1; + border-radius: 50%; + object-fit: cover; +} + +.deezer-candidate-name { + max-width: 100%; + overflow-wrap: anywhere; + font-weight: 600; +} + @media (max-width: 820px) { .artist-hero { flex-wrap: wrap;