diff --git a/CLAUDE.md b/CLAUDE.md index e13d483..2c5daf1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,9 +38,10 @@ Tests need no service container: `test_app()` builds a `Config` over a `TempDir` - **Library/binary split.** `src/main.rs` only loads config, dispatches CLI commands and serves. The router is built by `waveflow_server::app(&config, state)` in `src/lib.rs`, so tests spawn the same app in-process. Put logic behind `app()`, not in `main`. - **`AppState`** (`src/lib.rs`) holds the shared singletons: `db`, `auth`, `secret_box`, `scanner`, `media`, `services`, plus config values copied in (`artwork_dir`, `public_url`, `stream_ticket_ttl`). Add new singletons there. +- **Three directories carry the surfaces.** `src/api/` is `/api/v2`, one module per resource plus `error`, `access` and `web_session`; `src/subsonic/` is the façade, one module per method family; `src/services/` is the domain, several `impl DomainServices` blocks split by domain. Each `mod.rs` keeps only what its children share — the router or dispatch, the cross-cutting types, and for `services` the SQL projection macros, which sit ahead of the `mod` declarations so their textual scope reaches every child. File a new handler under the resource it serves, not in `mod.rs`. - **All environment access lives in `src/config.rs`.** Every tunable is a field on `Config` with its env var documented on it. -- **No SQL in handlers.** SQL belongs in `src/database.rs`, `src/catalog.rs` or `src/services.rs`; handlers orchestrate HTTP only. -- **One set of domain services.** `DomainServices` (`src/services.rs`) is the single implementation behind the native API, the Subsonic façade and the web client. A mutation reachable from two surfaces must call the same method — that convergence is the point of M4, and duplicating logic per surface is how the two drift. +- **No SQL in handlers.** SQL belongs in `src/database.rs`, `src/catalog.rs` or `src/services/`; handlers orchestrate HTTP only. +- **One set of domain services.** `DomainServices` (`src/services/`) is the single implementation behind the native API, the Subsonic façade and the web client. A mutation reachable from two surfaces must call the same method — that convergence is the point of M4, and duplicating logic per surface is how the two drift. - **Tenancy is enforced in the queries**, through `library_member`, not in handlers. The shared projections are `song_select!` / `album_select!` / `artist_select!` macros that `concat!` into literals: sqlx only accepts static SQL, so composing them stays injection-proof by construction. The first bind is always the user id. - **404 blurs everything.** A resource that is missing, and one that belongs to another account, answer identically. `ServiceError::Forbidden` maps onto 404 for that reason. Never confirm existence to someone not entitled to it. - **SQLite discipline.** WAL, foreign keys, `busy_timeout`. Every mutation takes the process-wide writer gate (`db.writer_guard()`); libraries may scan concurrently but never open independent writers. `NULL` sorts first in SQLite — order explicitly with `NULLS LAST` wherever a missing tag would otherwise jump the queue. diff --git a/src/api/access.rs b/src/api/access.rs new file mode 100644 index 0000000..306f6f5 --- /dev/null +++ b/src/api/access.rs @@ -0,0 +1,143 @@ +//! Who is calling, and what their token is allowed to do. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +/// What a route needs of the credential it was called with. +/// +/// Chosen at every call of [`authenticated`], which is the only way into a +/// route, so a new route cannot be written without deciding: the compiler asks +/// the question. That is the whole reason this is a parameter rather than a +/// second helper a handler may forget to call — which is exactly what happened +/// to the scope list, stored since the foundations and read by nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Access { + /// Reads the caller's own catalogue and user data. + Read, + /// Writes on the caller's behalf: playlists, favorites, ratings, the + /// queue, bookmarks, shares, scrobbles and scans. + Write, + /// Acts on the instance: accounts, libraries, memberships, credentials. + Admin, +} + +/// The scope that admits the administrative routes. +pub(super) const ADMIN_SCOPE: &str = "admin"; + +/// The scope that admits any mutation. +pub(super) const WRITE_SCOPE: &str = "write"; + +impl Access { + /// Whether a credential carrying `scopes` may do this. + /// + /// An empty list is unrestricted: a session, an OAuth grant and a token + /// issued without scopes all carry the account's full authority, so nothing + /// that works today stops working. + /// + /// A non-empty list grants only what it names, and a name this server does + /// not know grants nothing — so `catalog:read` reads and does no more, + /// without needing a vocabulary of every possible scope. `admin` implies + /// `write`: a credential trusted to create accounts is not usefully barred + /// from creating a playlist, and the surprise would be the other way round. + fn granted_by(self, scopes: &[String]) -> bool { + if scopes.is_empty() { + return true; + } + let holds = |wanted: &str| scopes.iter().any(|scope| scope == wanted); + match self { + Self::Read => true, + Self::Write => holds(WRITE_SCOPE) || holds(ADMIN_SCOPE), + Self::Admin => holds(ADMIN_SCOPE), + } + } +} + +/// Resolves the caller and checks, in one place, that the credential may do +/// what the route is about to do. +/// +/// Both halves of administrative authority live here: an active administrator, +/// on a credential that has not been narrowed away from it. A token cannot +/// promote an ordinary account, and an administrator's token is not widened by +/// whose account it belongs to. +/// +/// It could widen itself, once: minting a session through the authorization +/// code flow returned one carrying the account's whole authority, whatever the +/// credential that asked. That is closed where it belongs now — the grant +/// records the caller's scopes and the session inherits them — rather than by +/// a rule this function has to know about. +pub(crate) async fn authenticated( + state: &AppState, + headers: &HeaderMap, + access: Access, +) -> Result { + let token = bearer_token(headers).ok_or(ApiError::Unauthorized)?; + let user = state + .auth + .authenticate(token) + .await + .map_err(ApiError::from)?; + let role_ok = access != Access::Admin || user.role == crate::database::AccountRole::Admin; + if role_ok && access.granted_by(&user.scopes) { + Ok(user) + } else { + Err(ApiError::Forbidden) + } +} + +/// The bearer token, whatever case the client spelled the scheme in. +/// +/// RFC 7235 §2.1 makes the scheme name case-insensitive, so `bearer` is as +/// valid as `Bearer`. Matching the spelling exactly turned a conforming client +/// away as unauthenticated. +pub(super) fn bearer_token(headers: &HeaderMap) -> Option<&str> { + let (scheme, token) = headers + .get(header::AUTHORIZATION)? + .to_str() + .ok()? + .split_once(' ')?; + scheme + .eq_ignore_ascii_case("Bearer") + .then_some(token) + .filter(|token| !token.is_empty()) +} + +pub(super) async fn mutation_context( + state: &AppState, + headers: &HeaderMap, + user_id: Uuid, +) -> Result { + let operation_id = + optional_uuid_header(headers, OPERATION_ID_HEADER)?.unwrap_or_else(Uuid::new_v4); + let origin_device_id = optional_uuid_header(headers, DEVICE_ID_HEADER)?; + if let Some(device_id) = origin_device_id { + let owned = state + .sync + .device_belongs_to_user(user_id, device_id) + .await + .map_err(db_error)?; + if !owned { + return Err(ApiError::Validation); + } + } + Ok(crate::sync::MutationContext { + operation_id, + origin_device_id, + }) +} + +pub(super) fn optional_uuid_header( + headers: &HeaderMap, + name: &'static str, +) -> Result, ApiError> { + headers + .get(name) + .map(|value| { + value + .to_str() + .ok() + .and_then(|value| Uuid::parse_str(value).ok()) + .ok_or(ApiError::Validation) + }) + .transpose() +} diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..eeea764 --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,178 @@ +//! Login, refresh and logout, for native clients and for the browser. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct LoginRequest { + pub username: String, + pub password: String, + pub device_name: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct RefreshRequest { + pub refresh_token: String, +} + +#[utoipa::path( + post, + path = "/api/v2/auth/login", + tag = "authentication", + request_body = LoginRequest, + responses( + (status = 200, body = crate::authentication::AuthTokens), + (status = 401, body = ErrorResponse), + (status = 422, body = ErrorResponse), + (status = 503, body = ErrorResponse) + ) +)] +pub async fn login( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + state + .auth + .login(&request.username, &request.password, &request.device_name) + .await + .map(Json) + .map_err(ApiError::from) +} + +#[utoipa::path( + post, + path = "/api/v2/auth/refresh", + tag = "authentication", + request_body = RefreshRequest, + responses( + (status = 200, body = crate::authentication::AuthTokens), + (status = 401, body = ErrorResponse), + (status = 503, body = ErrorResponse) + ) +)] +pub async fn refresh( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + state + .auth + .refresh(&request.refresh_token) + .await + .map(Json) + .map_err(ApiError::from) +} + +#[utoipa::path( + post, + path = "/api/v2/auth/logout", + tag = "authentication", + responses( + (status = 204), + (status = 401, body = ErrorResponse), + (status = 503, body = ErrorResponse) + ) +)] +pub async fn logout( + State(state): State, + headers: HeaderMap, +) -> Result { + let access_token = bearer_token(&headers).ok_or(ApiError::Unauthorized)?; + state + .auth + .logout(access_token) + .await + .map_err(ApiError::from)?; + Ok(StatusCode::NO_CONTENT) +} + +/// Browser sessions keep only the short-lived access token in JavaScript. The +/// rotating refresh token is an HttpOnly, same-site cookie and is therefore +/// never exposed to the embedded SPA. +#[utoipa::path( + post, + path = "/api/v2/web/auth/login", + tag = "authentication", + request_body = LoginRequest, + responses( + (status = 200, body = WebAuthResponse), + (status = 401, body = ErrorResponse), + (status = 403, body = ErrorResponse), + (status = 422, body = ErrorResponse), + (status = 503, body = ErrorResponse) + ) +)] +pub async fn web_login( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + validate_web_origin(&state, &headers)?; + let tokens = state + .auth + .login(&request.username, &request.password, &request.device_name) + .await + .map_err(ApiError::from)?; + web_auth_response(&state, &headers, tokens) +} + +#[utoipa::path( + post, + path = "/api/v2/web/auth/refresh", + tag = "authentication", + responses( + (status = 200, body = WebAuthResponse), + (status = 401, body = ErrorResponse), + (status = 403, body = ErrorResponse), + (status = 503, body = ErrorResponse) + ) +)] +pub async fn web_refresh( + State(state): State, + headers: HeaderMap, +) -> Result { + validate_web_request(&state, &headers)?; + let refresh_token = cookie_value(&headers, WEB_REFRESH_COOKIE).ok_or(ApiError::Unauthorized)?; + let tokens = state + .auth + .refresh(refresh_token) + .await + .map_err(ApiError::from)?; + web_auth_response(&state, &headers, tokens) +} + +#[utoipa::path( + post, + path = "/api/v2/web/auth/logout", + tag = "authentication", + responses( + (status = 204), + (status = 401, body = ErrorResponse), + (status = 403, body = ErrorResponse), + (status = 503, body = ErrorResponse) + ) +)] +pub async fn web_logout( + State(state): State, + headers: HeaderMap, +) -> Result { + validate_web_request(&state, &headers)?; + let result = match cookie_value(&headers, WEB_REFRESH_COOKIE) { + Some(refresh_token) => state.auth.revoke_refresh(refresh_token).await, + None => Err(AuthError::InvalidRefreshToken), + }; + let mut response = match result { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(error) => ApiError::from(error).into_response(), + }; + let secure = secure_cookies(&state); + append_cookie( + &mut response, + expired_cookie(WEB_REFRESH_COOKIE, true, secure), + )?; + append_cookie( + &mut response, + expired_cookie(WEB_CSRF_COOKIE, false, secure), + )?; + Ok(response) +} diff --git a/src/api/bookmarks.rs b/src/api/bookmarks.rs new file mode 100644 index 0000000..f4a02cc --- /dev/null +++ b/src/api/bookmarks.rs @@ -0,0 +1,78 @@ +//! Playback bookmarks. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +/// A playback position to store on a track. +#[derive(Debug, Deserialize, ToSchema)] +pub struct BookmarkRequest { + /// Milliseconds from the start of the file. Negative positions are refused. + pub position_ms: i64, + /// Free text. Omitting it clears whatever comment the bookmark carried, + /// because a bookmark is replaced rather than patched. + #[serde(default)] + pub comment: Option, +} + +#[utoipa::path(get, path = "/api/v2/bookmarks", tag = "user-data", responses((status = 200, body = [crate::services::BookmarkItem]), (status = 401, body = ErrorResponse)))] +pub async fn list_bookmarks( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .bookmarks(user.id) + .await + .map(Json) + .map_err(service_error) +} + +/// One bookmark per account and track, so this replaces rather than adds. +/// +/// `PUT` and not `POST` for that reason: the track names the resource, and +/// sending the same position twice leaves the same single bookmark. Backed by +/// the same `DomainServices` method as the Subsonic `createBookmark`, so the +/// two surfaces cannot disagree about what a second call does. +#[utoipa::path(put, path = "/api/v2/bookmarks/{track_id}", tag = "user-data", params(("track_id" = Uuid, Path)), request_body = BookmarkRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn set_bookmark( + State(state): State, + Path(track_id): Path, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let user = authenticated(&state, &headers, Access::Write).await?; + let context = mutation_context(&state, &headers, user.id).await?; + state + .services + .set_bookmark_with_context( + user.id, + track_id, + request.position_ms, + request.comment.as_deref(), + context, + ) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} + +/// Deleting a bookmark that is not there succeeds: the caller asked for the +/// track to carry none, and it does not. It also avoids answering a question +/// about a track the account cannot reach. +#[utoipa::path(delete, path = "/api/v2/bookmarks/{track_id}", tag = "user-data", params(("track_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn delete_bookmark( + State(state): State, + Path(track_id): Path, + headers: HeaderMap, +) -> Result { + let user = authenticated(&state, &headers, Access::Write).await?; + let context = mutation_context(&state, &headers, user.id).await?; + state + .services + .delete_bookmark_with_context(user.id, track_id, context) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/api/catalog.rs b/src/api/catalog.rs new file mode 100644 index 0000000..0ef9136 --- /dev/null +++ b/src/api/catalog.rs @@ -0,0 +1,229 @@ +//! Albums, artists, genres and search. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Deserialize)] +pub struct BrowseQuery { + pub library_id: Option, + pub offset: Option, + pub limit: Option, +} + +/// Album discovery parameters. `sort` accepts the same vocabulary as the +/// Subsonic `type` parameter — both surfaces resolve to [`AlbumOrder`], so the +/// web client can build a home screen ("recently added", "most played") in one +/// call instead of paging the whole catalogue and sorting locally. +#[derive(Debug, Deserialize)] +pub struct AlbumBrowseQuery { + pub library_id: Option, + pub offset: Option, + pub limit: Option, + pub sort: Option, + /// Required by `sort=byGenre`, ignored otherwise. + pub genre: Option, + pub from_year: Option, + pub to_year: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GenreQuery { + pub library_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SearchQuery { + pub q: String, + /// Applied to every kind unless the per-kind offset below overrides it. + pub offset: Option, + pub limit: Option, + pub artist_offset: Option, + pub album_offset: Option, + pub song_offset: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RandomSongQuery { + pub library_id: Option, + pub genre: Option, + pub from_year: Option, + pub to_year: Option, + pub limit: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GenreSongQuery { + pub genre: String, + pub library_id: Option, + pub offset: Option, + pub limit: Option, +} + +#[utoipa::path(get, path = "/api/v2/albums", tag = "catalog", params(("library_id" = Option, Query), ("offset" = Option, Query), ("limit" = Option, Query), ("sort" = Option, Query), ("genre" = Option, Query), ("from_year" = Option, Query), ("to_year" = Option, Query)), responses((status = 200, body = [crate::services::AlbumItem]), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn list_albums( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + let order = query + .sort + .as_deref() + .map(crate::services::AlbumOrder::from_str) + .transpose() + .map_err(service_error)? + .unwrap_or_default(); + let request = crate::services::AlbumListQuery { + library_ids: query.library_id.into_iter().collect(), + order, + genre: query.genre, + from_year: query.from_year, + to_year: query.to_year, + page: crate::services::BrowsePage::new(query.offset, query.limit).map_err(service_error)?, + }; + state + .services + .list_albums(user.id, &request) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(get, path = "/api/v2/genres", tag = "catalog", params(("library_id" = Option, Query)), responses((status = 200, body = [crate::services::GenreItem]), (status = 401, body = ErrorResponse)))] +pub async fn list_genres( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + let libraries = query.library_id.into_iter().collect::>(); + state + .services + .list_genres(user.id, &libraries) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(get, path = "/api/v2/albums/{album_id}", tag = "catalog", params(("album_id" = Uuid, Path)), responses((status = 200, body = crate::services::AlbumDetail), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn get_album( + State(state): State, + Path(album_id): Path, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .album(user.id, album_id) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(get, path = "/api/v2/artists", tag = "catalog", params(("library_id" = Option, Query), ("offset" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = [crate::services::ArtistSummary]), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn list_artists( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + let page = + crate::services::BrowsePage::new(query.offset, query.limit).map_err(service_error)?; + state + .services + .list_artists(user.id, query.library_id, page) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(get, path = "/api/v2/artists/{artist_id}", tag = "catalog", params(("artist_id" = Uuid, Path)), responses((status = 200, body = crate::services::ArtistDetail), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn get_artist( + State(state): State, + Path(artist_id): Path, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .artist(user.id, artist_id) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(get, path = "/api/v2/search", tag = "catalog", params(("q" = String, Query), ("offset" = Option, Query), ("limit" = Option, Query), ("artist_offset" = Option, Query), ("album_offset" = Option, Query), ("song_offset" = Option, Query)), responses((status = 200, body = crate::services::SearchResult), (status = 400, description = "q is required"), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn search_catalog( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + // One offset for all three kinds unless the caller names one, which is + // what `search3` has always allowed and what a client paging songs past + // the end of the artists needs. + let page = |offset: Option| { + crate::services::BrowsePage::new(offset.or(query.offset), query.limit) + .map_err(service_error) + }; + state + .services + .search( + user.id, + &query.q, + page(query.artist_offset)?, + page(query.album_offset)?, + page(query.song_offset)?, + ) + .await + .map(Json) + .map_err(service_error) +} + +/// The native form of `getRandomSongs`. +/// +/// The selection is drawn in SQL, so a request for ten reads ten. `genre` +/// matches the canonical name, like every other genre filter on either +/// surface, and a reversed year range is read as a range rather than as an +/// empty one. +#[utoipa::path(get, path = "/api/v2/songs/random", tag = "catalog", params(("library_id" = Option, Query), ("genre" = Option, Query), ("from_year" = Option, Query), ("to_year" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = [crate::services::SongItem]), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn list_random_songs( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .random_songs( + user.id, + query.library_id.as_slice(), + query.genre.as_deref(), + query.from_year, + query.to_year, + query.limit.unwrap_or(10), + ) + .await + .map(Json) + .map_err(service_error) +} + +/// The native form of `getSongsByGenre`. `genre` is required: answering an +/// unfiltered catalogue would drop the filter in silence. +#[utoipa::path(get, path = "/api/v2/songs", tag = "catalog", params(("genre" = String, Query), ("library_id" = Option, Query), ("offset" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = [crate::services::SongItem]), (status = 400, description = "genre is required"), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn list_songs_by_genre( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + let page = + crate::services::BrowsePage::new(query.offset, query.limit).map_err(service_error)?; + state + .services + .songs_by_genre(user.id, query.library_id.as_slice(), &query.genre, page) + .await + .map(Json) + .map_err(service_error) +} diff --git a/src/api/error.rs b/src/api/error.rs new file mode 100644 index 0000000..8330cde --- /dev/null +++ b/src/api/error.rs @@ -0,0 +1,108 @@ +//! The API error type and the mappings onto it. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Serialize, ToSchema)] +pub struct ErrorResponse { + pub code: &'static str, + pub message: &'static str, +} + +#[derive(Debug)] +pub enum ApiError { + Unauthorized, + Forbidden, + Validation, + /// The request is well formed but collides with existing state: an + /// operation id replayed with a different payload, or a name already taken. + /// Distinct from `Validation` so a client can tell "my request is malformed" + /// from "my retry is inconsistent" — both permanent, different fixes. + Conflict, + /// The sync cursor precedes the oldest retained event. Same 409 status as + /// `Conflict` but a distinct code, because the reactions are opposite: + /// a conflict means mint a new operation id, this one means discard the + /// local projection and take a fresh snapshot. + CursorExpired, + Unavailable, + NotFound, +} + +impl From for ApiError { + fn from(value: AuthError) -> Self { + match value { + AuthError::InvalidCredentials | AuthError::InvalidRefreshToken => Self::Unauthorized, + AuthError::InvalidDeviceName => Self::Validation, + AuthError::Unavailable => Self::Unavailable, + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, code, message) = match self { + Self::Unauthorized => ( + StatusCode::UNAUTHORIZED, + "unauthorized", + "Authentication failed", + ), + Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden", "Request rejected"), + Self::Validation => ( + StatusCode::UNPROCESSABLE_ENTITY, + "validation_error", + "The request is invalid", + ), + Self::Conflict => ( + StatusCode::CONFLICT, + "conflict", + "The request conflicts with existing state", + ), + Self::CursorExpired => ( + StatusCode::CONFLICT, + "cursor_expired", + "The cursor precedes the oldest retained event; take a fresh snapshot", + ), + Self::Unavailable => ( + StatusCode::SERVICE_UNAVAILABLE, + "service_unavailable", + "Authentication is temporarily unavailable", + ), + Self::NotFound => (StatusCode::NOT_FOUND, "not_found", "Resource not found"), + }; + (status, Json(ErrorResponse { code, message })).into_response() + } +} + +pub(super) fn db_error(error: sqlx::Error) -> ApiError { + tracing::error!(error = %error, "catalog database operation failed"); + ApiError::Unavailable +} + +pub(super) fn sync_error(error: crate::sync::SyncError) -> ApiError { + match error { + crate::sync::SyncError::Invalid => ApiError::Validation, + crate::sync::SyncError::Conflict => ApiError::Conflict, + crate::sync::SyncError::CursorExpired => ApiError::CursorExpired, + crate::sync::SyncError::Database(error) => db_error(error), + } +} + +/// Maps a domain failure onto the HTTP surface. `Forbidden` deliberately answers +/// 404 like `NotFound`: telling a caller that a resource exists but belongs to +/// someone else would leak another tenant's catalogue, which is the same +/// no-existence-leak rule the Subsonic facade applies. +pub(super) fn service_error(error: crate::services::ServiceError) -> ApiError { + use crate::services::ServiceError; + match error { + ServiceError::NotFound | ServiceError::Forbidden => ApiError::NotFound, + ServiceError::Invalid => ApiError::Validation, + ServiceError::Conflict => ApiError::Conflict, + ServiceError::Unavailable => ApiError::Unavailable, + ServiceError::Database(error) => db_error(error), + ServiceError::Security(error) => { + tracing::error!(error = %error, "catalog security operation failed"); + ApiError::Unavailable + } + } +} diff --git a/src/api/favorites.rs b/src/api/favorites.rs new file mode 100644 index 0000000..3502382 --- /dev/null +++ b/src/api/favorites.rs @@ -0,0 +1,105 @@ +//! Favourites and ratings. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct RatingRequest { + /// 1 to 5 stars; 0 clears the rating. + pub rating: i64, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct StarredEntry { + pub entity_type: String, + pub entity_id: Uuid, + pub starred_at: i64, +} + +#[utoipa::path(get, path = "/api/v2/favorites", tag = "user-data", responses((status = 200, body = [StarredEntry]), (status = 401, body = ErrorResponse)))] +pub async fn list_favorites( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + let entries = state + .services + .starred_ids(user.id) + .await + .map_err(service_error)? + .into_iter() + .map(|(entity_type, entity_id, starred_at)| StarredEntry { + entity_type, + entity_id, + starred_at, + }) + .collect(); + Ok(Json(entries)) +} + +#[utoipa::path(put, path = "/api/v2/favorites/{entity_type}/{entity_id}", tag = "user-data", params(("entity_type" = String, Path, description = "track, album or artist"), ("entity_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn add_favorite( + State(state): State, + Path((entity_type, entity_id)): Path<(String, Uuid)>, + headers: HeaderMap, +) -> Result { + set_favorite(state, headers, &entity_type, entity_id, true).await +} + +#[utoipa::path(delete, path = "/api/v2/favorites/{entity_type}/{entity_id}", tag = "user-data", params(("entity_type" = String, Path, description = "track, album or artist"), ("entity_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn remove_favorite( + State(state): State, + Path((entity_type, entity_id)): Path<(String, Uuid)>, + headers: HeaderMap, +) -> Result { + set_favorite(state, headers, &entity_type, entity_id, false).await +} + +pub(super) async fn set_favorite( + state: AppState, + headers: HeaderMap, + entity_type: &str, + entity_id: Uuid, + starred: bool, +) -> Result { + let user = authenticated(&state, &headers, Access::Write).await?; + let context = mutation_context(&state, &headers, user.id).await?; + state + .services + .set_star_with_context(user.id, entity_type, entity_id, starred, context) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(put, path = "/api/v2/ratings/{entity_type}/{entity_id}", tag = "user-data", params(("entity_type" = String, Path, description = "track, album or artist"), ("entity_id" = Uuid, Path)), request_body = RatingRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn set_rating( + State(state): State, + Path((entity_type, entity_id)): Path<(String, Uuid)>, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let user = authenticated(&state, &headers, Access::Write).await?; + let context = mutation_context(&state, &headers, user.id).await?; + state + .services + .set_rating_with_context(user.id, &entity_type, entity_id, request.rating, context) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(get, path = "/api/v2/ratings", tag = "user-data", responses((status = 200, body = [crate::services::RatingItem]), (status = 401, body = ErrorResponse)))] +pub async fn list_ratings( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .ratings(user.id) + .await + .map(Json) + .map_err(service_error) +} diff --git a/src/api/libraries.rs b/src/api/libraries.rs new file mode 100644 index 0000000..ab8299d --- /dev/null +++ b/src/api/libraries.rs @@ -0,0 +1,218 @@ +//! Libraries, their members and their scans. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Serialize, ToSchema)] +pub struct ScanQueuedResponse { + pub scan_id: Uuid, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateLibraryRequest { + pub name: String, + pub path: String, + pub visibility: crate::database::LibraryVisibility, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct CreateLibraryResponse { + pub library_id: Uuid, + pub scan_id: Uuid, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SetLibraryMemberRequest { + pub role: crate::database::LibraryRole, +} + +#[utoipa::path(post, path = "/api/v2/libraries/{library_id}/scans", tag = "catalog", params(("library_id" = Uuid, Path)), responses((status = 202, body = ScanQueuedResponse), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn start_scan( + State(state): State, + Path(library_id): Path, + headers: HeaderMap, +) -> Result<(StatusCode, Json), ApiError> { + let user = authenticated(&state, &headers, Access::Write).await?; + let scan_id = state + .services + .start_library_scan(user.id, library_id) + .await + .map_err(service_error)?; + Ok((StatusCode::ACCEPTED, Json(ScanQueuedResponse { scan_id }))) +} + +#[utoipa::path(get, path = "/api/v2/libraries", tag = "catalog", responses((status = 200, body = [crate::catalog::LibraryAccess]), (status = 401, body = ErrorResponse)))] +pub async fn list_libraries( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .db + .libraries_for_user(user.id) + .await + .map(Json) + .map_err(db_error) +} + +#[utoipa::path(post, path = "/api/v2/libraries", tag = "administration", request_body = CreateLibraryRequest, responses((status = 201, body = CreateLibraryResponse), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn create_library( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let actor = authenticated(&state, &headers, Access::Admin).await?; + let path = std::path::PathBuf::from(&request.path); + let metadata = tokio::fs::symlink_metadata(&path) + .await + .map_err(|_| ApiError::Validation)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() || request.name.trim().is_empty() { + return Err(ApiError::Validation); + } + let canonical = tokio::fs::canonicalize(&path) + .await + .map_err(|_| ApiError::Validation)?; + let library_id = state + .db + .create_library( + actor.id, + &request.name, + &canonical, + request.visibility, + crate::authentication::now_ms(), + ) + .await + .map_err(db_error)?; + let scan_id = state + .scanner + .trigger( + crate::catalog::LibraryRecord { + id: library_id, + name: request.name, + root_path: canonical, + }, + Some(actor.id), + "library_added", + ) + .await + .map_err(|error| { + tracing::error!(error = %error, library_id = %library_id, "initial scan queue failed"); + ApiError::Unavailable + })?; + Ok(( + StatusCode::CREATED, + Json(CreateLibraryResponse { + library_id, + scan_id, + }), + )) +} + +#[utoipa::path(put, path = "/api/v2/libraries/{library_id}/members/{user_id}", tag = "administration", params(("library_id" = Uuid, Path), ("user_id" = Uuid, Path)), request_body = SetLibraryMemberRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn set_library_member( + State(state): State, + Path((library_id, user_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let actor = authenticated(&state, &headers, Access::Admin).await?; + if request.role == crate::database::LibraryRole::Owner + || state + .db + .account_by_id(user_id) + .await + .map_err(db_error)? + .is_none() + || !state + .db + .all_libraries() + .await + .map_err(db_error)? + .iter() + .any(|library| library.id == library_id) + { + return Err(ApiError::Validation); + } + state + .db + .add_library_member( + actor.id, + library_id, + user_id, + request.role, + crate::authentication::now_ms(), + ) + .await + .map_err(db_error)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(delete, path = "/api/v2/libraries/{library_id}/members/{user_id}", tag = "administration", params(("library_id" = Uuid, Path), ("user_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn remove_library_member( + State(state): State, + Path((library_id, user_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, +) -> Result { + let actor = authenticated(&state, &headers, Access::Admin).await?; + if state + .db + .remove_library_member( + actor.id, + library_id, + user_id, + crate::authentication::now_ms(), + ) + .await + .map_err(db_error)? + { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::NotFound) + } +} + +#[utoipa::path(get, path = "/api/v2/scans/{scan_id}", tag = "catalog", params(("scan_id" = Uuid, Path)), responses((status = 200, body = crate::catalog::ScanJobRecord), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn scan_status( + State(state): State, + Path(scan_id): Path, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .db + .scan_job_for_user(user.id, scan_id) + .await + .map_err(db_error)? + .map(Json) + .ok_or(ApiError::NotFound) +} + +#[utoipa::path(get, path = "/api/v2/scans/{scan_id}/events", tag = "catalog", params(("scan_id" = Uuid, Path)), responses((status = 200, description = "Server-sent scan progress events", content_type = "text/event-stream"), (status = 404, body = ErrorResponse)))] +pub async fn scan_events( + State(state): State, + Path(scan_id): Path, + headers: HeaderMap, +) -> Result>>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + let initial = state + .db + .scan_job_for_user(user.id, scan_id) + .await + .map_err(db_error)? + .ok_or(ApiError::NotFound)?; + let mut receiver = state.scanner.subscribe(scan_id); + let output = async_stream::stream! { + yield Ok(Event::default().event("snapshot").json_data(initial).expect("scan snapshot serializes")); + if let Some(ref mut receiver) = receiver { + loop { + match receiver.recv().await { + Ok(progress) => yield Ok(Event::default().event("progress").json_data(progress).expect("scan progress serializes")), + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } + }; + Ok(Sse::new(output).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))) +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..f830388 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,163 @@ +//! M0 HTTP surface: probes, OpenAPI and local session lifecycle. + +use std::{convert::Infallible, str::FromStr, time::Duration}; + +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + Path, Query, State, + }, + http::{header, HeaderMap, HeaderValue, StatusCode}, + response::{ + sse::{Event, KeepAlive}, + IntoResponse, Response, Sse, + }, + routing::{get, post, put}, + Json, Router, +}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{authentication::AuthError, AppState}; + +mod access; +mod auth; +mod bookmarks; +mod catalog; +mod error; +mod favorites; +mod libraries; +mod oauth; +mod playback; +mod playlists; +mod probes; +mod setup; +mod shares; +mod sync; +mod tokens; +mod tracks; +mod users; +mod web_session; + +pub(crate) use access::*; +pub use auth::*; +pub use bookmarks::*; +pub use catalog::*; +pub use error::*; +pub use favorites::*; +pub use libraries::*; +pub use oauth::*; +pub use playback::*; +pub use playlists::*; +pub use probes::*; +pub use setup::*; +pub use shares::*; +pub use sync::*; +pub use tokens::*; +pub use tracks::*; +pub use users::*; +pub use web_session::*; + +const WEB_REFRESH_COOKIE: &str = "waveflow-refresh"; + +const WEB_CSRF_COOKIE: &str = "waveflow-csrf"; + +pub const WEB_CSRF_HEADER: &str = "x-waveflow-csrf"; + +pub const OPERATION_ID_HEADER: &str = "x-waveflow-operation-id"; + +pub const DEVICE_ID_HEADER: &str = "x-waveflow-device-id"; + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/ready", get(ready)) + .route("/api/v2/setup", get(setup_status).post(setup)) + .route("/api/v2/auth/login", post(login)) + .route("/api/v2/auth/refresh", post(refresh)) + .route("/api/v2/auth/logout", post(logout)) + .route("/api/v2/web/auth/login", post(web_login)) + .route("/api/v2/web/auth/refresh", post(web_refresh)) + .route("/api/v2/web/auth/logout", post(web_logout)) + .route("/api/v2/oauth/authorize", post(oauth_authorize)) + // No auth layer: the code plus its PKCE verifier are the credential. + .route("/api/v2/oauth/token", post(oauth_token)) + .route("/api/v2/libraries/{library_id}/scans", post(start_scan)) + .route( + "/api/v2/libraries", + get(list_libraries).post(create_library), + ) + .route( + "/api/v2/libraries/{library_id}/members/{user_id}", + put(set_library_member).delete(remove_library_member), + ) + .route("/api/v2/scans/{scan_id}", get(scan_status)) + .route("/api/v2/scans/{scan_id}/events", get(scan_events)) + .route("/api/v2/libraries/{library_id}/tracks", get(list_tracks)) + .route("/api/v2/tracks/{track_id}", get(get_track)) + .route("/api/v2/tracks/{track_id}/lyrics", get(get_track_lyrics)) + .route("/api/v2/albums", get(list_albums)) + .route("/api/v2/genres", get(list_genres)) + .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/search", get(search_catalog)) + .route("/api/v2/songs", get(list_songs_by_genre)) + .route("/api/v2/songs/random", get(list_random_songs)) + .route( + "/api/v2/playlists", + get(list_playlists).post(create_playlist), + ) + .route( + "/api/v2/playlists/{playlist_id}", + get(get_playlist) + .patch(update_playlist) + .delete(delete_playlist), + ) + .route("/api/v2/favorites", get(list_favorites)) + .route( + "/api/v2/favorites/{entity_type}/{entity_id}", + put(add_favorite).delete(remove_favorite), + ) + .route("/api/v2/ratings/{entity_type}/{entity_id}", put(set_rating)) + .route("/api/v2/ratings", get(list_ratings)) + .route("/api/v2/bookmarks", get(list_bookmarks)) + .route( + "/api/v2/bookmarks/{track_id}", + put(set_bookmark).delete(delete_bookmark), + ) + .route("/api/v2/scrobbles", post(create_scrobble)) + .route("/api/v2/history", get(list_history)) + .route("/api/v2/now-playing", get(list_now_playing)) + .route("/api/v2/queue", get(get_queue).put(save_queue)) + .route("/api/v2/shares", get(list_shares).post(create_share)) + .route( + "/api/v2/shares/{share_id}", + axum::routing::patch(update_share).delete(delete_share), + ) + .route("/api/v2/sync/changes", get(sync_changes)) + .route("/api/v2/sync/snapshot", get(sync_snapshot)) + .route("/api/v2/sync/ack", put(sync_ack)) + .route("/api/v2/sync/socket", get(sync_socket)) + .route("/api/v2/transcode/status", get(transcode_status)) + .route("/api/v2/admin/users", get(list_users).post(create_user)) + .route( + "/api/v2/admin/users/{username}", + axum::routing::patch(update_user).delete(delete_user), + ) + .route( + "/api/v2/admin/users/{username}/subsonic-credential", + put(set_subsonic_credential).delete(revoke_subsonic_credential), + ) + .route( + "/api/v2/admin/users/{username}/tokens", + get(list_api_tokens).post(create_api_token), + ) + .route( + "/api/v2/admin/users/{username}/tokens/{token_id}", + axum::routing::delete(revoke_api_token), + ) + .with_state(state) +} diff --git a/src/api/oauth.rs b/src/api/oauth.rs new file mode 100644 index 0000000..a01706d --- /dev/null +++ b/src/api/oauth.rs @@ -0,0 +1,101 @@ +//! Authorization Code + PKCE for native clients. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct AuthorizeRequest { + pub client_id: String, + pub redirect_uri: String, + pub code_challenge: String, + #[serde(default = "default_challenge_method")] + pub code_challenge_method: String, + pub state: Option, + /// Name recorded for the device this grant will create a session for. + pub device_name: String, +} + +pub(super) fn default_challenge_method() -> String { + "S256".into() +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct AuthorizeResponse { + /// Where the consent screen must send the user agent. + pub redirect_to: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct TokenRequest { + pub code: String, + pub code_verifier: String, + pub client_id: String, + pub redirect_uri: String, +} + +#[utoipa::path(post, path = "/api/v2/oauth/authorize", tag = "authentication", request_body = AuthorizeRequest, responses((status = 200, body = AuthorizeResponse), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn oauth_authorize( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + // The browser session is the proof of identity; the consent screen is a + // route of the embedded client, so this is a JSON call rather than a form. + // + // A write, because pairing a device is a mutation on the account and + // nothing more. What used to make this Unrestricted — that the session it + // minted carried the account's whole authority whatever asked for it — is + // gone: the caller's scopes are recorded on the grant just below and the + // redeemed session is issued under them, so this cannot widen a credential. + let user = authenticated(&state, &headers, Access::Write).await?; + let redirect_to = state + .services + .authorize_native_client( + user.id, + crate::services::AuthorizationRequest { + client_id: &request.client_id, + redirect_uri: &request.redirect_uri, + code_challenge: &request.code_challenge, + code_challenge_method: &request.code_challenge_method, + device_name: &request.device_name, + state: request.state.as_deref(), + scopes: &user.scopes, + }, + ) + .await + .map_err(service_error)?; + Ok(Json(AuthorizeResponse { redirect_to })) +} + +#[utoipa::path(post, path = "/api/v2/oauth/token", tag = "authentication", request_body = TokenRequest, responses((status = 200, body = crate::authentication::AuthTokens), (status = 401, body = ErrorResponse), (status = 503, body = ErrorResponse)))] +pub async fn oauth_token( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + // Mounted without authentication by design: the code plus the verifier are + // the credential. Every rejection below is the same 401 so a caller cannot + // learn whether a code existed, expired, or was already spent. + let now = crate::authentication::now_ms(); + let grant = state + .db + .redeem_authorization(&crate::security::token_hash(&request.code), now) + .await + .map_err(db_error)? + .ok_or(ApiError::Unauthorized)?; + if grant.client_id != request.client_id.trim() + || grant.redirect_uri != request.redirect_uri + || crate::oauth::verify_challenge(&grant.code_challenge, &request.code_verifier).is_err() + { + return Err(ApiError::Unauthorized); + } + state + .auth + .issue_session_for_account(grant.user_id, &grant.device_name, &grant.scopes) + .await + .map(Json) + .map_err(|error| match error { + crate::authentication::AuthError::Unavailable => ApiError::Unavailable, + _ => ApiError::Unauthorized, + }) +} diff --git a/src/api/playback.rs b/src/api/playback.rs new file mode 100644 index 0000000..60ee12f --- /dev/null +++ b/src/api/playback.rs @@ -0,0 +1,153 @@ +//! Scrobbles, history, now playing, the queue and transcode status. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ScrobbleRequest { + pub track_id: Uuid, + /// `false` records a "now playing" ping, `true` a completed listen. + #[serde(default)] + pub submission: bool, + pub played_at: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SaveQueueRequest { + #[serde(default)] + pub track_ids: Vec, + pub current: Option, + #[serde(default)] + pub position_ms: i64, + pub client: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct NowPlayingEntry { + pub username: String, + pub song: crate::services::SongItem, + pub started_at: i64, +} + +#[derive(Debug, Deserialize)] +pub struct HistoryQuery { + pub limit: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct TranscodeStatusResponse { + pub available: bool, + pub active: usize, +} + +#[utoipa::path(post, path = "/api/v2/scrobbles", tag = "user-data", request_body = ScrobbleRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn create_scrobble( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let user = authenticated(&state, &headers, Access::Write).await?; + let context = mutation_context(&state, &headers, user.id).await?; + state + .services + .scrobble_with_context( + user.id, + request.track_id, + request.submission, + request.played_at, + context, + ) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(get, path = "/api/v2/history", tag = "user-data", params(("limit" = Option, Query)), responses((status = 200, body = [crate::services::HistoryItem]), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn list_history( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + let limit = query.limit.unwrap_or(200); + if !(1..=crate::sync::MAX_SYNC_LIMIT).contains(&limit) { + return Err(ApiError::Validation); + } + state + .services + .history(user.id, limit) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(get, path = "/api/v2/now-playing", tag = "user-data", responses((status = 200, body = [NowPlayingEntry]), (status = 401, body = ErrorResponse)))] +pub async fn list_now_playing( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + let entries = state + .services + .now_playing(user.id) + .await + .map_err(service_error)? + .into_iter() + .map(|(username, song, started_at)| NowPlayingEntry { + username, + song, + started_at, + }) + .collect(); + Ok(Json(entries)) +} + +#[utoipa::path(get, path = "/api/v2/queue", tag = "user-data", responses((status = 200, body = Option), (status = 401, body = ErrorResponse)))] +pub async fn get_queue( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .queue(user.id) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(put, path = "/api/v2/queue", tag = "user-data", request_body = SaveQueueRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn save_queue( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let user = authenticated(&state, &headers, Access::Write).await?; + let context = mutation_context(&state, &headers, user.id).await?; + state + .services + .save_queue_with_context( + user.id, + &request.track_ids, + request.current, + request.position_ms, + request.client.as_deref(), + context, + ) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(get, path = "/api/v2/transcode/status", tag = "catalog", responses((status = 200, body = TranscodeStatusResponse), (status = 401, body = ErrorResponse)))] +pub async fn transcode_status( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + authenticated(&state, &headers, Access::Read).await?; + Ok(Json(TranscodeStatusResponse { + available: state.media.transcoding_available(), + active: state.media.active_transcodes(), + })) +} diff --git a/src/api/playlists.rs b/src/api/playlists.rs new file mode 100644 index 0000000..bbe4eb7 --- /dev/null +++ b/src/api/playlists.rs @@ -0,0 +1,134 @@ +//! Playlists. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreatePlaylistRequest { + pub name: String, + #[serde(default)] + pub track_ids: Vec, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdatePlaylistRequest { + pub name: Option, + pub comment: Option, + pub public: Option, + /// Track ids appended to the end, applied after `remove_indexes`. + #[serde(default)] + pub add: Vec, + /// Zero-based positions removed before `add` is applied. + #[serde(default)] + pub remove_indexes: Vec, + /// Optional fields to blank out, by name. Currently `comment`. + /// + /// Omitting a field leaves it untouched, so clearing needs its own verb: + /// naming it here is the only way to distinguish "unchanged" from "empty", + /// and it cannot fire by accident on a client that simply omits the field. + #[serde(default)] + pub clear: Vec, +} + +#[utoipa::path(get, path = "/api/v2/playlists", tag = "user-data", responses((status = 200, body = [crate::services::PlaylistItem]), (status = 401, body = ErrorResponse)))] +pub async fn list_playlists( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .playlists(user.id) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(post, path = "/api/v2/playlists", tag = "user-data", request_body = CreatePlaylistRequest, responses((status = 201, body = crate::services::PlaylistItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn create_playlist( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let user = authenticated(&state, &headers, Access::Write).await?; + let context = mutation_context(&state, &headers, user.id).await?; + let playlist = state + .services + .create_playlist_with_context(user.id, &request.name, &request.track_ids, context) + .await + .map_err(service_error)?; + Ok((StatusCode::CREATED, Json(playlist))) +} + +#[utoipa::path(get, path = "/api/v2/playlists/{playlist_id}", tag = "user-data", params(("playlist_id" = Uuid, Path)), responses((status = 200, body = crate::services::PlaylistItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn get_playlist( + State(state): State, + Path(playlist_id): Path, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .playlist(user.id, playlist_id) + .await + .map(Json) + .map_err(service_error) +} + +/// An unknown name is refused rather than ignored: a client asking to clear +/// `expiresAt` instead of `expires_at` would otherwise be told it succeeded +/// while the field stayed put. +pub(super) fn playlist_clear(names: &[String]) -> Result { + let mut clear = crate::services::PlaylistClear::default(); + for name in names { + match name.as_str() { + "comment" => clear.comment = true, + _ => return Err(ApiError::Validation), + } + } + Ok(clear) +} + +#[utoipa::path(patch, path = "/api/v2/playlists/{playlist_id}", tag = "user-data", params(("playlist_id" = Uuid, Path)), request_body = UpdatePlaylistRequest, responses((status = 200, body = crate::services::PlaylistItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn update_playlist( + State(state): State, + Path(playlist_id): Path, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Write).await?; + let context = mutation_context(&state, &headers, user.id).await?; + state + .services + .update_playlist_with_context( + user.id, + playlist_id, + request.name.as_deref(), + request.comment.as_deref(), + request.public, + &request.add, + &request.remove_indexes, + playlist_clear(&request.clear)?, + context, + ) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(delete, path = "/api/v2/playlists/{playlist_id}", tag = "user-data", params(("playlist_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn delete_playlist( + State(state): State, + Path(playlist_id): Path, + headers: HeaderMap, +) -> Result { + let user = authenticated(&state, &headers, Access::Write).await?; + let context = mutation_context(&state, &headers, user.id).await?; + state + .services + .delete_playlist_with_context(user.id, playlist_id, context) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/api/probes.rs b/src/api/probes.rs new file mode 100644 index 0000000..5856837 --- /dev/null +++ b/src/api/probes.rs @@ -0,0 +1,65 @@ +//! Liveness and readiness. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Serialize, ToSchema)] +pub struct ProbeResponse { + pub status: &'static str, + pub version: &'static str, + pub schema: u8, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ReadyResponse { + pub status: &'static str, + pub database: &'static str, +} + +#[utoipa::path( + get, + path = "/health", + tag = "probes", + responses((status = 200, body = ProbeResponse)) +)] +pub async fn health() -> Json { + Json(ProbeResponse { + status: "ok", + version: env!("CARGO_PKG_VERSION"), + schema: 2, + }) +} + +#[utoipa::path( + get, + path = "/ready", + tag = "probes", + responses( + (status = 200, body = ReadyResponse), + (status = 503, body = ReadyResponse) + ) +)] +pub async fn ready(State(state): State) -> Response { + match state.db.ping().await { + Ok(()) => ( + StatusCode::OK, + Json(ReadyResponse { + status: "ready", + database: "ok", + }), + ) + .into_response(), + Err(error) => { + tracing::warn!(error = %error, "readiness database probe failed"); + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ReadyResponse { + status: "unavailable", + database: "unavailable", + }), + ) + .into_response() + } + } +} diff --git a/src/api/setup.rs b/src/api/setup.rs new file mode 100644 index 0000000..f4569a3 --- /dev/null +++ b/src/api/setup.rs @@ -0,0 +1,44 @@ +//! First-run setup. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Serialize, ToSchema)] +pub struct SetupStatusResponse { + pub required: bool, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SetupRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct SetupResponse { + pub user_id: Uuid, +} + +#[utoipa::path(get, path = "/api/v2/setup", tag = "authentication", responses((status = 200, body = SetupStatusResponse)))] +pub async fn setup_status( + State(state): State, +) -> Result, ApiError> { + let required = state.db.setup_required().await.map_err(db_error)?; + Ok(Json(SetupStatusResponse { required })) +} + +#[utoipa::path(post, path = "/api/v2/setup", tag = "authentication", params(("Origin" = String, Header, description = "Required browser origin")), request_body = SetupRequest, responses((status = 201, body = SetupResponse), (status = 403, description = "Origin header missing or rejected", body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn setup( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + validate_web_origin(&state, &headers)?; + let user_id = state + .services + .bootstrap_admin(&request.username, &request.password) + .await + .map_err(service_error)?; + Ok((StatusCode::CREATED, Json(SetupResponse { user_id }))) +} diff --git a/src/api/shares.rs b/src/api/shares.rs new file mode 100644 index 0000000..35c0468 --- /dev/null +++ b/src/api/shares.rs @@ -0,0 +1,148 @@ +//! Public shares. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateShareRequest { + pub track_ids: Vec, + pub description: Option, + pub expires_at: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateShareRequest { + pub description: Option, + pub expires_at: Option, + /// Optional fields to blank out, by name: `description`, `expires_at`. + /// + /// Without this, an expiry set by mistake is permanent — `COALESCE` reads an + /// absent field and an explicit null identically, so the owner's only + /// recourse would be deleting the share and publishing a different URL. + #[serde(default)] + pub clear: Vec, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ShareResponse { + pub id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + pub description: Option, + pub expires_at: Option, + pub created_at: i64, + pub visit_count: i64, + pub track_ids: Vec, +} + +/// See [`playlist_clear`]. +pub(super) fn share_clear(names: &[String]) -> Result { + let mut clear = crate::services::ShareClear::default(); + for name in names { + match name.as_str() { + "description" => clear.description = true, + "expires_at" => clear.expires_at = true, + _ => return Err(ApiError::Validation), + } + } + Ok(clear) +} + +#[utoipa::path(get, path = "/api/v2/shares", tag = "user-data", responses((status = 200, body = [ShareResponse]), (status = 401, body = ErrorResponse)))] +pub async fn list_shares( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + let shares = state + .services + .shares(user.id) + .await + .map_err(service_error)? + .into_iter() + .map(|share| share_response(&state, share)) + .collect(); + Ok(Json(shares)) +} + +#[utoipa::path(post, path = "/api/v2/shares", tag = "user-data", request_body = CreateShareRequest, responses((status = 201, body = ShareResponse), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn create_share( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let user = authenticated(&state, &headers, Access::Write).await?; + let context = mutation_context(&state, &headers, user.id).await?; + let share = state + .services + .create_share_with_context( + user.id, + &request.track_ids, + request.description.as_deref(), + request.expires_at, + context, + ) + .await + .map_err(service_error)?; + Ok((StatusCode::CREATED, Json(share_response(&state, share)))) +} + +#[utoipa::path(patch, path = "/api/v2/shares/{share_id}", tag = "user-data", params(("share_id" = Uuid, Path)), request_body = UpdateShareRequest, responses((status = 200, body = ShareResponse), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn update_share( + State(state): State, + Path(share_id): Path, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Write).await?; + let context = mutation_context(&state, &headers, user.id).await?; + let share = state + .services + .update_share_with_context( + user.id, + share_id, + request.description.as_deref(), + request.expires_at, + share_clear(&request.clear)?, + context, + ) + .await + .map_err(service_error)?; + Ok(Json(share_response(&state, share))) +} + +#[utoipa::path(delete, path = "/api/v2/shares/{share_id}", tag = "user-data", params(("share_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn delete_share( + State(state): State, + Path(share_id): Path, + headers: HeaderMap, +) -> Result { + let user = authenticated(&state, &headers, Access::Write).await?; + let context = mutation_context(&state, &headers, user.id).await?; + state + .services + .delete_share_with_context(user.id, share_id, context) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} + +pub(super) fn share_response(state: &AppState, share: crate::services::ShareItem) -> ShareResponse { + let url = share.url_token.map(|token| { + let path = format!("/share/{token}"); + state + .public_url + .as_ref() + .map_or_else(|| path.clone(), |base| format!("{base}{path}")) + }); + ShareResponse { + id: share.id, + url, + description: share.description, + expires_at: share.expires_at, + created_at: share.created_at, + visit_count: share.visit_count, + track_ids: share.songs.into_iter().map(|song| song.id).collect(), + } +} diff --git a/src/api/sync.rs b/src/api/sync.rs new file mode 100644 index 0000000..4a991d0 --- /dev/null +++ b/src/api/sync.rs @@ -0,0 +1,328 @@ +//! The sync journal: changes, snapshot, acknowledgement and the socket. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Deserialize)] +pub struct SyncQuery { + pub after: Option, + pub limit: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SyncAckRequest { + pub device_id: Uuid, + pub cursor: i64, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct SyncSnapshot { + pub cursor: i64, + pub playlists: Vec, + pub favorites: Vec, + pub ratings: Vec, + pub queue: Option, + pub history: Vec, + pub shares: Vec, + pub bookmarks: Vec, +} + +#[utoipa::path( + get, + path = "/api/v2/sync/changes", + tag = "sync", + params(("after" = Option, Query), ("limit" = Option, Query)), + responses( + (status = 200, body = crate::sync::SyncPage), + (status = 401, body = ErrorResponse), + ( + status = 409, + description = "`code` is `cursor_expired`: the cursor precedes the oldest \ + retained event, so the gap cannot be replayed. Discard the local \ + projection, take a fresh /sync/snapshot and resume from its \ + cursor. Distinct from `conflict`, which is about operation ids.", + body = ErrorResponse + ), + (status = 422, body = ErrorResponse) + ) +)] +pub async fn sync_changes( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + let after = query.after.unwrap_or(0); + let limit = query.limit.unwrap_or(crate::sync::DEFAULT_SYNC_LIMIT); + if after < 0 || limit <= 0 || limit > crate::sync::MAX_SYNC_LIMIT { + return Err(ApiError::Validation); + } + state + .sync + .changes(user.id, after, limit) + .await + .map(Json) + .map_err(sync_error) +} + +#[utoipa::path( + get, + path = "/api/v2/sync/snapshot", + tag = "sync", + responses((status = 200, body = SyncSnapshot), (status = 401, body = ErrorResponse)) +)] +pub async fn sync_snapshot( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + let snapshot = state + .services + .sync_snapshot(user.id, crate::sync::MAX_SYNC_LIMIT) + .await + .map_err(service_error)?; + let favorites = snapshot + .favorites + .into_iter() + .map(|(entity_type, entity_id, starred_at)| StarredEntry { + entity_type, + entity_id, + starred_at, + }) + .collect(); + let shares = snapshot + .shares + .into_iter() + .map(|share| share_response(&state, share)) + .collect(); + Ok(Json(SyncSnapshot { + cursor: snapshot.cursor, + playlists: snapshot.playlists, + favorites, + ratings: snapshot.ratings, + queue: snapshot.queue, + history: snapshot.history, + shares, + bookmarks: snapshot.bookmarks, + })) +} + +#[utoipa::path( + put, + path = "/api/v2/sync/ack", + tag = "sync", + request_body = SyncAckRequest, + responses( + (status = 204), + (status = 401, body = ErrorResponse), + (status = 422, body = ErrorResponse) + ) +)] +pub async fn sync_ack( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let user = authenticated(&state, &headers, Access::Write).await?; + let acknowledged = state + .sync + .acknowledge(user.id, request.device_id, request.cursor) + .await + .map_err(db_error)?; + if !acknowledged { + return Err(ApiError::Validation); + } + Ok(StatusCode::NO_CONTENT) +} + +/// The socket is an edge-triggered wake-up channel. A client always follows a +/// notice with `GET /sync/changes`; the durable cursor, not socket delivery, is +/// the synchronization guarantee. +#[utoipa::path( + get, + path = "/api/v2/sync/socket", + tag = "sync", + params(("after" = Option, Query)), + responses( + (status = 101, description = "WebSocket cursor notifications"), + (status = 401, body = ErrorResponse), + (status = 422, body = ErrorResponse) + ) +)] +pub async fn sync_socket( + State(state): State, + headers: HeaderMap, + Query(query): Query, + upgrade: WebSocketUpgrade, +) -> Result { + let user = authenticated(&state, &headers, Access::Read).await?; + let after = query.after.unwrap_or(0); + if after < 0 { + return Err(ApiError::Validation); + } + Ok(upgrade + .on_upgrade(move |socket| serve_sync_socket(socket, state, user.id, after)) + .into_response()) +} + +pub(super) async fn serve_sync_socket( + socket: WebSocket, + state: AppState, + user_id: Uuid, + after: i64, +) { + let (mut sender, mut receiver) = socket.split(); + let mut notices = state.sync.subscribe(); + if let Ok(cursor) = state.sync.latest_user_cursor(user_id).await { + if cursor > after && send_sync_notice(&mut sender, cursor).await.is_err() { + return; + } + } + let mut heartbeat = tokio::time::interval(Duration::from_secs(30)); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + heartbeat.tick().await; + let mut awaiting_pong = false; + loop { + tokio::select! { + incoming = receiver.next() => match incoming { + Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, + Some(Ok(Message::Pong(_))) => awaiting_pong = false, + Some(Ok(Message::Ping(payload))) => { + if sender.send(Message::Pong(payload)).await.is_err() { + break; + } + } + Some(Ok(_)) => {} + }, + notice = notices.recv() => match sync_notice_action(&state.sync, user_id, notice).await { + Ok(SyncNoticeAction::Send(cursor)) => { + if send_sync_notice(&mut sender, cursor).await.is_err() { + break; + } + } + Ok(SyncNoticeAction::Continue) => {} + Ok(SyncNoticeAction::Close) | Err(_) => break, + }, + _ = heartbeat.tick() => { + if awaiting_pong || sender.send(Message::Ping(Vec::new().into())).await.is_err() { + break; + } + awaiting_pong = true; + } + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum SyncNoticeAction { + Send(i64), + Continue, + Close, +} + +pub(super) async fn sync_notice_action( + sync: &crate::sync::SyncService, + user_id: Uuid, + notice: Result<(Uuid, crate::sync::SyncNotice), tokio::sync::broadcast::error::RecvError>, +) -> Result { + match notice { + Ok((notice_user, notice)) if notice_user == user_id => { + Ok(SyncNoticeAction::Send(notice.cursor)) + } + Ok(_) => Ok(SyncNoticeAction::Continue), + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => sync + .latest_user_cursor(user_id) + .await + .map(SyncNoticeAction::Send), + Err(tokio::sync::broadcast::error::RecvError::Closed) => Ok(SyncNoticeAction::Close), + } +} + +pub(super) async fn send_sync_notice( + sender: &mut futures_util::stream::SplitSink, + cursor: i64, +) -> Result<(), axum::Error> { + let body = + serde_json::to_string(&crate::sync::SyncNotice { cursor }).expect("sync notice serializes"); + sender.send(Message::Text(body.into())).await +} + +#[cfg(test)] +mod tests { + use super::{sync_notice_action, SyncNoticeAction}; + + /// A lagged socket recovers the cursor from the journal, not from a default. + /// + /// The distinction needs a non-zero cursor to be visible at all: a user with + /// no events answers 0, which is also what returning a constant would give, + /// so the empty case alone proves only that the branch is wired to + /// something. The event below is written through the real journal path, so + /// the expected value is one the test never chose. + #[tokio::test] + async fn lagged_sync_socket_recovers_from_the_durable_cursor() { + let temp = tempfile::tempdir().unwrap(); + let config = crate::Config::for_data_dir(temp.path().join("data")); + let db = crate::database::Database::open(&config).await.unwrap(); + db.migrate().await.unwrap(); + let user_id = db + .create_account( + "lagged", + "hash", + crate::database::AccountRole::User, + crate::authentication::now_ms(), + ) + .await + .unwrap(); + let sync = crate::sync::SyncService::new(db.clone()); + + let context = crate::sync::MutationContext::server_generated(); + let writer = db.writer_guard().await; + let mut tx = db.pool().begin().await.unwrap(); + sync.claim_operation( + &writer, + &mut tx, + user_id, + context, + crate::sync::MutationIntent::new("set-rating", "track:seed", &serde_json::json!({})), + ) + .await + .unwrap(); + let receipt = sync + .complete_operation( + &mut tx, + user_id, + context, + "rating", + uuid::Uuid::new_v4(), + "upsert", + &serde_json::json!({}), + None, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + drop(writer); + assert_ne!(receipt.cursor, 0); + + let action = sync_notice_action( + &sync, + user_id, + Err(tokio::sync::broadcast::error::RecvError::Lagged(3)), + ) + .await + .unwrap(); + assert_eq!(action, SyncNoticeAction::Send(receipt.cursor)); + + // An account with nothing in the journal still falls back to the base + // cursor rather than failing. + let action = sync_notice_action( + &sync, + uuid::Uuid::new_v4(), + Err(tokio::sync::broadcast::error::RecvError::Lagged(3)), + ) + .await + .unwrap(); + assert_eq!(action, SyncNoticeAction::Send(0)); + } +} diff --git a/src/api/tokens.rs b/src/api/tokens.rs new file mode 100644 index 0000000..ff536ca --- /dev/null +++ b/src/api/tokens.rs @@ -0,0 +1,79 @@ +//! API tokens. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +/// A token to issue. +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateApiTokenRequest { + /// What the token is for. Shown in the listing so a stale one can be told + /// apart from a live one before it is revoked. + pub name: String, + #[serde(default)] + pub scopes: Vec, +} + +/// An issued token. The secret appears here and nowhere else, ever again. +#[derive(Debug, Serialize, ToSchema)] +pub struct CreateApiTokenResponse { + #[serde(flatten)] + pub token: crate::database::ApiTokenRecord, + /// Shown once. Only its SHA-256 hash is stored, so it cannot be recovered. + pub secret: String, +} + +#[utoipa::path(get, path = "/api/v2/admin/users/{username}/tokens", tag = "administration", params(("username" = String, Path)), responses((status = 200, body = [crate::database::ApiTokenRecord]), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn list_api_tokens( + State(state): State, + Path(username): Path, + headers: HeaderMap, +) -> Result>, ApiError> { + let actor = authenticated(&state, &headers, Access::Admin).await?; + state + .services + .api_tokens(actor.id, &username) + .await + .map(Json) + .map_err(service_error) +} + +/// Issues an API token without a shell on the host. +/// +/// The `token create` CLI command remains, for bootstrapping an instance that +/// has no administrator session yet; from here on the two share +/// `DomainServices::create_api_token`, so a token minted either way carries the +/// same scopes and the same audit trail. +#[utoipa::path(post, path = "/api/v2/admin/users/{username}/tokens", tag = "administration", params(("username" = String, Path)), request_body = CreateApiTokenRequest, responses((status = 201, body = CreateApiTokenResponse), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn create_api_token( + State(state): State, + Path(username): Path, + headers: HeaderMap, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let actor = authenticated(&state, &headers, Access::Admin).await?; + let (token, secret) = state + .services + .create_api_token(actor.id, &username, &request.name, &request.scopes) + .await + .map_err(service_error)?; + Ok(( + StatusCode::CREATED, + Json(CreateApiTokenResponse { token, secret }), + )) +} + +#[utoipa::path(delete, path = "/api/v2/admin/users/{username}/tokens/{token_id}", tag = "administration", params(("username" = String, Path), ("token_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn revoke_api_token( + State(state): State, + Path((username, token_id)): Path<(String, Uuid)>, + headers: HeaderMap, +) -> Result { + let actor = authenticated(&state, &headers, Access::Admin).await?; + state + .services + .revoke_api_token(actor.id, &username, token_id) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/api/tracks.rs b/src/api/tracks.rs new file mode 100644 index 0000000..2baf560 --- /dev/null +++ b/src/api/tracks.rs @@ -0,0 +1,76 @@ +//! Tracks and their lyrics. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Deserialize)] +pub struct TrackQuery { + pub q: Option, + pub offset: Option, + pub limit: Option, +} + +#[utoipa::path(get, path = "/api/v2/libraries/{library_id}/tracks", tag = "catalog", params(("library_id" = Uuid, Path), ("q" = Option, Query), ("offset" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = [crate::catalog::TrackRecord]), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn list_tracks( + State(state): State, + Path(library_id): Path, + Query(query): Query, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + if state + .db + .library_for_user(user.id, library_id) + .await + .map_err(db_error)? + .is_none() + { + return Err(ApiError::NotFound); + } + let offset = query.offset.unwrap_or(0); + let limit = query.limit.unwrap_or(500); + if offset < 0 || !(1..=500).contains(&limit) { + return Err(ApiError::Validation); + } + let query = query.q.as_deref().map(str::trim).filter(|q| !q.is_empty()); + let tracks = state + .db + .browse_tracks_for_user(user.id, library_id, query, offset, limit) + .await + .map_err(db_error)?; + Ok(Json(tracks)) +} + +#[utoipa::path(get, path = "/api/v2/tracks/{track_id}", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, body = crate::services::SongItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn get_track( + State(state): State, + Path(track_id): Path, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .songs_by_ids(user.id, &[track_id]) + .await + .map_err(service_error)? + .into_iter() + .next() + .map(Json) + .ok_or(ApiError::NotFound) +} + +#[utoipa::path(get, path = "/api/v2/tracks/{track_id}/lyrics", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, body = crate::lyrics::LyricsList), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn get_track_lyrics( + State(state): State, + Path(track_id): Path, + headers: HeaderMap, +) -> Result, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .lyrics(user.id, track_id) + .await + .map(Json) + .map_err(service_error) +} diff --git a/src/api/users.rs b/src/api/users.rs new file mode 100644 index 0000000..c86c0bb --- /dev/null +++ b/src/api/users.rs @@ -0,0 +1,140 @@ +//! User administration and Subsonic credentials. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateUserRequest { + pub username: String, + pub web_password: String, + pub role: crate::database::AccountRole, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateUserRequest { + pub role: Option, + pub disabled: Option, + pub library_ids: Option>, + pub subsonic_password: Option, + pub web_password: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SetSubsonicCredentialRequest { + pub password: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct SubsonicCredentialResponse { + /// Shown once. Only its SHA-256 hash is stored by the server. + pub api_key: String, +} + +#[utoipa::path(get, path = "/api/v2/admin/users", tag = "administration", responses((status = 200, body = [crate::services::UserItem]), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse)))] +pub async fn list_users( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + let actor = authenticated(&state, &headers, Access::Admin).await?; + state + .services + .users(actor.id) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(post, path = "/api/v2/admin/users", tag = "administration", request_body = CreateUserRequest, responses((status = 201, body = crate::services::UserItem), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn create_user( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let actor = authenticated(&state, &headers, Access::Admin).await?; + let user = state + .services + .create_web_user( + actor.id, + &request.username, + &request.web_password, + request.role, + ) + .await + .map_err(service_error)?; + Ok((StatusCode::CREATED, Json(user))) +} + +#[utoipa::path(patch, path = "/api/v2/admin/users/{username}", tag = "administration", params(("username" = String, Path)), request_body = UpdateUserRequest, responses((status = 200, body = crate::services::UserItem), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn update_user( + State(state): State, + Path(username): Path, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + let actor = authenticated(&state, &headers, Access::Admin).await?; + state + .services + .update_user( + actor.id, + &username, + crate::services::UserUpdate { + admin: request + .role + .map(|role| role == crate::database::AccountRole::Admin), + disabled: request.disabled, + folder_ids: request.library_ids.as_deref(), + subsonic_password: request.subsonic_password.as_deref(), + web_password: request.web_password.as_deref(), + }, + ) + .await + .map(Json) + .map_err(service_error) +} + +#[utoipa::path(delete, path = "/api/v2/admin/users/{username}", tag = "administration", params(("username" = String, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn delete_user( + State(state): State, + Path(username): Path, + headers: HeaderMap, +) -> Result { + let actor = authenticated(&state, &headers, Access::Admin).await?; + state + .services + .delete_user(actor.id, &username) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(put, path = "/api/v2/admin/users/{username}/subsonic-credential", tag = "administration", params(("username" = String, Path)), request_body = SetSubsonicCredentialRequest, responses((status = 200, body = SubsonicCredentialResponse), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +pub async fn set_subsonic_credential( + State(state): State, + Path(username): Path, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + let actor = authenticated(&state, &headers, Access::Admin).await?; + let api_key = state + .services + .set_subsonic_credential(actor.id, &username, &request.password) + .await + .map_err(service_error)?; + Ok(Json(SubsonicCredentialResponse { api_key })) +} + +#[utoipa::path(delete, path = "/api/v2/admin/users/{username}/subsonic-credential", tag = "administration", params(("username" = String, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn revoke_subsonic_credential( + State(state): State, + Path(username): Path, + headers: HeaderMap, +) -> Result { + let actor = authenticated(&state, &headers, Access::Admin).await?; + state + .services + .revoke_subsonic_credential(actor.id, &username) + .await + .map_err(service_error)?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/api/web_session.rs b/src/api/web_session.rs new file mode 100644 index 0000000..fddc063 --- /dev/null +++ b/src/api/web_session.rs @@ -0,0 +1,139 @@ +//! The browser session: cookies, CSRF and origin checks. +//! +//! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. + +use super::*; + +#[derive(Debug, Serialize, ToSchema)] +pub struct WebAuthResponse { + pub access_token: String, + pub token_type: &'static str, + pub expires_in: u64, + pub user: crate::authentication::AuthUser, + pub device_id: Uuid, +} + +pub(super) fn web_auth_response( + state: &AppState, + _headers: &HeaderMap, + tokens: crate::authentication::AuthTokens, +) -> Result { + let csrf_token = crate::security::generate_token("wfcsrf_"); + let secure = secure_cookies(state); + let refresh_cookie = format!( + "{WEB_REFRESH_COOKIE}={}; Path=/api/v2/web/auth; HttpOnly; SameSite=Strict; Max-Age={}{}", + tokens.refresh_token, + state.refresh_token_ttl.as_secs(), + if secure { "; Secure" } else { "" } + ); + let csrf_cookie = format!( + "{WEB_CSRF_COOKIE}={csrf_token}; Path=/; SameSite=Strict; Max-Age={}{}", + state.refresh_token_ttl.as_secs(), + if secure { "; Secure" } else { "" } + ); + let body = WebAuthResponse { + access_token: tokens.access_token, + token_type: tokens.token_type, + expires_in: tokens.expires_in, + user: tokens.user, + device_id: tokens.device_id, + }; + let mut response = Json(body).into_response(); + append_cookie(&mut response, refresh_cookie)?; + append_cookie(&mut response, csrf_cookie)?; + Ok(response) +} + +pub(super) fn append_cookie(response: &mut Response, value: String) -> Result<(), ApiError> { + let value = HeaderValue::from_str(&value).map_err(|_| ApiError::Unavailable)?; + response.headers_mut().append(header::SET_COOKIE, value); + Ok(()) +} + +pub(super) fn expired_cookie(name: &str, http_only: bool, secure: bool) -> String { + format!( + "{name}=; Path={}; SameSite=Strict; Max-Age=0{}{}", + if http_only { "/api/v2/web/auth" } else { "/" }, + if http_only { "; HttpOnly" } else { "" }, + if secure { "; Secure" } else { "" } + ) +} + +pub(super) fn secure_cookies(state: &AppState) -> bool { + public_url_is_https(state.public_url.as_deref()) +} + +pub(super) fn public_url_is_https(public_url: Option<&str>) -> bool { + public_url + .and_then(|url| url::Url::parse(url).ok()) + .is_some_and(|url| url.scheme() == "https") +} + +pub(super) fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .filter_map(|pair| pair.trim().split_once('=')) + .find_map(|(key, value)| (key == name && !value.is_empty()).then_some(value)) +} + +pub(super) fn validate_web_request(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> { + validate_web_origin(state, headers)?; + let cookie = cookie_value(headers, WEB_CSRF_COOKIE).ok_or(ApiError::Forbidden)?; + let supplied = headers + .get(WEB_CSRF_HEADER) + .and_then(|value| value.to_str().ok()) + .ok_or(ApiError::Forbidden)?; + if !crate::security::constant_time_bytes_eq(cookie.as_bytes(), supplied.as_bytes()) { + return Err(ApiError::Forbidden); + } + Ok(()) +} + +pub(super) fn validate_web_origin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> { + let origin = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .ok_or(ApiError::Forbidden)?; + let parsed = url::Url::parse(origin).map_err(|_| ApiError::Forbidden)?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.path() != "/" + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(ApiError::Forbidden); + } + if let Some(public_url) = state.public_url.as_deref() { + let expected = url::Url::parse(public_url).map_err(|_| ApiError::Unavailable)?; + return if parsed.origin() == expected.origin() { + Ok(()) + } else { + Err(ApiError::Forbidden) + }; + } + let authority = &parsed[url::Position::BeforeHost..url::Position::AfterPort]; + let host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .ok_or(ApiError::Forbidden)?; + if authority.eq_ignore_ascii_case(host) { + Ok(()) + } else { + Err(ApiError::Forbidden) + } +} + +#[cfg(test)] +mod tests { + use super::public_url_is_https; + + #[test] + fn secure_cookie_detection_uses_the_parsed_url_scheme() { + assert!(public_url_is_https(Some("HTTPS://waveflow.test/"))); + assert!(!public_url_is_https(Some("http://waveflow.test"))); + assert!(!public_url_is_https(Some("not a URL"))); + assert!(!public_url_is_https(None)); + } +} diff --git a/src/http.rs b/src/http.rs deleted file mode 100644 index 6fb36b1..0000000 --- a/src/http.rs +++ /dev/null @@ -1,2415 +0,0 @@ -//! M0 HTTP surface: probes, OpenAPI and local session lifecycle. - -use std::{convert::Infallible, str::FromStr, time::Duration}; - -use axum::{ - extract::{ - ws::{Message, WebSocket, WebSocketUpgrade}, - Path, Query, State, - }, - http::{header, HeaderMap, HeaderValue, StatusCode}, - response::{ - sse::{Event, KeepAlive}, - IntoResponse, Response, Sse, - }, - routing::{get, post, put}, - Json, Router, -}; -use futures_util::{SinkExt, StreamExt}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::{authentication::AuthError, AppState}; - -const WEB_REFRESH_COOKIE: &str = "waveflow-refresh"; -const WEB_CSRF_COOKIE: &str = "waveflow-csrf"; -pub const WEB_CSRF_HEADER: &str = "x-waveflow-csrf"; -pub const OPERATION_ID_HEADER: &str = "x-waveflow-operation-id"; -pub const DEVICE_ID_HEADER: &str = "x-waveflow-device-id"; - -#[derive(Debug, Serialize, ToSchema)] -pub struct ProbeResponse { - pub status: &'static str, - pub version: &'static str, - pub schema: u8, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct ReadyResponse { - pub status: &'static str, - pub database: &'static str, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct LoginRequest { - pub username: String, - pub password: String, - pub device_name: String, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct RefreshRequest { - pub refresh_token: String, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct WebAuthResponse { - pub access_token: String, - pub token_type: &'static str, - pub expires_in: u64, - pub user: crate::authentication::AuthUser, - pub device_id: Uuid, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct ErrorResponse { - pub code: &'static str, - pub message: &'static str, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct ScanQueuedResponse { - pub scan_id: Uuid, -} - -#[derive(Debug, Deserialize)] -pub struct TrackQuery { - pub q: Option, - pub offset: Option, - pub limit: Option, -} - -#[derive(Debug, Deserialize)] -pub struct BrowseQuery { - pub library_id: Option, - pub offset: Option, - pub limit: Option, -} - -/// Album discovery parameters. `sort` accepts the same vocabulary as the -/// Subsonic `type` parameter — both surfaces resolve to [`AlbumOrder`], so the -/// web client can build a home screen ("recently added", "most played") in one -/// call instead of paging the whole catalogue and sorting locally. -#[derive(Debug, Deserialize)] -pub struct AlbumBrowseQuery { - pub library_id: Option, - pub offset: Option, - pub limit: Option, - pub sort: Option, - /// Required by `sort=byGenre`, ignored otherwise. - pub genre: Option, - pub from_year: Option, - pub to_year: Option, -} - -#[derive(Debug, Deserialize)] -pub struct GenreQuery { - pub library_id: Option, -} - -#[derive(Debug, Deserialize)] -pub struct SearchQuery { - pub q: String, - /// Applied to every kind unless the per-kind offset below overrides it. - pub offset: Option, - pub limit: Option, - pub artist_offset: Option, - pub album_offset: Option, - pub song_offset: Option, -} - -#[derive(Debug, Deserialize)] -pub struct RandomSongQuery { - pub library_id: Option, - pub genre: Option, - pub from_year: Option, - pub to_year: Option, - pub limit: Option, -} - -#[derive(Debug, Deserialize)] -pub struct GenreSongQuery { - pub genre: String, - pub library_id: Option, - pub offset: Option, - pub limit: Option, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct CreatePlaylistRequest { - pub name: String, - #[serde(default)] - pub track_ids: Vec, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct UpdatePlaylistRequest { - pub name: Option, - pub comment: Option, - pub public: Option, - /// Track ids appended to the end, applied after `remove_indexes`. - #[serde(default)] - pub add: Vec, - /// Zero-based positions removed before `add` is applied. - #[serde(default)] - pub remove_indexes: Vec, - /// Optional fields to blank out, by name. Currently `comment`. - /// - /// Omitting a field leaves it untouched, so clearing needs its own verb: - /// naming it here is the only way to distinguish "unchanged" from "empty", - /// and it cannot fire by accident on a client that simply omits the field. - #[serde(default)] - pub clear: Vec, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct RatingRequest { - /// 1 to 5 stars; 0 clears the rating. - pub rating: i64, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct ScrobbleRequest { - pub track_id: Uuid, - /// `false` records a "now playing" ping, `true` a completed listen. - #[serde(default)] - pub submission: bool, - pub played_at: Option, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct SaveQueueRequest { - #[serde(default)] - pub track_ids: Vec, - pub current: Option, - #[serde(default)] - pub position_ms: i64, - pub client: Option, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct CreateShareRequest { - pub track_ids: Vec, - pub description: Option, - pub expires_at: Option, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct UpdateShareRequest { - pub description: Option, - pub expires_at: Option, - /// Optional fields to blank out, by name: `description`, `expires_at`. - /// - /// Without this, an expiry set by mistake is permanent — `COALESCE` reads an - /// absent field and an explicit null identically, so the owner's only - /// recourse would be deleting the share and publishing a different URL. - #[serde(default)] - pub clear: Vec, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct ShareResponse { - pub id: Uuid, - #[serde(skip_serializing_if = "Option::is_none")] - pub url: Option, - pub description: Option, - pub expires_at: Option, - pub created_at: i64, - pub visit_count: i64, - pub track_ids: Vec, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct AuthorizeRequest { - pub client_id: String, - pub redirect_uri: String, - pub code_challenge: String, - #[serde(default = "default_challenge_method")] - pub code_challenge_method: String, - pub state: Option, - /// Name recorded for the device this grant will create a session for. - pub device_name: String, -} - -fn default_challenge_method() -> String { - "S256".into() -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct AuthorizeResponse { - /// Where the consent screen must send the user agent. - pub redirect_to: String, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct TokenRequest { - pub code: String, - pub code_verifier: String, - pub client_id: String, - pub redirect_uri: String, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct StarredEntry { - pub entity_type: String, - pub entity_id: Uuid, - pub starred_at: i64, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct NowPlayingEntry { - pub username: String, - pub song: crate::services::SongItem, - pub started_at: i64, -} - -#[derive(Debug, Deserialize)] -pub struct SyncQuery { - pub after: Option, - pub limit: Option, -} - -#[derive(Debug, Deserialize)] -pub struct HistoryQuery { - pub limit: Option, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct TranscodeStatusResponse { - pub available: bool, - pub active: usize, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct CreateUserRequest { - pub username: String, - pub web_password: String, - pub role: crate::database::AccountRole, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct UpdateUserRequest { - pub role: Option, - pub disabled: Option, - pub library_ids: Option>, - pub subsonic_password: Option, - pub web_password: Option, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct SetSubsonicCredentialRequest { - pub password: String, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct SubsonicCredentialResponse { - /// Shown once. Only its SHA-256 hash is stored by the server. - pub api_key: String, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct SetupStatusResponse { - pub required: bool, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct SetupRequest { - pub username: String, - pub password: String, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct SetupResponse { - pub user_id: Uuid, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct CreateLibraryRequest { - pub name: String, - pub path: String, - pub visibility: crate::database::LibraryVisibility, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct CreateLibraryResponse { - pub library_id: Uuid, - pub scan_id: Uuid, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct SetLibraryMemberRequest { - pub role: crate::database::LibraryRole, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct SyncAckRequest { - pub device_id: Uuid, - pub cursor: i64, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct SyncSnapshot { - pub cursor: i64, - pub playlists: Vec, - pub favorites: Vec, - pub ratings: Vec, - pub queue: Option, - pub history: Vec, - pub shares: Vec, - pub bookmarks: Vec, -} - -pub fn router(state: AppState) -> Router { - Router::new() - .route("/health", get(health)) - .route("/ready", get(ready)) - .route("/api/v2/setup", get(setup_status).post(setup)) - .route("/api/v2/auth/login", post(login)) - .route("/api/v2/auth/refresh", post(refresh)) - .route("/api/v2/auth/logout", post(logout)) - .route("/api/v2/web/auth/login", post(web_login)) - .route("/api/v2/web/auth/refresh", post(web_refresh)) - .route("/api/v2/web/auth/logout", post(web_logout)) - .route("/api/v2/oauth/authorize", post(oauth_authorize)) - // No auth layer: the code plus its PKCE verifier are the credential. - .route("/api/v2/oauth/token", post(oauth_token)) - .route("/api/v2/libraries/{library_id}/scans", post(start_scan)) - .route( - "/api/v2/libraries", - get(list_libraries).post(create_library), - ) - .route( - "/api/v2/libraries/{library_id}/members/{user_id}", - put(set_library_member).delete(remove_library_member), - ) - .route("/api/v2/scans/{scan_id}", get(scan_status)) - .route("/api/v2/scans/{scan_id}/events", get(scan_events)) - .route("/api/v2/libraries/{library_id}/tracks", get(list_tracks)) - .route("/api/v2/tracks/{track_id}", get(get_track)) - .route("/api/v2/tracks/{track_id}/lyrics", get(get_track_lyrics)) - .route("/api/v2/albums", get(list_albums)) - .route("/api/v2/genres", get(list_genres)) - .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/search", get(search_catalog)) - .route("/api/v2/songs", get(list_songs_by_genre)) - .route("/api/v2/songs/random", get(list_random_songs)) - .route( - "/api/v2/playlists", - get(list_playlists).post(create_playlist), - ) - .route( - "/api/v2/playlists/{playlist_id}", - get(get_playlist) - .patch(update_playlist) - .delete(delete_playlist), - ) - .route("/api/v2/favorites", get(list_favorites)) - .route( - "/api/v2/favorites/{entity_type}/{entity_id}", - put(add_favorite).delete(remove_favorite), - ) - .route("/api/v2/ratings/{entity_type}/{entity_id}", put(set_rating)) - .route("/api/v2/ratings", get(list_ratings)) - .route("/api/v2/bookmarks", get(list_bookmarks)) - .route( - "/api/v2/bookmarks/{track_id}", - put(set_bookmark).delete(delete_bookmark), - ) - .route("/api/v2/scrobbles", post(create_scrobble)) - .route("/api/v2/history", get(list_history)) - .route("/api/v2/now-playing", get(list_now_playing)) - .route("/api/v2/queue", get(get_queue).put(save_queue)) - .route("/api/v2/shares", get(list_shares).post(create_share)) - .route( - "/api/v2/shares/{share_id}", - axum::routing::patch(update_share).delete(delete_share), - ) - .route("/api/v2/sync/changes", get(sync_changes)) - .route("/api/v2/sync/snapshot", get(sync_snapshot)) - .route("/api/v2/sync/ack", put(sync_ack)) - .route("/api/v2/sync/socket", get(sync_socket)) - .route("/api/v2/transcode/status", get(transcode_status)) - .route("/api/v2/admin/users", get(list_users).post(create_user)) - .route( - "/api/v2/admin/users/{username}", - axum::routing::patch(update_user).delete(delete_user), - ) - .route( - "/api/v2/admin/users/{username}/subsonic-credential", - put(set_subsonic_credential).delete(revoke_subsonic_credential), - ) - .route( - "/api/v2/admin/users/{username}/tokens", - get(list_api_tokens).post(create_api_token), - ) - .route( - "/api/v2/admin/users/{username}/tokens/{token_id}", - axum::routing::delete(revoke_api_token), - ) - .with_state(state) -} - -#[utoipa::path( - get, - path = "/health", - tag = "probes", - responses((status = 200, body = ProbeResponse)) -)] -pub async fn health() -> Json { - Json(ProbeResponse { - status: "ok", - version: env!("CARGO_PKG_VERSION"), - schema: 2, - }) -} - -#[utoipa::path( - get, - path = "/ready", - tag = "probes", - responses( - (status = 200, body = ReadyResponse), - (status = 503, body = ReadyResponse) - ) -)] -pub async fn ready(State(state): State) -> Response { - match state.db.ping().await { - Ok(()) => ( - StatusCode::OK, - Json(ReadyResponse { - status: "ready", - database: "ok", - }), - ) - .into_response(), - Err(error) => { - tracing::warn!(error = %error, "readiness database probe failed"); - ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ReadyResponse { - status: "unavailable", - database: "unavailable", - }), - ) - .into_response() - } - } -} - -#[utoipa::path(get, path = "/api/v2/setup", tag = "authentication", responses((status = 200, body = SetupStatusResponse)))] -pub async fn setup_status( - State(state): State, -) -> Result, ApiError> { - let required = state.db.setup_required().await.map_err(db_error)?; - Ok(Json(SetupStatusResponse { required })) -} - -#[utoipa::path(post, path = "/api/v2/setup", tag = "authentication", params(("Origin" = String, Header, description = "Required browser origin")), request_body = SetupRequest, responses((status = 201, body = SetupResponse), (status = 403, description = "Origin header missing or rejected", body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn setup( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Result<(StatusCode, Json), ApiError> { - validate_web_origin(&state, &headers)?; - let user_id = state - .services - .bootstrap_admin(&request.username, &request.password) - .await - .map_err(service_error)?; - Ok((StatusCode::CREATED, Json(SetupResponse { user_id }))) -} - -#[utoipa::path( - post, - path = "/api/v2/auth/login", - tag = "authentication", - request_body = LoginRequest, - responses( - (status = 200, body = crate::authentication::AuthTokens), - (status = 401, body = ErrorResponse), - (status = 422, body = ErrorResponse), - (status = 503, body = ErrorResponse) - ) -)] -pub async fn login( - State(state): State, - Json(request): Json, -) -> Result, ApiError> { - state - .auth - .login(&request.username, &request.password, &request.device_name) - .await - .map(Json) - .map_err(ApiError::from) -} - -#[utoipa::path( - post, - path = "/api/v2/auth/refresh", - tag = "authentication", - request_body = RefreshRequest, - responses( - (status = 200, body = crate::authentication::AuthTokens), - (status = 401, body = ErrorResponse), - (status = 503, body = ErrorResponse) - ) -)] -pub async fn refresh( - State(state): State, - Json(request): Json, -) -> Result, ApiError> { - state - .auth - .refresh(&request.refresh_token) - .await - .map(Json) - .map_err(ApiError::from) -} - -#[utoipa::path( - post, - path = "/api/v2/auth/logout", - tag = "authentication", - responses( - (status = 204), - (status = 401, body = ErrorResponse), - (status = 503, body = ErrorResponse) - ) -)] -pub async fn logout( - State(state): State, - headers: HeaderMap, -) -> Result { - let access_token = bearer_token(&headers).ok_or(ApiError::Unauthorized)?; - state - .auth - .logout(access_token) - .await - .map_err(ApiError::from)?; - Ok(StatusCode::NO_CONTENT) -} - -/// Browser sessions keep only the short-lived access token in JavaScript. The -/// rotating refresh token is an HttpOnly, same-site cookie and is therefore -/// never exposed to the embedded SPA. -#[utoipa::path( - post, - path = "/api/v2/web/auth/login", - tag = "authentication", - request_body = LoginRequest, - responses( - (status = 200, body = WebAuthResponse), - (status = 401, body = ErrorResponse), - (status = 403, body = ErrorResponse) - ) -)] -pub async fn web_login( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Result { - validate_web_origin(&state, &headers)?; - let tokens = state - .auth - .login(&request.username, &request.password, &request.device_name) - .await - .map_err(ApiError::from)?; - web_auth_response(&state, &headers, tokens) -} - -#[utoipa::path( - post, - path = "/api/v2/web/auth/refresh", - tag = "authentication", - responses( - (status = 200, body = WebAuthResponse), - (status = 401, body = ErrorResponse), - (status = 403, body = ErrorResponse) - ) -)] -pub async fn web_refresh( - State(state): State, - headers: HeaderMap, -) -> Result { - validate_web_request(&state, &headers)?; - let refresh_token = cookie_value(&headers, WEB_REFRESH_COOKIE).ok_or(ApiError::Unauthorized)?; - let tokens = state - .auth - .refresh(refresh_token) - .await - .map_err(ApiError::from)?; - web_auth_response(&state, &headers, tokens) -} - -#[utoipa::path( - post, - path = "/api/v2/web/auth/logout", - tag = "authentication", - responses( - (status = 204), - (status = 401, body = ErrorResponse), - (status = 403, body = ErrorResponse) - ) -)] -pub async fn web_logout( - State(state): State, - headers: HeaderMap, -) -> Result { - validate_web_request(&state, &headers)?; - let result = match cookie_value(&headers, WEB_REFRESH_COOKIE) { - Some(refresh_token) => state.auth.revoke_refresh(refresh_token).await, - None => Err(AuthError::InvalidRefreshToken), - }; - let mut response = match result { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(error) => ApiError::from(error).into_response(), - }; - let secure = secure_cookies(&state); - append_cookie( - &mut response, - expired_cookie(WEB_REFRESH_COOKIE, true, secure), - )?; - append_cookie( - &mut response, - expired_cookie(WEB_CSRF_COOKIE, false, secure), - )?; - Ok(response) -} - -#[utoipa::path(post, path = "/api/v2/libraries/{library_id}/scans", tag = "catalog", params(("library_id" = Uuid, Path)), responses((status = 202, body = ScanQueuedResponse), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn start_scan( - State(state): State, - Path(library_id): Path, - headers: HeaderMap, -) -> Result<(StatusCode, Json), ApiError> { - let user = authenticated(&state, &headers, Access::Write).await?; - let scan_id = state - .services - .start_library_scan(user.id, library_id) - .await - .map_err(service_error)?; - Ok((StatusCode::ACCEPTED, Json(ScanQueuedResponse { scan_id }))) -} - -#[utoipa::path(get, path = "/api/v2/libraries", tag = "catalog", responses((status = 200, body = [crate::catalog::LibraryAccess]), (status = 401, body = ErrorResponse)))] -pub async fn list_libraries( - State(state): State, - headers: HeaderMap, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - state - .db - .libraries_for_user(user.id) - .await - .map(Json) - .map_err(db_error) -} - -#[utoipa::path(post, path = "/api/v2/libraries", tag = "administration", request_body = CreateLibraryRequest, responses((status = 201, body = CreateLibraryResponse), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn create_library( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Result<(StatusCode, Json), ApiError> { - let actor = authenticated(&state, &headers, Access::Admin).await?; - let path = std::path::PathBuf::from(&request.path); - let metadata = tokio::fs::symlink_metadata(&path) - .await - .map_err(|_| ApiError::Validation)?; - if metadata.file_type().is_symlink() || !metadata.is_dir() || request.name.trim().is_empty() { - return Err(ApiError::Validation); - } - let canonical = tokio::fs::canonicalize(&path) - .await - .map_err(|_| ApiError::Validation)?; - let library_id = state - .db - .create_library( - actor.id, - &request.name, - &canonical, - request.visibility, - crate::authentication::now_ms(), - ) - .await - .map_err(db_error)?; - let scan_id = state - .scanner - .trigger( - crate::catalog::LibraryRecord { - id: library_id, - name: request.name, - root_path: canonical, - }, - Some(actor.id), - "library_added", - ) - .await - .map_err(|error| { - tracing::error!(error = %error, library_id = %library_id, "initial scan queue failed"); - ApiError::Unavailable - })?; - Ok(( - StatusCode::CREATED, - Json(CreateLibraryResponse { - library_id, - scan_id, - }), - )) -} - -#[utoipa::path(put, path = "/api/v2/libraries/{library_id}/members/{user_id}", tag = "administration", params(("library_id" = Uuid, Path), ("user_id" = Uuid, Path)), request_body = SetLibraryMemberRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn set_library_member( - State(state): State, - Path((library_id, user_id)): Path<(Uuid, Uuid)>, - headers: HeaderMap, - Json(request): Json, -) -> Result { - let actor = authenticated(&state, &headers, Access::Admin).await?; - if request.role == crate::database::LibraryRole::Owner - || state - .db - .account_by_id(user_id) - .await - .map_err(db_error)? - .is_none() - || !state - .db - .all_libraries() - .await - .map_err(db_error)? - .iter() - .any(|library| library.id == library_id) - { - return Err(ApiError::Validation); - } - state - .db - .add_library_member( - actor.id, - library_id, - user_id, - request.role, - crate::authentication::now_ms(), - ) - .await - .map_err(db_error)?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path(delete, path = "/api/v2/libraries/{library_id}/members/{user_id}", tag = "administration", params(("library_id" = Uuid, Path), ("user_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn remove_library_member( - State(state): State, - Path((library_id, user_id)): Path<(Uuid, Uuid)>, - headers: HeaderMap, -) -> Result { - let actor = authenticated(&state, &headers, Access::Admin).await?; - if state - .db - .remove_library_member( - actor.id, - library_id, - user_id, - crate::authentication::now_ms(), - ) - .await - .map_err(db_error)? - { - Ok(StatusCode::NO_CONTENT) - } else { - Err(ApiError::NotFound) - } -} - -#[utoipa::path(get, path = "/api/v2/scans/{scan_id}", tag = "catalog", params(("scan_id" = Uuid, Path)), responses((status = 200, body = crate::catalog::ScanJobRecord), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn scan_status( - State(state): State, - Path(scan_id): Path, - headers: HeaderMap, -) -> Result, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - state - .db - .scan_job_for_user(user.id, scan_id) - .await - .map_err(db_error)? - .map(Json) - .ok_or(ApiError::NotFound) -} - -#[utoipa::path(get, path = "/api/v2/scans/{scan_id}/events", tag = "catalog", params(("scan_id" = Uuid, Path)), responses((status = 200, description = "Server-sent scan progress events", content_type = "text/event-stream"), (status = 404, body = ErrorResponse)))] -pub async fn scan_events( - State(state): State, - Path(scan_id): Path, - headers: HeaderMap, -) -> Result>>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - let initial = state - .db - .scan_job_for_user(user.id, scan_id) - .await - .map_err(db_error)? - .ok_or(ApiError::NotFound)?; - let mut receiver = state.scanner.subscribe(scan_id); - let output = async_stream::stream! { - yield Ok(Event::default().event("snapshot").json_data(initial).expect("scan snapshot serializes")); - if let Some(ref mut receiver) = receiver { - loop { - match receiver.recv().await { - Ok(progress) => yield Ok(Event::default().event("progress").json_data(progress).expect("scan progress serializes")), - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - } - }; - Ok(Sse::new(output).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))) -} - -#[utoipa::path(get, path = "/api/v2/libraries/{library_id}/tracks", tag = "catalog", params(("library_id" = Uuid, Path), ("q" = Option, Query), ("offset" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = [crate::catalog::TrackRecord]), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn list_tracks( - State(state): State, - Path(library_id): Path, - Query(query): Query, - headers: HeaderMap, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - if state - .db - .library_for_user(user.id, library_id) - .await - .map_err(db_error)? - .is_none() - { - return Err(ApiError::NotFound); - } - let offset = query.offset.unwrap_or(0); - let limit = query.limit.unwrap_or(500); - if offset < 0 || !(1..=500).contains(&limit) { - return Err(ApiError::Validation); - } - let query = query.q.as_deref().map(str::trim).filter(|q| !q.is_empty()); - let tracks = state - .db - .browse_tracks_for_user(user.id, library_id, query, offset, limit) - .await - .map_err(db_error)?; - Ok(Json(tracks)) -} - -#[utoipa::path(get, path = "/api/v2/tracks/{track_id}", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, body = crate::services::SongItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn get_track( - State(state): State, - Path(track_id): Path, - headers: HeaderMap, -) -> Result, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - state - .services - .songs_by_ids(user.id, &[track_id]) - .await - .map_err(service_error)? - .into_iter() - .next() - .map(Json) - .ok_or(ApiError::NotFound) -} - -#[utoipa::path(get, path = "/api/v2/tracks/{track_id}/lyrics", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, body = crate::lyrics::LyricsList), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn get_track_lyrics( - State(state): State, - Path(track_id): Path, - headers: HeaderMap, -) -> Result, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - state - .services - .lyrics(user.id, track_id) - .await - .map(Json) - .map_err(service_error) -} - -#[utoipa::path(get, path = "/api/v2/albums", tag = "catalog", params(("library_id" = Option, Query), ("offset" = Option, Query), ("limit" = Option, Query), ("sort" = Option, Query), ("genre" = Option, Query), ("from_year" = Option, Query), ("to_year" = Option, Query)), responses((status = 200, body = [crate::services::AlbumItem]), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn list_albums( - State(state): State, - Query(query): Query, - headers: HeaderMap, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - let order = query - .sort - .as_deref() - .map(crate::services::AlbumOrder::from_str) - .transpose() - .map_err(service_error)? - .unwrap_or_default(); - let request = crate::services::AlbumListQuery { - library_ids: query.library_id.into_iter().collect(), - order, - genre: query.genre, - from_year: query.from_year, - to_year: query.to_year, - page: crate::services::BrowsePage::new(query.offset, query.limit).map_err(service_error)?, - }; - state - .services - .list_albums(user.id, &request) - .await - .map(Json) - .map_err(service_error) -} - -#[utoipa::path(get, path = "/api/v2/genres", tag = "catalog", params(("library_id" = Option, Query)), responses((status = 200, body = [crate::services::GenreItem]), (status = 401, body = ErrorResponse)))] -pub async fn list_genres( - State(state): State, - Query(query): Query, - headers: HeaderMap, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - let libraries = query.library_id.into_iter().collect::>(); - state - .services - .list_genres(user.id, &libraries) - .await - .map(Json) - .map_err(service_error) -} - -#[utoipa::path(get, path = "/api/v2/albums/{album_id}", tag = "catalog", params(("album_id" = Uuid, Path)), responses((status = 200, body = crate::services::AlbumDetail), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn get_album( - State(state): State, - Path(album_id): Path, - headers: HeaderMap, -) -> Result, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - state - .services - .album(user.id, album_id) - .await - .map(Json) - .map_err(service_error) -} - -#[utoipa::path(get, path = "/api/v2/artists", tag = "catalog", params(("library_id" = Option, Query), ("offset" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = [crate::services::ArtistSummary]), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn list_artists( - State(state): State, - Query(query): Query, - headers: HeaderMap, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - let page = - crate::services::BrowsePage::new(query.offset, query.limit).map_err(service_error)?; - state - .services - .list_artists(user.id, query.library_id, page) - .await - .map(Json) - .map_err(service_error) -} - -#[utoipa::path(get, path = "/api/v2/artists/{artist_id}", tag = "catalog", params(("artist_id" = Uuid, Path)), responses((status = 200, body = crate::services::ArtistDetail), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn get_artist( - State(state): State, - Path(artist_id): Path, - headers: HeaderMap, -) -> Result, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - state - .services - .artist(user.id, artist_id) - .await - .map(Json) - .map_err(service_error) -} - -#[utoipa::path(get, path = "/api/v2/search", tag = "catalog", params(("q" = String, Query), ("offset" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = crate::services::SearchResult), (status = 400, description = "q is required"), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn search_catalog( - State(state): State, - Query(query): Query, - headers: HeaderMap, -) -> Result, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - // One offset for all three kinds unless the caller names one, which is - // what `search3` has always allowed and what a client paging songs past - // the end of the artists needs. - let page = |offset: Option| { - crate::services::BrowsePage::new(offset.or(query.offset), query.limit) - .map_err(service_error) - }; - state - .services - .search( - user.id, - &query.q, - page(query.artist_offset)?, - page(query.album_offset)?, - page(query.song_offset)?, - ) - .await - .map(Json) - .map_err(service_error) -} - -/// The native form of `getRandomSongs`. -/// -/// The selection is drawn in SQL, so a request for ten reads ten. `genre` -/// matches the canonical name, like every other genre filter on either -/// surface, and a reversed year range is read as a range rather than as an -/// empty one. -#[utoipa::path(get, path = "/api/v2/songs/random", tag = "catalog", params(("library_id" = Option, Query), ("genre" = Option, Query), ("from_year" = Option, Query), ("to_year" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = [crate::services::SongItem]), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn list_random_songs( - State(state): State, - Query(query): Query, - headers: HeaderMap, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - state - .services - .random_songs( - user.id, - query.library_id.as_slice(), - query.genre.as_deref(), - query.from_year, - query.to_year, - query.limit.unwrap_or(10), - ) - .await - .map(Json) - .map_err(service_error) -} - -/// The native form of `getSongsByGenre`. `genre` is required: answering an -/// unfiltered catalogue would drop the filter in silence. -#[utoipa::path(get, path = "/api/v2/songs", tag = "catalog", params(("genre" = String, Query), ("library_id" = Option, Query), ("offset" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = [crate::services::SongItem]), (status = 400, description = "genre is required"), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn list_songs_by_genre( - State(state): State, - Query(query): Query, - headers: HeaderMap, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - let page = - crate::services::BrowsePage::new(query.offset, query.limit).map_err(service_error)?; - state - .services - .songs_by_genre(user.id, query.library_id.as_slice(), &query.genre, page) - .await - .map(Json) - .map_err(service_error) -} - -#[utoipa::path(post, path = "/api/v2/oauth/authorize", tag = "authentication", request_body = AuthorizeRequest, responses((status = 200, body = AuthorizeResponse), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn oauth_authorize( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Result, ApiError> { - // The browser session is the proof of identity; the consent screen is a - // route of the embedded client, so this is a JSON call rather than a form. - // - // A write, because pairing a device is a mutation on the account and - // nothing more. What used to make this Unrestricted — that the session it - // minted carried the account's whole authority whatever asked for it — is - // gone: the caller's scopes are recorded on the grant just below and the - // redeemed session is issued under them, so this cannot widen a credential. - let user = authenticated(&state, &headers, Access::Write).await?; - let redirect_to = state - .services - .authorize_native_client( - user.id, - crate::services::AuthorizationRequest { - client_id: &request.client_id, - redirect_uri: &request.redirect_uri, - code_challenge: &request.code_challenge, - code_challenge_method: &request.code_challenge_method, - device_name: &request.device_name, - state: request.state.as_deref(), - scopes: &user.scopes, - }, - ) - .await - .map_err(service_error)?; - Ok(Json(AuthorizeResponse { redirect_to })) -} - -#[utoipa::path(post, path = "/api/v2/oauth/token", tag = "authentication", request_body = TokenRequest, responses((status = 200, body = crate::authentication::AuthTokens), (status = 401, body = ErrorResponse), (status = 503, body = ErrorResponse)))] -pub async fn oauth_token( - State(state): State, - Json(request): Json, -) -> Result, ApiError> { - // Mounted without authentication by design: the code plus the verifier are - // the credential. Every rejection below is the same 401 so a caller cannot - // learn whether a code existed, expired, or was already spent. - let now = crate::authentication::now_ms(); - let grant = state - .db - .redeem_authorization(&crate::security::token_hash(&request.code), now) - .await - .map_err(db_error)? - .ok_or(ApiError::Unauthorized)?; - if grant.client_id != request.client_id.trim() - || grant.redirect_uri != request.redirect_uri - || crate::oauth::verify_challenge(&grant.code_challenge, &request.code_verifier).is_err() - { - return Err(ApiError::Unauthorized); - } - state - .auth - .issue_session_for_account(grant.user_id, &grant.device_name, &grant.scopes) - .await - .map(Json) - .map_err(|error| match error { - crate::authentication::AuthError::Unavailable => ApiError::Unavailable, - _ => ApiError::Unauthorized, - }) -} - -#[utoipa::path(get, path = "/api/v2/playlists", tag = "user-data", responses((status = 200, body = [crate::services::PlaylistItem]), (status = 401, body = ErrorResponse)))] -pub async fn list_playlists( - State(state): State, - headers: HeaderMap, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - state - .services - .playlists(user.id) - .await - .map(Json) - .map_err(service_error) -} - -#[utoipa::path(post, path = "/api/v2/playlists", tag = "user-data", request_body = CreatePlaylistRequest, responses((status = 201, body = crate::services::PlaylistItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn create_playlist( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Result<(StatusCode, Json), ApiError> { - let user = authenticated(&state, &headers, Access::Write).await?; - let context = mutation_context(&state, &headers, user.id).await?; - let playlist = state - .services - .create_playlist_with_context(user.id, &request.name, &request.track_ids, context) - .await - .map_err(service_error)?; - Ok((StatusCode::CREATED, Json(playlist))) -} - -#[utoipa::path(get, path = "/api/v2/playlists/{playlist_id}", tag = "user-data", params(("playlist_id" = Uuid, Path)), responses((status = 200, body = crate::services::PlaylistItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn get_playlist( - State(state): State, - Path(playlist_id): Path, - headers: HeaderMap, -) -> Result, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - state - .services - .playlist(user.id, playlist_id) - .await - .map(Json) - .map_err(service_error) -} - -/// An unknown name is refused rather than ignored: a client asking to clear -/// `expiresAt` instead of `expires_at` would otherwise be told it succeeded -/// while the field stayed put. -fn playlist_clear(names: &[String]) -> Result { - let mut clear = crate::services::PlaylistClear::default(); - for name in names { - match name.as_str() { - "comment" => clear.comment = true, - _ => return Err(ApiError::Validation), - } - } - Ok(clear) -} - -/// See [`playlist_clear`]. -fn share_clear(names: &[String]) -> Result { - let mut clear = crate::services::ShareClear::default(); - for name in names { - match name.as_str() { - "description" => clear.description = true, - "expires_at" => clear.expires_at = true, - _ => return Err(ApiError::Validation), - } - } - Ok(clear) -} - -#[utoipa::path(patch, path = "/api/v2/playlists/{playlist_id}", tag = "user-data", params(("playlist_id" = Uuid, Path)), request_body = UpdatePlaylistRequest, responses((status = 200, body = crate::services::PlaylistItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 409, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn update_playlist( - State(state): State, - Path(playlist_id): Path, - headers: HeaderMap, - Json(request): Json, -) -> Result, ApiError> { - let user = authenticated(&state, &headers, Access::Write).await?; - let context = mutation_context(&state, &headers, user.id).await?; - state - .services - .update_playlist_with_context( - user.id, - playlist_id, - request.name.as_deref(), - request.comment.as_deref(), - request.public, - &request.add, - &request.remove_indexes, - playlist_clear(&request.clear)?, - context, - ) - .await - .map(Json) - .map_err(service_error) -} - -#[utoipa::path(delete, path = "/api/v2/playlists/{playlist_id}", tag = "user-data", params(("playlist_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn delete_playlist( - State(state): State, - Path(playlist_id): Path, - headers: HeaderMap, -) -> Result { - let user = authenticated(&state, &headers, Access::Write).await?; - let context = mutation_context(&state, &headers, user.id).await?; - state - .services - .delete_playlist_with_context(user.id, playlist_id, context) - .await - .map_err(service_error)?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path(get, path = "/api/v2/favorites", tag = "user-data", responses((status = 200, body = [StarredEntry]), (status = 401, body = ErrorResponse)))] -pub async fn list_favorites( - State(state): State, - headers: HeaderMap, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - let entries = state - .services - .starred_ids(user.id) - .await - .map_err(service_error)? - .into_iter() - .map(|(entity_type, entity_id, starred_at)| StarredEntry { - entity_type, - entity_id, - starred_at, - }) - .collect(); - Ok(Json(entries)) -} - -#[utoipa::path(put, path = "/api/v2/favorites/{entity_type}/{entity_id}", tag = "user-data", params(("entity_type" = String, Path, description = "track, album or artist"), ("entity_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn add_favorite( - State(state): State, - Path((entity_type, entity_id)): Path<(String, Uuid)>, - headers: HeaderMap, -) -> Result { - set_favorite(state, headers, &entity_type, entity_id, true).await -} - -#[utoipa::path(delete, path = "/api/v2/favorites/{entity_type}/{entity_id}", tag = "user-data", params(("entity_type" = String, Path, description = "track, album or artist"), ("entity_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn remove_favorite( - State(state): State, - Path((entity_type, entity_id)): Path<(String, Uuid)>, - headers: HeaderMap, -) -> Result { - set_favorite(state, headers, &entity_type, entity_id, false).await -} - -async fn set_favorite( - state: AppState, - headers: HeaderMap, - entity_type: &str, - entity_id: Uuid, - starred: bool, -) -> Result { - let user = authenticated(&state, &headers, Access::Write).await?; - let context = mutation_context(&state, &headers, user.id).await?; - state - .services - .set_star_with_context(user.id, entity_type, entity_id, starred, context) - .await - .map_err(service_error)?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path(put, path = "/api/v2/ratings/{entity_type}/{entity_id}", tag = "user-data", params(("entity_type" = String, Path, description = "track, album or artist"), ("entity_id" = Uuid, Path)), request_body = RatingRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn set_rating( - State(state): State, - Path((entity_type, entity_id)): Path<(String, Uuid)>, - headers: HeaderMap, - Json(request): Json, -) -> Result { - let user = authenticated(&state, &headers, Access::Write).await?; - let context = mutation_context(&state, &headers, user.id).await?; - state - .services - .set_rating_with_context(user.id, &entity_type, entity_id, request.rating, context) - .await - .map_err(service_error)?; - Ok(StatusCode::NO_CONTENT) -} - -/// A playback position to store on a track. -#[derive(Debug, Deserialize, ToSchema)] -pub struct BookmarkRequest { - /// Milliseconds from the start of the file. Negative positions are refused. - pub position_ms: i64, - /// Free text. Omitting it clears whatever comment the bookmark carried, - /// because a bookmark is replaced rather than patched. - #[serde(default)] - pub comment: Option, -} - -#[utoipa::path(get, path = "/api/v2/bookmarks", tag = "user-data", responses((status = 200, body = [crate::services::BookmarkItem]), (status = 401, body = ErrorResponse)))] -pub async fn list_bookmarks( - State(state): State, - headers: HeaderMap, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - state - .services - .bookmarks(user.id) - .await - .map(Json) - .map_err(service_error) -} - -/// One bookmark per account and track, so this replaces rather than adds. -/// -/// `PUT` and not `POST` for that reason: the track names the resource, and -/// sending the same position twice leaves the same single bookmark. Backed by -/// the same `DomainServices` method as the Subsonic `createBookmark`, so the -/// two surfaces cannot disagree about what a second call does. -#[utoipa::path(put, path = "/api/v2/bookmarks/{track_id}", tag = "user-data", params(("track_id" = Uuid, Path)), request_body = BookmarkRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn set_bookmark( - State(state): State, - Path(track_id): Path, - headers: HeaderMap, - Json(request): Json, -) -> Result { - let user = authenticated(&state, &headers, Access::Write).await?; - let context = mutation_context(&state, &headers, user.id).await?; - state - .services - .set_bookmark_with_context( - user.id, - track_id, - request.position_ms, - request.comment.as_deref(), - context, - ) - .await - .map_err(service_error)?; - Ok(StatusCode::NO_CONTENT) -} - -/// Deleting a bookmark that is not there succeeds: the caller asked for the -/// track to carry none, and it does not. It also avoids answering a question -/// about a track the account cannot reach. -#[utoipa::path(delete, path = "/api/v2/bookmarks/{track_id}", tag = "user-data", params(("track_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn delete_bookmark( - State(state): State, - Path(track_id): Path, - headers: HeaderMap, -) -> Result { - let user = authenticated(&state, &headers, Access::Write).await?; - let context = mutation_context(&state, &headers, user.id).await?; - state - .services - .delete_bookmark_with_context(user.id, track_id, context) - .await - .map_err(service_error)?; - Ok(StatusCode::NO_CONTENT) -} - -/// A token to issue. -#[derive(Debug, Deserialize, ToSchema)] -pub struct CreateApiTokenRequest { - /// What the token is for. Shown in the listing so a stale one can be told - /// apart from a live one before it is revoked. - pub name: String, - #[serde(default)] - pub scopes: Vec, -} - -/// An issued token. The secret appears here and nowhere else, ever again. -#[derive(Debug, Serialize, ToSchema)] -pub struct CreateApiTokenResponse { - #[serde(flatten)] - pub token: crate::database::ApiTokenRecord, - /// Shown once. Only its SHA-256 hash is stored, so it cannot be recovered. - pub secret: String, -} - -#[utoipa::path(get, path = "/api/v2/admin/users/{username}/tokens", tag = "administration", params(("username" = String, Path)), responses((status = 200, body = [crate::database::ApiTokenRecord]), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn list_api_tokens( - State(state): State, - Path(username): Path, - headers: HeaderMap, -) -> Result>, ApiError> { - let actor = authenticated(&state, &headers, Access::Admin).await?; - state - .services - .api_tokens(actor.id, &username) - .await - .map(Json) - .map_err(service_error) -} - -/// Issues an API token without a shell on the host. -/// -/// The `token create` CLI command remains, for bootstrapping an instance that -/// has no administrator session yet; from here on the two share -/// `DomainServices::create_api_token`, so a token minted either way carries the -/// same scopes and the same audit trail. -#[utoipa::path(post, path = "/api/v2/admin/users/{username}/tokens", tag = "administration", params(("username" = String, Path)), request_body = CreateApiTokenRequest, responses((status = 201, body = CreateApiTokenResponse), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn create_api_token( - State(state): State, - Path(username): Path, - headers: HeaderMap, - Json(request): Json, -) -> Result<(StatusCode, Json), ApiError> { - let actor = authenticated(&state, &headers, Access::Admin).await?; - let (token, secret) = state - .services - .create_api_token(actor.id, &username, &request.name, &request.scopes) - .await - .map_err(service_error)?; - Ok(( - StatusCode::CREATED, - Json(CreateApiTokenResponse { token, secret }), - )) -} - -#[utoipa::path(delete, path = "/api/v2/admin/users/{username}/tokens/{token_id}", tag = "administration", params(("username" = String, Path), ("token_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn revoke_api_token( - State(state): State, - Path((username, token_id)): Path<(String, Uuid)>, - headers: HeaderMap, -) -> Result { - let actor = authenticated(&state, &headers, Access::Admin).await?; - state - .services - .revoke_api_token(actor.id, &username, token_id) - .await - .map_err(service_error)?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path(get, path = "/api/v2/ratings", tag = "user-data", responses((status = 200, body = [crate::services::RatingItem]), (status = 401, body = ErrorResponse)))] -pub async fn list_ratings( - State(state): State, - headers: HeaderMap, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - state - .services - .ratings(user.id) - .await - .map(Json) - .map_err(service_error) -} - -#[utoipa::path(post, path = "/api/v2/scrobbles", tag = "user-data", request_body = ScrobbleRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn create_scrobble( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Result { - let user = authenticated(&state, &headers, Access::Write).await?; - let context = mutation_context(&state, &headers, user.id).await?; - state - .services - .scrobble_with_context( - user.id, - request.track_id, - request.submission, - request.played_at, - context, - ) - .await - .map_err(service_error)?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path(get, path = "/api/v2/history", tag = "user-data", params(("limit" = Option, Query)), responses((status = 200, body = [crate::services::HistoryItem]), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn list_history( - State(state): State, - headers: HeaderMap, - Query(query): Query, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - let limit = query.limit.unwrap_or(200); - if !(1..=crate::sync::MAX_SYNC_LIMIT).contains(&limit) { - return Err(ApiError::Validation); - } - state - .services - .history(user.id, limit) - .await - .map(Json) - .map_err(service_error) -} - -#[utoipa::path(get, path = "/api/v2/transcode/status", tag = "catalog", responses((status = 200, body = TranscodeStatusResponse), (status = 401, body = ErrorResponse)))] -pub async fn transcode_status( - State(state): State, - headers: HeaderMap, -) -> Result, ApiError> { - authenticated(&state, &headers, Access::Read).await?; - Ok(Json(TranscodeStatusResponse { - available: state.media.transcoding_available(), - active: state.media.active_transcodes(), - })) -} - -#[utoipa::path(get, path = "/api/v2/admin/users", tag = "administration", responses((status = 200, body = [crate::services::UserItem]), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse)))] -pub async fn list_users( - State(state): State, - headers: HeaderMap, -) -> Result>, ApiError> { - let actor = authenticated(&state, &headers, Access::Admin).await?; - state - .services - .users(actor.id) - .await - .map(Json) - .map_err(service_error) -} - -#[utoipa::path(post, path = "/api/v2/admin/users", tag = "administration", request_body = CreateUserRequest, responses((status = 201, body = crate::services::UserItem), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn create_user( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Result<(StatusCode, Json), ApiError> { - let actor = authenticated(&state, &headers, Access::Admin).await?; - let user = state - .services - .create_web_user( - actor.id, - &request.username, - &request.web_password, - request.role, - ) - .await - .map_err(service_error)?; - Ok((StatusCode::CREATED, Json(user))) -} - -#[utoipa::path(patch, path = "/api/v2/admin/users/{username}", tag = "administration", params(("username" = String, Path)), request_body = UpdateUserRequest, responses((status = 200, body = crate::services::UserItem), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn update_user( - State(state): State, - Path(username): Path, - headers: HeaderMap, - Json(request): Json, -) -> Result, ApiError> { - let actor = authenticated(&state, &headers, Access::Admin).await?; - state - .services - .update_user( - actor.id, - &username, - crate::services::UserUpdate { - admin: request - .role - .map(|role| role == crate::database::AccountRole::Admin), - disabled: request.disabled, - folder_ids: request.library_ids.as_deref(), - subsonic_password: request.subsonic_password.as_deref(), - web_password: request.web_password.as_deref(), - }, - ) - .await - .map(Json) - .map_err(service_error) -} - -#[utoipa::path(delete, path = "/api/v2/admin/users/{username}", tag = "administration", params(("username" = String, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn delete_user( - State(state): State, - Path(username): Path, - headers: HeaderMap, -) -> Result { - let actor = authenticated(&state, &headers, Access::Admin).await?; - state - .services - .delete_user(actor.id, &username) - .await - .map_err(service_error)?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path(put, path = "/api/v2/admin/users/{username}/subsonic-credential", tag = "administration", params(("username" = String, Path)), request_body = SetSubsonicCredentialRequest, responses((status = 200, body = SubsonicCredentialResponse), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn set_subsonic_credential( - State(state): State, - Path(username): Path, - headers: HeaderMap, - Json(request): Json, -) -> Result, ApiError> { - let actor = authenticated(&state, &headers, Access::Admin).await?; - let api_key = state - .services - .set_subsonic_credential(actor.id, &username, &request.password) - .await - .map_err(service_error)?; - Ok(Json(SubsonicCredentialResponse { api_key })) -} - -#[utoipa::path(delete, path = "/api/v2/admin/users/{username}/subsonic-credential", tag = "administration", params(("username" = String, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn revoke_subsonic_credential( - State(state): State, - Path(username): Path, - headers: HeaderMap, -) -> Result { - let actor = authenticated(&state, &headers, Access::Admin).await?; - state - .services - .revoke_subsonic_credential(actor.id, &username) - .await - .map_err(service_error)?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path(get, path = "/api/v2/now-playing", tag = "user-data", responses((status = 200, body = [NowPlayingEntry]), (status = 401, body = ErrorResponse)))] -pub async fn list_now_playing( - State(state): State, - headers: HeaderMap, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - let entries = state - .services - .now_playing(user.id) - .await - .map_err(service_error)? - .into_iter() - .map(|(username, song, started_at)| NowPlayingEntry { - username, - song, - started_at, - }) - .collect(); - Ok(Json(entries)) -} - -#[utoipa::path(get, path = "/api/v2/queue", tag = "user-data", responses((status = 200, body = Option), (status = 401, body = ErrorResponse)))] -pub async fn get_queue( - State(state): State, - headers: HeaderMap, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - state - .services - .queue(user.id) - .await - .map(Json) - .map_err(service_error) -} - -#[utoipa::path(put, path = "/api/v2/queue", tag = "user-data", request_body = SaveQueueRequest, responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn save_queue( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Result { - let user = authenticated(&state, &headers, Access::Write).await?; - let context = mutation_context(&state, &headers, user.id).await?; - state - .services - .save_queue_with_context( - user.id, - &request.track_ids, - request.current, - request.position_ms, - request.client.as_deref(), - context, - ) - .await - .map_err(service_error)?; - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path(get, path = "/api/v2/shares", tag = "user-data", responses((status = 200, body = [ShareResponse]), (status = 401, body = ErrorResponse)))] -pub async fn list_shares( - State(state): State, - headers: HeaderMap, -) -> Result>, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - let shares = state - .services - .shares(user.id) - .await - .map_err(service_error)? - .into_iter() - .map(|share| share_response(&state, share)) - .collect(); - Ok(Json(shares)) -} - -#[utoipa::path(post, path = "/api/v2/shares", tag = "user-data", request_body = CreateShareRequest, responses((status = 201, body = ShareResponse), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] -pub async fn create_share( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Result<(StatusCode, Json), ApiError> { - let user = authenticated(&state, &headers, Access::Write).await?; - let context = mutation_context(&state, &headers, user.id).await?; - let share = state - .services - .create_share_with_context( - user.id, - &request.track_ids, - request.description.as_deref(), - request.expires_at, - context, - ) - .await - .map_err(service_error)?; - Ok((StatusCode::CREATED, Json(share_response(&state, share)))) -} - -#[utoipa::path(patch, path = "/api/v2/shares/{share_id}", tag = "user-data", params(("share_id" = Uuid, Path)), request_body = UpdateShareRequest, responses((status = 200, body = ShareResponse), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn update_share( - State(state): State, - Path(share_id): Path, - headers: HeaderMap, - Json(request): Json, -) -> Result, ApiError> { - let user = authenticated(&state, &headers, Access::Write).await?; - let context = mutation_context(&state, &headers, user.id).await?; - let share = state - .services - .update_share_with_context( - user.id, - share_id, - request.description.as_deref(), - request.expires_at, - share_clear(&request.clear)?, - context, - ) - .await - .map_err(service_error)?; - Ok(Json(share_response(&state, share))) -} - -#[utoipa::path(delete, path = "/api/v2/shares/{share_id}", tag = "user-data", params(("share_id" = Uuid, Path)), responses((status = 204), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] -pub async fn delete_share( - State(state): State, - Path(share_id): Path, - headers: HeaderMap, -) -> Result { - let user = authenticated(&state, &headers, Access::Write).await?; - let context = mutation_context(&state, &headers, user.id).await?; - state - .services - .delete_share_with_context(user.id, share_id, context) - .await - .map_err(service_error)?; - Ok(StatusCode::NO_CONTENT) -} - -fn share_response(state: &AppState, share: crate::services::ShareItem) -> ShareResponse { - let url = share.url_token.map(|token| { - let path = format!("/share/{token}"); - state - .public_url - .as_ref() - .map_or_else(|| path.clone(), |base| format!("{base}{path}")) - }); - ShareResponse { - id: share.id, - url, - description: share.description, - expires_at: share.expires_at, - created_at: share.created_at, - visit_count: share.visit_count, - track_ids: share.songs.into_iter().map(|song| song.id).collect(), - } -} - -#[utoipa::path( - get, - path = "/api/v2/sync/changes", - tag = "sync", - params(("after" = Option, Query), ("limit" = Option, Query)), - responses( - (status = 200, body = crate::sync::SyncPage), - (status = 401, body = ErrorResponse), - ( - status = 409, - description = "`code` is `cursor_expired`: the cursor precedes the oldest \ - retained event, so the gap cannot be replayed. Discard the local \ - projection, take a fresh /sync/snapshot and resume from its \ - cursor. Distinct from `conflict`, which is about operation ids.", - body = ErrorResponse - ), - (status = 422, body = ErrorResponse) - ) -)] -pub async fn sync_changes( - State(state): State, - headers: HeaderMap, - Query(query): Query, -) -> Result, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - let after = query.after.unwrap_or(0); - let limit = query.limit.unwrap_or(crate::sync::DEFAULT_SYNC_LIMIT); - if after < 0 || limit <= 0 || limit > crate::sync::MAX_SYNC_LIMIT { - return Err(ApiError::Validation); - } - state - .sync - .changes(user.id, after, limit) - .await - .map(Json) - .map_err(sync_error) -} - -#[utoipa::path( - get, - path = "/api/v2/sync/snapshot", - tag = "sync", - responses((status = 200, body = SyncSnapshot), (status = 401, body = ErrorResponse)) -)] -pub async fn sync_snapshot( - State(state): State, - headers: HeaderMap, -) -> Result, ApiError> { - let user = authenticated(&state, &headers, Access::Read).await?; - let snapshot = state - .services - .sync_snapshot(user.id, crate::sync::MAX_SYNC_LIMIT) - .await - .map_err(service_error)?; - let favorites = snapshot - .favorites - .into_iter() - .map(|(entity_type, entity_id, starred_at)| StarredEntry { - entity_type, - entity_id, - starred_at, - }) - .collect(); - let shares = snapshot - .shares - .into_iter() - .map(|share| share_response(&state, share)) - .collect(); - Ok(Json(SyncSnapshot { - cursor: snapshot.cursor, - playlists: snapshot.playlists, - favorites, - ratings: snapshot.ratings, - queue: snapshot.queue, - history: snapshot.history, - shares, - bookmarks: snapshot.bookmarks, - })) -} - -#[utoipa::path( - put, - path = "/api/v2/sync/ack", - tag = "sync", - request_body = SyncAckRequest, - responses( - (status = 204), - (status = 401, body = ErrorResponse), - (status = 422, body = ErrorResponse) - ) -)] -pub async fn sync_ack( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Result { - let user = authenticated(&state, &headers, Access::Write).await?; - let acknowledged = state - .sync - .acknowledge(user.id, request.device_id, request.cursor) - .await - .map_err(db_error)?; - if !acknowledged { - return Err(ApiError::Validation); - } - Ok(StatusCode::NO_CONTENT) -} - -/// The socket is an edge-triggered wake-up channel. A client always follows a -/// notice with `GET /sync/changes`; the durable cursor, not socket delivery, is -/// the synchronization guarantee. -#[utoipa::path( - get, - path = "/api/v2/sync/socket", - tag = "sync", - params(("after" = Option, Query)), - responses( - (status = 101, description = "WebSocket cursor notifications"), - (status = 401, body = ErrorResponse), - (status = 422, body = ErrorResponse) - ) -)] -pub async fn sync_socket( - State(state): State, - headers: HeaderMap, - Query(query): Query, - upgrade: WebSocketUpgrade, -) -> Result { - let user = authenticated(&state, &headers, Access::Read).await?; - let after = query.after.unwrap_or(0); - if after < 0 { - return Err(ApiError::Validation); - } - Ok(upgrade - .on_upgrade(move |socket| serve_sync_socket(socket, state, user.id, after)) - .into_response()) -} - -async fn serve_sync_socket(socket: WebSocket, state: AppState, user_id: Uuid, after: i64) { - let (mut sender, mut receiver) = socket.split(); - let mut notices = state.sync.subscribe(); - if let Ok(cursor) = state.sync.latest_user_cursor(user_id).await { - if cursor > after && send_sync_notice(&mut sender, cursor).await.is_err() { - return; - } - } - let mut heartbeat = tokio::time::interval(Duration::from_secs(30)); - heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - heartbeat.tick().await; - let mut awaiting_pong = false; - loop { - tokio::select! { - incoming = receiver.next() => match incoming { - Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, - Some(Ok(Message::Pong(_))) => awaiting_pong = false, - Some(Ok(Message::Ping(payload))) => { - if sender.send(Message::Pong(payload)).await.is_err() { - break; - } - } - Some(Ok(_)) => {} - }, - notice = notices.recv() => match sync_notice_action(&state.sync, user_id, notice).await { - Ok(SyncNoticeAction::Send(cursor)) => { - if send_sync_notice(&mut sender, cursor).await.is_err() { - break; - } - } - Ok(SyncNoticeAction::Continue) => {} - Ok(SyncNoticeAction::Close) | Err(_) => break, - }, - _ = heartbeat.tick() => { - if awaiting_pong || sender.send(Message::Ping(Vec::new().into())).await.is_err() { - break; - } - awaiting_pong = true; - } - } - } -} - -#[derive(Debug, PartialEq, Eq)] -enum SyncNoticeAction { - Send(i64), - Continue, - Close, -} - -async fn sync_notice_action( - sync: &crate::sync::SyncService, - user_id: Uuid, - notice: Result<(Uuid, crate::sync::SyncNotice), tokio::sync::broadcast::error::RecvError>, -) -> Result { - match notice { - Ok((notice_user, notice)) if notice_user == user_id => { - Ok(SyncNoticeAction::Send(notice.cursor)) - } - Ok(_) => Ok(SyncNoticeAction::Continue), - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => sync - .latest_user_cursor(user_id) - .await - .map(SyncNoticeAction::Send), - Err(tokio::sync::broadcast::error::RecvError::Closed) => Ok(SyncNoticeAction::Close), - } -} - -async fn send_sync_notice( - sender: &mut futures_util::stream::SplitSink, - cursor: i64, -) -> Result<(), axum::Error> { - let body = - serde_json::to_string(&crate::sync::SyncNotice { cursor }).expect("sync notice serializes"); - sender.send(Message::Text(body.into())).await -} - -#[derive(Debug)] -pub enum ApiError { - Unauthorized, - Forbidden, - Validation, - /// The request is well formed but collides with existing state: an - /// operation id replayed with a different payload, or a name already taken. - /// Distinct from `Validation` so a client can tell "my request is malformed" - /// from "my retry is inconsistent" — both permanent, different fixes. - Conflict, - /// The sync cursor precedes the oldest retained event. Same 409 status as - /// `Conflict` but a distinct code, because the reactions are opposite: - /// a conflict means mint a new operation id, this one means discard the - /// local projection and take a fresh snapshot. - CursorExpired, - Unavailable, - NotFound, -} - -impl From for ApiError { - fn from(value: AuthError) -> Self { - match value { - AuthError::InvalidCredentials | AuthError::InvalidRefreshToken => Self::Unauthorized, - AuthError::InvalidDeviceName => Self::Validation, - AuthError::Unavailable => Self::Unavailable, - } - } -} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - let (status, code, message) = match self { - Self::Unauthorized => ( - StatusCode::UNAUTHORIZED, - "unauthorized", - "Authentication failed", - ), - Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden", "Request rejected"), - Self::Validation => ( - StatusCode::UNPROCESSABLE_ENTITY, - "validation_error", - "The request is invalid", - ), - Self::Conflict => ( - StatusCode::CONFLICT, - "conflict", - "The request conflicts with existing state", - ), - Self::CursorExpired => ( - StatusCode::CONFLICT, - "cursor_expired", - "The cursor precedes the oldest retained event; take a fresh snapshot", - ), - Self::Unavailable => ( - StatusCode::SERVICE_UNAVAILABLE, - "service_unavailable", - "Authentication is temporarily unavailable", - ), - Self::NotFound => (StatusCode::NOT_FOUND, "not_found", "Resource not found"), - }; - (status, Json(ErrorResponse { code, message })).into_response() - } -} - -fn web_auth_response( - state: &AppState, - _headers: &HeaderMap, - tokens: crate::authentication::AuthTokens, -) -> Result { - let csrf_token = crate::security::generate_token("wfcsrf_"); - let secure = secure_cookies(state); - let refresh_cookie = format!( - "{WEB_REFRESH_COOKIE}={}; Path=/api/v2/web/auth; HttpOnly; SameSite=Strict; Max-Age={}{}", - tokens.refresh_token, - state.refresh_token_ttl.as_secs(), - if secure { "; Secure" } else { "" } - ); - let csrf_cookie = format!( - "{WEB_CSRF_COOKIE}={csrf_token}; Path=/; SameSite=Strict; Max-Age={}{}", - state.refresh_token_ttl.as_secs(), - if secure { "; Secure" } else { "" } - ); - let body = WebAuthResponse { - access_token: tokens.access_token, - token_type: tokens.token_type, - expires_in: tokens.expires_in, - user: tokens.user, - device_id: tokens.device_id, - }; - let mut response = Json(body).into_response(); - append_cookie(&mut response, refresh_cookie)?; - append_cookie(&mut response, csrf_cookie)?; - Ok(response) -} - -fn append_cookie(response: &mut Response, value: String) -> Result<(), ApiError> { - let value = HeaderValue::from_str(&value).map_err(|_| ApiError::Unavailable)?; - response.headers_mut().append(header::SET_COOKIE, value); - Ok(()) -} - -fn expired_cookie(name: &str, http_only: bool, secure: bool) -> String { - format!( - "{name}=; Path={}; SameSite=Strict; Max-Age=0{}{}", - if http_only { "/api/v2/web/auth" } else { "/" }, - if http_only { "; HttpOnly" } else { "" }, - if secure { "; Secure" } else { "" } - ) -} - -fn secure_cookies(state: &AppState) -> bool { - public_url_is_https(state.public_url.as_deref()) -} - -fn public_url_is_https(public_url: Option<&str>) -> bool { - public_url - .and_then(|url| url::Url::parse(url).ok()) - .is_some_and(|url| url.scheme() == "https") -} - -fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { - headers - .get_all(header::COOKIE) - .iter() - .filter_map(|value| value.to_str().ok()) - .flat_map(|value| value.split(';')) - .filter_map(|pair| pair.trim().split_once('=')) - .find_map(|(key, value)| (key == name && !value.is_empty()).then_some(value)) -} - -fn validate_web_request(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> { - validate_web_origin(state, headers)?; - let cookie = cookie_value(headers, WEB_CSRF_COOKIE).ok_or(ApiError::Forbidden)?; - let supplied = headers - .get(WEB_CSRF_HEADER) - .and_then(|value| value.to_str().ok()) - .ok_or(ApiError::Forbidden)?; - if !crate::security::constant_time_bytes_eq(cookie.as_bytes(), supplied.as_bytes()) { - return Err(ApiError::Forbidden); - } - Ok(()) -} - -fn validate_web_origin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> { - let origin = headers - .get(header::ORIGIN) - .and_then(|value| value.to_str().ok()) - .ok_or(ApiError::Forbidden)?; - let parsed = url::Url::parse(origin).map_err(|_| ApiError::Forbidden)?; - if !matches!(parsed.scheme(), "http" | "https") - || parsed.path() != "/" - || parsed.query().is_some() - || parsed.fragment().is_some() - { - return Err(ApiError::Forbidden); - } - if let Some(public_url) = state.public_url.as_deref() { - let expected = url::Url::parse(public_url).map_err(|_| ApiError::Unavailable)?; - return if parsed.origin() == expected.origin() { - Ok(()) - } else { - Err(ApiError::Forbidden) - }; - } - let authority = &parsed[url::Position::BeforeHost..url::Position::AfterPort]; - let host = headers - .get(header::HOST) - .and_then(|value| value.to_str().ok()) - .ok_or(ApiError::Forbidden)?; - if authority.eq_ignore_ascii_case(host) { - Ok(()) - } else { - Err(ApiError::Forbidden) - } -} - -fn bearer_token(headers: &HeaderMap) -> Option<&str> { - headers - .get(header::AUTHORIZATION)? - .to_str() - .ok()? - .strip_prefix("Bearer ") - .filter(|token| !token.is_empty()) -} - -/// What a route needs of the credential it was called with. -/// -/// Chosen at every call of [`authenticated`], which is the only way into a -/// route, so a new route cannot be written without deciding: the compiler asks -/// the question. That is the whole reason this is a parameter rather than a -/// second helper a handler may forget to call — which is exactly what happened -/// to the scope list, stored since the foundations and read by nothing. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum Access { - /// Reads the caller's own catalogue and user data. - Read, - /// Writes on the caller's behalf: playlists, favorites, ratings, the - /// queue, bookmarks, shares, scrobbles and scans. - Write, - /// Acts on the instance: accounts, libraries, memberships, credentials. - Admin, -} - -/// The scope that admits the administrative routes. -const ADMIN_SCOPE: &str = "admin"; -/// The scope that admits any mutation. -const WRITE_SCOPE: &str = "write"; - -impl Access { - /// Whether a credential carrying `scopes` may do this. - /// - /// An empty list is unrestricted: a session, an OAuth grant and a token - /// issued without scopes all carry the account's full authority, so nothing - /// that works today stops working. - /// - /// A non-empty list grants only what it names, and a name this server does - /// not know grants nothing — so `catalog:read` reads and does no more, - /// without needing a vocabulary of every possible scope. `admin` implies - /// `write`: a credential trusted to create accounts is not usefully barred - /// from creating a playlist, and the surprise would be the other way round. - fn granted_by(self, scopes: &[String]) -> bool { - if scopes.is_empty() { - return true; - } - let holds = |wanted: &str| scopes.iter().any(|scope| scope == wanted); - match self { - Self::Read => true, - Self::Write => holds(WRITE_SCOPE) || holds(ADMIN_SCOPE), - Self::Admin => holds(ADMIN_SCOPE), - } - } -} - -/// Resolves the caller and checks, in one place, that the credential may do -/// what the route is about to do. -/// -/// Both halves of administrative authority live here: an active administrator, -/// on a credential that has not been narrowed away from it. A token cannot -/// promote an ordinary account, and an administrator's token is not widened by -/// whose account it belongs to. -/// -/// It could widen itself, once: minting a session through the authorization -/// code flow returned one carrying the account's whole authority, whatever the -/// credential that asked. That is closed where it belongs now — the grant -/// records the caller's scopes and the session inherits them — rather than by -/// a rule this function has to know about. -pub(crate) async fn authenticated( - state: &AppState, - headers: &HeaderMap, - access: Access, -) -> Result { - let token = bearer_token(headers).ok_or(ApiError::Unauthorized)?; - let user = state - .auth - .authenticate(token) - .await - .map_err(ApiError::from)?; - let role_ok = access != Access::Admin || user.role == crate::database::AccountRole::Admin; - if role_ok && access.granted_by(&user.scopes) { - Ok(user) - } else { - Err(ApiError::Forbidden) - } -} - -async fn mutation_context( - state: &AppState, - headers: &HeaderMap, - user_id: Uuid, -) -> Result { - let operation_id = - optional_uuid_header(headers, OPERATION_ID_HEADER)?.unwrap_or_else(Uuid::new_v4); - let origin_device_id = optional_uuid_header(headers, DEVICE_ID_HEADER)?; - if let Some(device_id) = origin_device_id { - let owned = state - .sync - .device_belongs_to_user(user_id, device_id) - .await - .map_err(db_error)?; - if !owned { - return Err(ApiError::Validation); - } - } - Ok(crate::sync::MutationContext { - operation_id, - origin_device_id, - }) -} - -fn optional_uuid_header(headers: &HeaderMap, name: &'static str) -> Result, ApiError> { - headers - .get(name) - .map(|value| { - value - .to_str() - .ok() - .and_then(|value| Uuid::parse_str(value).ok()) - .ok_or(ApiError::Validation) - }) - .transpose() -} - -fn db_error(error: sqlx::Error) -> ApiError { - tracing::error!(error = %error, "catalog database operation failed"); - ApiError::Unavailable -} - -fn sync_error(error: crate::sync::SyncError) -> ApiError { - match error { - crate::sync::SyncError::Invalid => ApiError::Validation, - crate::sync::SyncError::Conflict => ApiError::Conflict, - crate::sync::SyncError::CursorExpired => ApiError::CursorExpired, - crate::sync::SyncError::Database(error) => db_error(error), - } -} - -/// Maps a domain failure onto the HTTP surface. `Forbidden` deliberately answers -/// 404 like `NotFound`: telling a caller that a resource exists but belongs to -/// someone else would leak another tenant's catalogue, which is the same -/// no-existence-leak rule the Subsonic facade applies. -fn service_error(error: crate::services::ServiceError) -> ApiError { - use crate::services::ServiceError; - match error { - ServiceError::NotFound | ServiceError::Forbidden => ApiError::NotFound, - ServiceError::Invalid => ApiError::Validation, - ServiceError::Conflict => ApiError::Conflict, - ServiceError::Unavailable => ApiError::Unavailable, - ServiceError::Database(error) => db_error(error), - ServiceError::Security(error) => { - tracing::error!(error = %error, "catalog security operation failed"); - ApiError::Unavailable - } - } -} - -#[cfg(test)] -mod tests { - use super::{public_url_is_https, sync_notice_action, SyncNoticeAction}; - - #[test] - fn secure_cookie_detection_uses_the_parsed_url_scheme() { - assert!(public_url_is_https(Some("HTTPS://waveflow.test/"))); - assert!(!public_url_is_https(Some("http://waveflow.test"))); - assert!(!public_url_is_https(Some("not a URL"))); - assert!(!public_url_is_https(None)); - } - - #[tokio::test] - async fn lagged_sync_socket_recovers_from_the_durable_cursor() { - let temp = tempfile::tempdir().unwrap(); - let config = crate::Config::for_data_dir(temp.path().join("data")); - let db = crate::database::Database::open(&config).await.unwrap(); - db.migrate().await.unwrap(); - let sync = crate::sync::SyncService::new(db); - let action = sync_notice_action( - &sync, - uuid::Uuid::new_v4(), - Err(tokio::sync::broadcast::error::RecvError::Lagged(3)), - ) - .await - .unwrap(); - - assert_eq!(action, SyncNoticeAction::Send(0)); - } -} diff --git a/src/lib.rs b/src/lib.rs index 9524cab..3ab9ace 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,11 @@ //! WaveFlow Server v2 library surface. +pub mod api; pub mod authentication; pub mod catalog; pub mod cli; pub mod config; pub mod database; -pub mod http; pub mod lyrics; pub mod media; pub mod oauth; @@ -67,91 +67,91 @@ pub struct AppState { license(name = "AGPL-3.0-only") ), paths( - http::health, - http::ready, - http::setup_status, - http::setup, - http::login, - http::refresh, - http::logout, - http::web_login, - http::web_refresh, - http::web_logout, - http::oauth_authorize, - http::oauth_token, - http::start_scan, - http::list_libraries, - http::create_library, - http::set_library_member, - http::remove_library_member, - http::scan_status, - http::scan_events, - http::list_tracks, - http::get_track, - http::get_track_lyrics, - http::list_albums, - http::list_genres, - http::get_album, - http::list_artists, - http::get_artist, - http::search_catalog, - http::list_random_songs, - http::list_songs_by_genre, - http::list_playlists, - http::create_playlist, - http::get_playlist, - http::update_playlist, - http::delete_playlist, - http::list_favorites, - http::add_favorite, - http::remove_favorite, - http::set_rating, - http::list_ratings, - http::create_scrobble, - http::list_history, - http::list_now_playing, - http::get_queue, - http::save_queue, - http::list_shares, - http::create_share, - http::update_share, - http::delete_share, - http::sync_changes, - http::sync_snapshot, - http::sync_ack, - http::sync_socket, - http::transcode_status, - http::list_users, - http::create_user, - http::update_user, - http::delete_user, - http::set_subsonic_credential, - http::revoke_subsonic_credential, - http::list_bookmarks, - http::set_bookmark, - http::delete_bookmark, - http::list_api_tokens, - http::create_api_token, - http::revoke_api_token, + api::health, + api::ready, + api::setup_status, + api::setup, + api::login, + api::refresh, + api::logout, + api::web_login, + api::web_refresh, + api::web_logout, + api::oauth_authorize, + api::oauth_token, + api::start_scan, + api::list_libraries, + api::create_library, + api::set_library_member, + api::remove_library_member, + api::scan_status, + api::scan_events, + api::list_tracks, + api::get_track, + api::get_track_lyrics, + api::list_albums, + api::list_genres, + api::get_album, + api::list_artists, + api::get_artist, + api::search_catalog, + api::list_random_songs, + api::list_songs_by_genre, + api::list_playlists, + api::create_playlist, + api::get_playlist, + api::update_playlist, + api::delete_playlist, + api::list_favorites, + api::add_favorite, + api::remove_favorite, + api::set_rating, + api::list_ratings, + api::create_scrobble, + api::list_history, + api::list_now_playing, + api::get_queue, + api::save_queue, + api::list_shares, + api::create_share, + api::update_share, + api::delete_share, + api::sync_changes, + api::sync_snapshot, + api::sync_ack, + api::sync_socket, + api::transcode_status, + api::list_users, + api::create_user, + api::update_user, + api::delete_user, + api::set_subsonic_credential, + api::revoke_subsonic_credential, + api::list_bookmarks, + api::set_bookmark, + api::delete_bookmark, + api::list_api_tokens, + api::create_api_token, + api::revoke_api_token, media::stream_track, media::create_stream_ticket, media::stream_with_ticket, media::artwork ), components(schemas( - http::ProbeResponse, - http::ReadyResponse, - http::SetupStatusResponse, - http::SetupRequest, - http::SetupResponse, - http::LoginRequest, - http::RefreshRequest, - http::WebAuthResponse, - http::ErrorResponse, + api::ProbeResponse, + api::ReadyResponse, + api::SetupStatusResponse, + api::SetupRequest, + api::SetupResponse, + api::LoginRequest, + api::RefreshRequest, + api::WebAuthResponse, + api::ErrorResponse, authentication::AuthTokens, authentication::AuthUser, database::AccountRole, - http::ScanQueuedResponse, + api::ScanQueuedResponse, catalog::ScanJobRecord, catalog::TrackRecord, catalog::LibraryAccess, @@ -172,33 +172,33 @@ pub struct AppState { services::HistoryItem, services::BookmarkItem, services::UserItem, - http::CreatePlaylistRequest, - http::UpdatePlaylistRequest, - http::RatingRequest, - http::ScrobbleRequest, - http::SaveQueueRequest, - http::CreateShareRequest, - http::UpdateShareRequest, - http::ShareResponse, - http::AuthorizeRequest, - http::AuthorizeResponse, - http::TokenRequest, - http::StarredEntry, - http::NowPlayingEntry, - http::SyncAckRequest, - http::SyncSnapshot, - http::TranscodeStatusResponse, - http::CreateUserRequest, - http::UpdateUserRequest, - http::SetSubsonicCredentialRequest, - http::SubsonicCredentialResponse, - http::BookmarkRequest, - http::CreateApiTokenRequest, - http::CreateApiTokenResponse, + api::CreatePlaylistRequest, + api::UpdatePlaylistRequest, + api::RatingRequest, + api::ScrobbleRequest, + api::SaveQueueRequest, + api::CreateShareRequest, + api::UpdateShareRequest, + api::ShareResponse, + api::AuthorizeRequest, + api::AuthorizeResponse, + api::TokenRequest, + api::StarredEntry, + api::NowPlayingEntry, + api::SyncAckRequest, + api::SyncSnapshot, + api::TranscodeStatusResponse, + api::CreateUserRequest, + api::UpdateUserRequest, + api::SetSubsonicCredentialRequest, + api::SubsonicCredentialResponse, + api::BookmarkRequest, + api::CreateApiTokenRequest, + api::CreateApiTokenResponse, database::ApiTokenRecord, - http::CreateLibraryRequest, - http::CreateLibraryResponse, - http::SetLibraryMemberRequest, + api::CreateLibraryRequest, + api::CreateLibraryResponse, + api::SetLibraryMemberRequest, sync::SyncChange, sync::SyncPage, media::StreamTicketResponse, @@ -345,13 +345,13 @@ fn annotate_mutation_headers(openapi: &mut utoipa::openapi::OpenApi) { }); let parameters = operation.parameters.get_or_insert_with(Vec::new); parameters.push(header( - http::OPERATION_ID_HEADER, + api::OPERATION_ID_HEADER, "Stable id for this logical mutation. Repeating it replays the original \ outcome instead of applying twice; reusing it for a different payload is \ rejected as a conflict. Generated server-side when absent.", )); parameters.push(header( - http::DEVICE_ID_HEADER, + api::DEVICE_ID_HEADER, "Device originating the mutation. Rejected when it belongs to another \ account. Lets other devices skip their own echo in the sync journal.", )); @@ -439,7 +439,7 @@ pub fn app(config: &Config, state: AppState) -> Router { .layer(PropagateRequestIdLayer::new(request_id_header)); let ordinary = Router::new() - .merge(http::router(state.clone())) + .merge(api::router(state.clone())) .merge(Router::from(Scalar::with_url(SCALAR_PATH, openapi))) .route( OPENAPI_JSON_PATH, @@ -480,9 +480,9 @@ pub fn app(config: &Config, state: AppState) -> Router { axum::http::header::AUTHORIZATION, axum::http::header::CONTENT_TYPE, axum::http::header::RANGE, - axum::http::HeaderName::from_static(http::WEB_CSRF_HEADER), - axum::http::HeaderName::from_static(http::OPERATION_ID_HEADER), - axum::http::HeaderName::from_static(http::DEVICE_ID_HEADER), + axum::http::HeaderName::from_static(api::WEB_CSRF_HEADER), + axum::http::HeaderName::from_static(api::OPERATION_ID_HEADER), + axum::http::HeaderName::from_static(api::DEVICE_ID_HEADER), ]) .expose_headers([ axum::http::header::ACCEPT_RANGES, diff --git a/src/media.rs b/src/media.rs index b33680b..1f6effe 100644 --- a/src/media.rs +++ b/src/media.rs @@ -497,7 +497,7 @@ async fn bearer_principal( state: &AppState, headers: &HeaderMap, ) -> Result { - crate::http::authenticated(state, headers, crate::http::Access::Read) + crate::api::authenticated(state, headers, crate::api::Access::Read) .await .map_err(|_| MediaError::Unauthorized) } diff --git a/src/services.rs b/src/services.rs deleted file mode 100644 index 6a161e4..0000000 --- a/src/services.rs +++ /dev/null @@ -1,4273 +0,0 @@ -//! Shared v2 domain services and tenant-filtered read models. - -use std::{collections::HashMap, path::PathBuf, str::FromStr, sync::Arc}; - -use serde::Serialize; -use sqlx::{Row, SqliteConnection}; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::{ - authentication::now_ms, - database::{AccountRecord, AccountRole, ApiTokenRecord, Database}, - lyrics::{self, LyricsList, StructuredLyrics}, - security::{self, EncryptedSecret, SecretBox}, - sync::{MutationContext, MutationIntent, MutationReceipt, OperationClaim, SyncService}, -}; - -/// Tenant-filtered projections shared by the Subsonic facade and the native -/// browse endpoints. Each expands to a literal ending at `WHERE m.user_id=?` so -/// callers `concat!` their own predicates onto it — sqlx only accepts static SQL, -/// which keeps these compositions injection-proof by construction. The first -/// bind is always the user id. -macro_rules! song_select { - () => { - "SELECT t.id, t.library_id, t.album_id, t.title, t.album_title, t.artist_display, \ - (SELECT tp.artist_id FROM track_participant tp \ - WHERE tp.track_id=t.id AND tp.role='artist' \ - ORDER BY tp.position LIMIT 1) AS artist_id, \ - t.genre_display, t.year, t.track_number, t.disc_number, t.duration_ms, t.bitrate, \ - t.codec, t.relative_path, t.file_size, t.artwork_hash, t.full_hash, t.created_at, \ - us.starred_at, ur.rating AS user_rating, \ - t.sample_rate, t.channels, t.bit_depth, \ - (SELECT COUNT(*) FROM play_event pe \ - WHERE pe.user_id=m.user_id AND pe.submission=1 AND pe.track_id=t.id) \ - AS play_count, \ - (SELECT MAX(pe.played_at) FROM play_event pe \ - WHERE pe.user_id=m.user_id AND pe.submission=1 AND pe.track_id=t.id) \ - AS last_played_at, \ - t.musicbrainz_recording_id, t.replay_gain_track_gain, t.replay_gain_track_peak, \ - t.replay_gain_album_gain, t.replay_gain_album_peak, t.bpm, t.sort_title, \ - t.comment, t.isrc, t.moods, t.explicit_status, \ - alb.album_artist_name, alb.album_artist_id \ - FROM track t JOIN library_member m ON m.library_id=t.library_id \ - LEFT JOIN album alb ON alb.id=t.album_id \ - LEFT JOIN user_star us ON us.user_id=m.user_id AND us.entity_type='track' AND us.entity_id=t.id \ - LEFT JOIN user_rating ur ON ur.user_id=m.user_id AND ur.entity_type='track' AND ur.entity_id=t.id \ - WHERE m.user_id=? AND t.is_available=1" - }; -} - -/// Narrows [`song_select!`] to an optional set of libraries. Binds the JSON -/// library list twice, as the album and artist scopes do. -macro_rules! song_folder_clause { - () => { - " AND (? IS NULL OR t.library_id IN (SELECT value FROM json_each(?)))" - }; -} - -/// Restricts [`song_select!`] to one genre, matched on `genre.canonical_name` -/// so case, punctuation and spacing fold exactly as they do in `getGenres` and -/// in the `byGenre` album filter. Binds the canonical name once. -macro_rules! song_genre_clause { - () => { - " AND t.id IN (SELECT tg.track_id FROM track_genre tg \ - JOIN genre g ON g.id=tg.genre_id WHERE g.canonical_name=?)" - }; -} - -/// An optional inclusive year range. Binds a flag and the two bounds, so one -/// literal serves both the filtered and the unfiltered request rather than -/// forking the statement. -macro_rules! song_year_clause { - () => { - " AND (? = 0 OR (t.year IS NOT NULL AND t.year BETWEEN ? AND ?))" - }; -} - -macro_rules! album_select { - () => { - "SELECT al.id, al.library_id, al.title, al.album_artist_name, al.album_artist_id, \ - al.artwork_hash, al.year, al.is_compilation, al.musicbrainz_id, al.sort_name, \ - al.created_at, us.starred_at, \ - ur.rating AS user_rating, \ - (SELECT COUNT(*) FROM play_event pe JOIN track pt ON pt.id=pe.track_id \ - WHERE pe.user_id=m.user_id AND pe.submission=1 AND pt.album_id=al.id) AS play_count, \ - (SELECT MAX(pe.played_at) FROM play_event pe JOIN track pt ON pt.id=pe.track_id \ - WHERE pe.user_id=m.user_id AND pe.submission=1 AND pt.album_id=al.id) AS last_played_at, \ - (SELECT COUNT(*) FROM track t2 WHERE t2.album_id=al.id AND t2.is_available=1) \ - AS song_count, \ - (SELECT COALESCE(SUM(t2.duration_ms), 0) FROM track t2 \ - WHERE t2.album_id=al.id AND t2.is_available=1) AS duration_ms \ - FROM album al JOIN library_member m ON m.library_id=al.library_id \ - LEFT JOIN user_star us ON us.user_id=m.user_id AND us.entity_type='album' AND us.entity_id=al.id \ - LEFT JOIN user_rating ur ON ur.user_id=m.user_id AND ur.entity_type='album' AND ur.entity_id=al.id \ - WHERE m.user_id=?" - }; -} - -/// [`album_select!`] narrowed to an optional set of libraries and wrapped so a -/// caller can filter and order on the projected aggregates — `play_count`, -/// `last_played_at`, `song_count` — instead of repeating their subqueries. -/// SQLite does not accept a result alias in `WHERE`, hence the wrapper rather -/// than a longer predicate list. Binds are the user id, then the JSON library -/// list twice. -macro_rules! album_scope { - () => { - concat!( - "SELECT * FROM (", - album_select!(), - " AND (? IS NULL OR al.library_id IN (SELECT value FROM json_each(?)))) AS a" - ) - }; -} - -/// The artist projection, in the two shapes the catalogue reads it. -/// -/// `artist_select!()` stops at the columns `ArtistItem` carries; -/// `artist_select!(album_count)` adds the count `ArtistSummary` needs, so a -/// browse that never renders it does not pay a correlated subquery per artist. -/// Both expand from the same column list on purpose: the browses that wanted -/// the short shape used to spell it out by hand, and one of those copies fell -/// a column behind the day this list gained one — the browse reading it then -/// failed on a column the query never selected, which nothing reading the -/// macro could have predicted. -macro_rules! artist_select { - () => { - artist_select!(@columns "") - }; - (album_count) => { - artist_select!( - @columns ", COALESCE((SELECT ars.album_count FROM artist_role_stats ars \ - WHERE ars.artist_id=ar.id AND ars.role='albumartist'), 0) \ - AS album_count" - ) - }; - (@columns $extra:expr) => { - concat!( - "SELECT ar.id, ar.library_id, ar.name, ar.artwork_hash, ar.musicbrainz_id, \ - ar.sort_name, us.starred_at, ur.rating AS user_rating, \ - (SELECT group_concat(role) FROM \ - (SELECT ars.role FROM artist_role_stats ars \ - WHERE ars.artist_id=ar.id ORDER BY ars.role)) AS roles", - $extra, - " FROM artist ar JOIN library_member m ON m.library_id=ar.library_id \ - LEFT JOIN user_star us ON us.user_id=m.user_id AND us.entity_type='artist' AND us.entity_id=ar.id \ - LEFT JOIN user_rating ur ON ur.user_id=m.user_id AND ur.entity_type='artist' AND ur.entity_id=ar.id \ - WHERE m.user_id=?" - ) - }; -} - -pub struct SubsonicCredentialRecord { - pub account: AccountRecord, - pub encrypted_password: EncryptedSecret, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct MusicFolderItem { - pub id: Uuid, - pub name: String, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct ArtistItem { - pub id: Uuid, - pub library_id: Uuid, - pub name: String, - pub artwork_hash: Option, - pub musicbrainz_id: Option, - /// The tagged sort form of the name, `None` when no file supplied one. - /// The Subsonic node emits it empty in that case rather than omitting it: - /// the field is supported, and this artist is untagged. - pub sort_name: Option, - pub starred_at: Option, - pub user_rating: Option, - /// The capacities this artist is credited in, anywhere in the catalogue. - /// - /// Derived from the credits rather than stored on the row, so an artist - /// who stops being a producer stops saying so at the next scan. - pub roles: Vec, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct AlbumItem { - pub id: Uuid, - pub library_id: Uuid, - pub title: String, - pub artist: Option, - pub artist_id: Option, - pub artwork_hash: Option, - pub year: Option, - pub is_compilation: bool, - pub musicbrainz_id: Option, - /// The tagged sort form of the title, on the same terms as the artist's. - pub sort_name: Option, - /// Every artist credited on the album's available tracks, and every - /// genre they carry. Derived rather than stored: an album has no credit - /// or genre of its own in the schema, only the union of its files'. - pub artists: Vec, - pub genres: Vec, - pub created_at: i64, - pub starred_at: Option, - pub user_rating: Option, - pub play_count: i64, - pub last_played_at: Option, - /// Available tracks in the album, and their total duration in - /// milliseconds. Projected here rather than derived by the caller: album - /// listings used to compute both by loading every track of the tenant, so - /// the counts cost a full catalogue read per request. - pub song_count: i64, - pub duration_ms: i64, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct SongItem { - pub id: Uuid, - pub library_id: Uuid, - pub album_id: Option, - pub title: String, - pub album: Option, - pub artist: Option, - /// Primary credited artist, matching the first artist in `artist`. - pub artist_id: Option, - pub genre: Option, - pub year: Option, - pub track: Option, - pub disc: Option, - pub duration_ms: i64, - pub bitrate: Option, - pub codec: Option, - pub suffix: String, - pub size: i64, - pub artwork_hash: Option, - /// Content fingerprint: **BLAKE3, unkeyed, hexadecimal, over the whole - /// file** — 64 characters. A client can compute the same value locally and - /// compare, which is the only automatic link M5 accepts (a unique full-hash - /// match; MBID stays a suggestion to confirm). - /// - /// It fingerprints the *file*, not the decoded audio: two copies of one - /// recording with different tags do not match. The algorithm is part of the - /// contract — changing it means adding a field, never redefining this one. - pub full_hash: String, - pub created_at: i64, - pub starred_at: Option, - pub user_rating: Option, - pub sample_rate: Option, - pub channels: Option, - pub bit_depth: Option, - pub play_count: i64, - pub last_played_at: Option, - /// Every credited artist, in tag order. `artist` and `artist_id` stay the - /// display string and the primary credit; these are the structured form - /// `track_artist` has always held and no surface ever read. - pub artists: Vec, - /// Every genre of the track, from `track_genre` rather than from the - /// semicolon-joined `genre` display string. - pub genres: Vec, - /// The credit the album carries, which is not always the track's own: a - /// guest appearance names the guest, and the album still belongs under - /// the album artist. - pub album_artist: Option, - pub album_artist_id: Option, - /// Every artist the album is credited to, which the single `album_artist_id` - /// above can only ever name the first of. It stays because the frozen - /// `artistId` field needs one. - pub album_artists: Vec, - /// Every credit that is neither the track's artist nor its album artist: - /// composer, producer, performer and the rest, in role then tag order. - pub contributors: Vec, - /// The MusicBrainz recording identifier: the performance, which is what - /// OpenSubsonic means by a song's `musicBrainzId`. RFC-004 keeps a match - /// on it a candidate the user confirms, never an automatic link. - pub musicbrainz_id: Option, - pub replay_gain_track_gain: Option, - pub replay_gain_track_peak: Option, - pub replay_gain_album_gain: Option, - pub replay_gain_album_peak: Option, - pub bpm: Option, - pub sort_name: Option, - pub comment: Option, - /// Split from the tag the same way artists and genres are. - pub isrc: Vec, - pub moods: Vec, - /// `explicit` or `clean`; the scanner stores nothing else. - pub explicit_status: Option, -} - -/// One credited artist of a track. Only `id` and `name` are carried: those are -/// the required `ArtistID3` fields, and OpenSubsonic asks for no more than the -/// required ones inside a media item. -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct ArtistRef { - pub id: Uuid, - pub name: String, -} - -/// One artist credited on a track in some capacity other than being its -/// artist or its album artist. -/// -/// The role is the reference's own name for it, and `sub_role` carries the -/// instrument a performer is credited on — the only role that has one. -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct Contributor { - pub role: String, - pub sub_role: Option, - pub artist: ArtistRef, -} - -/// A browse view that stops short of the tracks. -#[derive(Debug, Clone)] -pub struct CatalogOverview { - pub folders: Vec, - /// Carried as summaries because the browse that renders them needs the - /// album count, and computing it in the facade was a loop over every album - /// for every artist. - pub artists: Vec, - pub albums: Vec, -} - -#[derive(Debug, Clone)] -pub struct CatalogSnapshot { - pub folders: Vec, - pub artists: Vec, - pub albums: Vec, - pub songs: Vec, -} - -/// Everything one account has starred, across the three entity kinds. -#[derive(Debug, Clone)] -pub struct StarredCatalog { - pub artists: Vec, - pub albums: Vec, - pub songs: Vec, -} - -/// Result of a Subsonic `search3`, backed by the FTS5 index. -#[derive(Debug, Clone)] -pub struct CatalogSearch { - pub artists: Vec, - pub albums: Vec, - pub songs: Vec, -} - -/// Upper bound on a native browse page. It matches the Subsonic contract's -/// 500-item cap so both surfaces expose the same paging ceiling. -pub const MAX_BROWSE_LIMIT: i64 = 500; -const DEFAULT_BROWSE_LIMIT: i64 = 100; -pub const MAX_HISTORY_LIMIT: i64 = 500; -/// Fits a UUID-only queue request below the server's 16 KiB body limit while -/// also bounding the work performed under the global SQLite writer gate. -pub const MAX_QUEUE_TRACKS: usize = 400; -/// Applies the same request-size and writer-gate bound to public shares. -pub const MAX_SHARE_TRACKS: usize = MAX_QUEUE_TRACKS; - -/// Offset/limit pair validated once, at the HTTP boundary, so the SQL layer can -/// bind it without re-checking bounds. -#[derive(Debug, Clone, Copy)] -pub struct BrowsePage { - offset: i64, - limit: i64, -} - -impl BrowsePage { - pub fn new(offset: Option, limit: Option) -> Result { - let offset = offset.unwrap_or(0); - let limit = limit.unwrap_or(DEFAULT_BROWSE_LIMIT); - if offset < 0 || limit <= 0 || limit > MAX_BROWSE_LIMIT { - return Err(ServiceError::Invalid); - } - Ok(Self { offset, limit }) - } -} - -impl Default for BrowsePage { - fn default() -> Self { - Self { - offset: 0, - limit: DEFAULT_BROWSE_LIMIT, - } - } -} - -/// How an album listing is ordered and filtered. -/// -/// This is the single implementation of the ten Subsonic `getAlbumList2` modes, -/// and it lives in the domain services rather than in the facade for two -/// reasons. The facade used to sort in Rust over [`DomainServices::catalog_snapshot`], -/// which materialises every folder, artist, album *and track* the tenant can -/// see on each call — `byGenre` then rescanned every track once per album, and -/// `songCount` needed the whole track list just to be counted. And the native -/// API had no ordering at all, so the web client could not ask for "recently -/// added" without paging the entire catalogue itself. -/// -/// The variant names are the Subsonic `type` values verbatim, so the facade -/// stays a parameter adapter with no vocabulary of its own. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum AlbumOrder { - #[default] - AlphabeticalByName, - AlphabeticalByArtist, - Newest, - Highest, - Frequent, - Recent, - Starred, - Random, - ByYear, - ByGenre, -} - -impl FromStr for AlbumOrder { - type Err = ServiceError; - - fn from_str(value: &str) -> Result { - Ok(match value { - "alphabeticalByName" => Self::AlphabeticalByName, - "alphabeticalByArtist" => Self::AlphabeticalByArtist, - "newest" => Self::Newest, - "highest" => Self::Highest, - "frequent" => Self::Frequent, - "recent" => Self::Recent, - "starred" => Self::Starred, - "random" => Self::Random, - "byYear" => Self::ByYear, - "byGenre" => Self::ByGenre, - _ => return Err(ServiceError::Invalid), - }) - } -} - -/// One album listing request. -#[derive(Debug, Clone, Default)] -pub struct AlbumListQuery { - /// Restrict to these libraries. Empty means every library the user can see. - /// It is a set because Subsonic sends repeated `musicFolderId` values. - pub library_ids: Vec, - pub order: AlbumOrder, - /// Required by [`AlbumOrder::ByGenre`], ignored otherwise. - pub genre: Option, - /// Bounds for [`AlbumOrder::ByYear`], inclusive. Supplying them reversed is - /// how Subsonic asks for descending years, and that is preserved here. - pub from_year: Option, - pub to_year: Option, - pub page: BrowsePage, -} - -/// A genre with the size of what it holds, aggregated across the libraries the -/// user can see. -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct GenreItem { - pub name: String, - pub song_count: i64, - pub album_count: i64, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct ArtistSummary { - #[serde(flatten)] - pub artist: ArtistItem, - pub album_count: i64, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct AlbumDetail { - #[serde(flatten)] - pub album: AlbumItem, - pub songs: Vec, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct ArtistDetail { - #[serde(flatten)] - pub artist: ArtistItem, - /// Same field the list endpoint returns. `albums` below is unpaginated, so - /// its length matches — but that is an unwritten guarantee, and a client - /// should not have to depend on one. - pub album_count: i64, - pub albums: Vec, -} - -/// Inputs for a native client's authorization request. -#[derive(Debug, Clone, Copy)] -pub struct AuthorizationRequest<'a> { - pub client_id: &'a str, - pub redirect_uri: &'a str, - pub code_challenge: &'a str, - pub code_challenge_method: &'a str, - pub device_name: &'a str, - pub state: Option<&'a str>, - /// The scopes of the credential authorizing this grant. Recorded on the - /// grant so the session redeemed from it inherits them, which is what - /// keeps a session from ever being broader than what asked for it. - pub scopes: &'a [String], -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct SearchResult { - pub artists: Vec, - pub albums: Vec, - pub songs: Vec, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct PlaylistItem { - pub id: Uuid, - pub name: String, - pub comment: Option, - pub public: bool, - pub created_at: i64, - pub updated_at: i64, - pub songs: Vec, -} - -/// A playback position the user saved in one track. -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct BookmarkItem { - pub position_ms: i64, - pub comment: Option, - pub created_at: i64, - pub updated_at: i64, - /// The bookmarked track, resolved through the same tenant-filtered - /// projection every other surface reads. - pub song: SongItem, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct QueueItem { - pub current: Option, - pub position_ms: i64, - pub changed_by: Option, - pub updated_at: i64, - pub songs: Vec, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct RatingItem { - pub entity_type: String, - pub entity_id: Uuid, - pub rating: i64, - pub updated_at: i64, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct HistoryItem { - pub track_id: Uuid, - pub submission: bool, - pub played_at: i64, -} - -/// Optional fields a share update may blank out. -/// -/// `COALESCE(?, column)` cannot express this: an absent field and an explicit -/// null arrive as the same bind, so "leave it alone" and "remove it" collapse. -/// The consequence was not cosmetic — an expiry set by mistake could never be -/// lifted, and the owner's only recourse was to delete the share and mint a new -/// URL. Clearing is opt-in and named, so a client that merely omits a field can -/// never erase one by accident. -#[derive(Debug, Clone, Copy, Default)] -pub struct ShareClear { - pub description: bool, - pub expires_at: bool, -} - -/// Optional fields a playlist update may blank out. See [`ShareClear`]. -#[derive(Debug, Clone, Copy, Default)] -pub struct PlaylistClear { - pub comment: bool, - /// Drop the existing track list before applying `add`, which turns an - /// update into a replacement. Subsonic's `createPlaylist` needs it: given a - /// `playlistId`, its `songId` values are the whole playlist rather than - /// additions to it. The native surface does not expose it. - pub tracks: bool, -} - -#[derive(Debug, Clone)] -pub struct ShareItem { - pub id: Uuid, - pub owner_id: Uuid, - /// Present only in the result of a newly-created share. Persistent reads - /// deliberately cannot recover the bearer token from its lookup hash. - pub url_token: Option, - pub description: Option, - pub expires_at: Option, - pub created_at: i64, - pub visit_count: i64, - pub songs: Vec, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct UserItem { - pub id: Uuid, - pub username: String, - pub role: AccountRole, - pub disabled: bool, - pub has_subsonic_credential: bool, - pub folder_ids: Vec, -} - -pub struct UserUpdate<'a> { - pub admin: Option, - pub disabled: Option, - pub folder_ids: Option<&'a [Uuid]>, - pub subsonic_password: Option<&'a str>, - pub web_password: Option<&'a str>, -} - -pub struct SyncSnapshotData { - pub cursor: i64, - pub playlists: Vec, - pub favorites: Vec<(String, Uuid, i64)>, - pub ratings: Vec, - pub queue: Option, - pub history: Vec, - pub shares: Vec, - pub bookmarks: Vec, -} - -#[derive(Clone)] -pub struct DomainServices { - db: Database, - secret_box: Arc, - sync: SyncService, - scanner: crate::scanner::ScanManager, -} - -#[derive(Debug, thiserror::Error)] -pub enum ServiceError { - #[error("resource not found")] - NotFound, - #[error("operation is forbidden")] - Forbidden, - #[error("invalid input")] - Invalid, - #[error("conflict")] - Conflict, - #[error("service unavailable")] - Unavailable, - #[error(transparent)] - Database(#[from] sqlx::Error), - #[error(transparent)] - Security(#[from] security::SecurityError), -} - -impl From for ServiceError { - fn from(error: crate::sync::SyncError) -> Self { - match error { - crate::sync::SyncError::Invalid => Self::Invalid, - // Mutations claim operations; they never read the journal, so - // CursorExpired cannot reach this conversion. Folded into Conflict - // rather than given a domain variant nothing would ever construct. - crate::sync::SyncError::Conflict | crate::sync::SyncError::CursorExpired => { - Self::Conflict - } - crate::sync::SyncError::Database(error) => Self::Database(error), - } - } -} - -impl DomainServices { - pub fn new( - db: Database, - secret_box: Arc, - sync: SyncService, - scanner: crate::scanner::ScanManager, - ) -> Self { - Self { - db, - secret_box, - sync, - scanner, - } - } - - /// Queues a rescan of one library the user can reach. - /// - /// The single implementation behind `POST /api/v2/libraries/{id}/scans` - /// and the Subsonic `startScan`. Both surfaces have to answer the same - /// question about who may scan what, so the membership check cannot sit - /// in a handler where the two copies can drift apart. - pub async fn start_library_scan( - &self, - user_id: Uuid, - library_id: Uuid, - ) -> Result { - let library = self - .db - .library_for_user(user_id, library_id) - .await? - .ok_or(ServiceError::NotFound)?; - // The lookup above reads the root path; it is not what authorises the - // job. The insert tests `library_member` itself — membership and role - // together — so an access revoked or downgraded between the two refuses - // the job instead of queuing work the requester may no longer ask for. - let scan_id = self - .db - .create_scan_job_for_user(user_id, library_id, "manual") - .await? - .ok_or(ServiceError::NotFound)?; - self.scanner.spawn(scan_id, library); - Ok(scan_id) - } - - /// Queues a rescan of every library the user may scan, for the Subsonic - /// `startScan`, which takes no library parameter. - /// - /// Libraries the account only listens to are skipped rather than attempted - /// and reported: `startScan` names no library, so refusing the whole call - /// because one of the account's libraries is read-only would put the - /// scannable ones out of reach from Subsonic entirely. - /// - /// An account that may scan nothing therefore queues nothing and succeeds, - /// like an account that reaches no library at all: there is no missing - /// resource to report, and every other catalogue-wide method answers such - /// an account with an empty result rather than an error. - /// - /// Best effort by design: a library whose job cannot be queued does not - /// cancel the ones that can. Aborting on the first failure would leave - /// the caller reading an error while half the catalogue is already - /// rescanning, which is the worst of both answers. The error surfaces - /// only when nothing at all could be queued. - /// - /// Re-queuing a library that is already scanning is deliberately allowed, - /// exactly as calling the native endpoint twice is: [`crate::scanner::ScanManager`] - /// serialises jobs per library and a scan converges on file content, so a - /// redundant pass costs time and changes nothing. - pub async fn start_visible_scans(&self, user_id: Uuid) -> Result, ServiceError> { - let libraries = self.db.libraries_for_user(user_id).await?; - let mut queued = Vec::new(); - let mut failure = None; - for access in libraries - .into_iter() - .filter(|access| access.role.may_scan()) - { - match self.start_library_scan(user_id, access.id).await { - Ok(scan_id) => queued.push(scan_id), - Err(error) => failure = Some(error), - } - } - match failure { - Some(error) if queued.is_empty() => Err(error), - _ => Ok(queued), - } - } - - pub async fn bootstrap_admin( - &self, - username: &str, - password: &str, - ) -> Result { - validate_username(username)?; - if password.len() < 12 { - return Err(ServiceError::Invalid); - } - let password = password.to_owned(); - let password_hash = tokio::task::spawn_blocking(move || security::hash_password(&password)) - .await - .map_err(|_| ServiceError::Unavailable)??; - self.db - .bootstrap_admin(username, &password_hash, now_ms()) - .await? - .ok_or(ServiceError::Conflict) - } - - pub async fn credential_by_username( - &self, - username: &str, - ) -> Result, ServiceError> { - let row = sqlx::query( - "SELECT a.id, a.username, a.password_hash, a.role, a.disabled, \ - c.password_nonce, c.password_ciphertext \ - FROM account a JOIN subsonic_credential c ON c.user_id=a.id \ - WHERE a.username=? COLLATE NOCASE AND a.disabled=0", - ) - .bind(username) - .fetch_optional(self.db.pool()) - .await?; - row.map(credential_from_row).transpose().map_err(Into::into) - } - - pub async fn credential_by_api_key( - &self, - api_key: &str, - ) -> Result, ServiceError> { - let hash = security::token_hash(api_key); - let row = sqlx::query( - "SELECT a.id, a.username, a.password_hash, a.role, a.disabled, \ - c.password_nonce, c.password_ciphertext \ - FROM account a JOIN subsonic_credential c ON c.user_id=a.id \ - WHERE c.api_key_hash=? AND a.disabled=0", - ) - .bind(hash.as_slice()) - .fetch_optional(self.db.pool()) - .await?; - row.map(credential_from_row).transpose().map_err(Into::into) - } - - pub fn decrypt_subsonic_password( - &self, - credential: &SubsonicCredentialRecord, - ) -> Result, ServiceError> { - self.secret_box - .decrypt( - &credential.encrypted_password.nonce, - &credential.encrypted_password.ciphertext, - ) - .map_err(Into::into) - } - - /// The libraries one account can reach. - /// - /// `getMusicFolders` needs nothing else, and used to read the whole - /// catalogue to answer with a handful of names. - pub async fn music_folders( - &self, - user_id: Uuid, - folder_ids: &[Uuid], - ) -> Result, ServiceError> { - let folder_filter = folder_filter(folder_ids); - Ok(sqlx::query( - "SELECT l.id, l.name FROM library l JOIN library_member m ON m.library_id=l.id \ - WHERE m.user_id=? AND (? IS NULL OR l.id IN (SELECT value FROM json_each(?))) \ - ORDER BY l.name COLLATE NOCASE", - ) - .bind(user_id.to_string()) - .bind(folder_filter.as_deref()) - .bind(folder_filter.as_deref()) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(|row| { - Ok(MusicFolderItem { - id: parse_uuid(row.try_get("id")?)?, - name: row.try_get("name")?, - }) - }) - .collect::, sqlx::Error>>()?) - } - - /// Folders, artists and albums, without the tracks. - /// - /// Most of what browses the catalogue never looks at a track: an index of - /// artists, an artist's albums, a folder's contents. Those used to read - /// every visible track anyway, because one snapshot served every browse - /// method, and the track read is by far the largest of the three — and - /// since the OpenSubsonic fields landed it carries two relation loads of - /// its own. - pub async fn catalog_overview( - &self, - user_id: Uuid, - folder_ids: &[Uuid], - ) -> Result { - let folder_filter = folder_filter(folder_ids); - let folders = self.music_folders(user_id, folder_ids).await?; - let artists = sqlx::query(concat!( - artist_select!(album_count), - // Only artists an album is credited to. A composer with no album - // of their own is reachable by identifier and by search, but does - // not belong in an index of the library's artists — which is what - // the reference answers, and what `getArtists` means. - " AND EXISTS (SELECT 1 FROM artist_role_stats ars \ - WHERE ars.artist_id=ar.id AND ars.role='albumartist') \ - AND (? IS NULL OR ar.library_id IN (SELECT value FROM json_each(?))) \ - ORDER BY ar.name COLLATE NOCASE" - )) - .bind(user_id.to_string()) - .bind(folder_filter.as_deref()) - .bind(folder_filter.as_deref()) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(artist_summary_from_row) - .collect::, _>>()?; - let mut albums = sqlx::query(concat!( - album_select!(), - " AND (? IS NULL OR al.library_id IN (SELECT value FROM json_each(?))) \ - ORDER BY al.title COLLATE NOCASE" - )) - .bind(user_id.to_string()) - .bind(folder_filter.as_deref()) - .bind(folder_filter.as_deref()) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(album_from_row) - .collect::, _>>()?; - attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; - Ok(CatalogOverview { - folders, - artists, - albums, - }) - } - - /// The overview plus every visible track. - /// - /// **No route calls this.** Every browse method that used to now asks for - /// what it renders, and nothing should reach for this again: it is the - /// shape that made one album page read a tenant's whole catalogue. - /// - /// It survives as a fixture. The integration suite builds ids from it — - /// "give me an album of this account so I can ask for it" — which is a - /// legitimate use of a full read in a test with three tracks in it, and - /// not one in a request. - pub async fn catalog_snapshot( - &self, - user_id: Uuid, - folder_ids: &[Uuid], - ) -> Result { - let overview = self.catalog_overview(user_id, folder_ids).await?; - let songs = fetch_songs( - &self.db, - user_id, - folder_filter(folder_ids).as_deref(), - None, - ) - .await?; - Ok(CatalogSnapshot { - folders: overview.folders, - artists: overview.artists, - albums: overview.albums, - songs, - }) - } - - /// Backs Subsonic `search3` with the FTS5 index instead of materialising the - /// whole catalogue and filtering it in memory. - /// - /// `track_fts` indexes title, album, artists and genres per track, so - /// selecting matching tracks and deriving their albums and artists covers - /// the same ground the in-memory pass did — a matching album title reaches - /// its own tracks through the `album` column. - /// - /// Its tokenizer folds case *and* diacritics, so "echo" now finds "Écho", - /// which the previous lowercase substring test did not. What it gives up is - /// matching inside a word: "cho" no longer finds "Echo". The trailing term - /// is treated as a prefix so search-as-you-type still works. - pub async fn catalog_search( - &self, - user_id: Uuid, - folder_ids: &[Uuid], - query: &str, - ) -> Result { - let Some(fts) = crate::catalog::fts_prefix_query(query) else { - return Ok(CatalogSearch { - artists: Vec::new(), - albums: Vec::new(), - songs: Vec::new(), - }); - }; - let folder_filter = (!folder_ids.is_empty()).then(|| { - serde_json::to_string(folder_ids).expect("UUID list serialization cannot fail") - }); - let folder_filter = folder_filter.as_deref(); - - let mut songs = sqlx::query(concat!( - song_select!(), - " AND (? IS NULL OR t.library_id IN (SELECT value FROM json_each(?))) \ - AND t.id IN (SELECT track_id FROM track_fts WHERE track_fts MATCH ?) \ - ORDER BY t.title COLLATE NOCASE, t.id" - )) - .bind(user_id.to_string()) - .bind(folder_filter) - .bind(folder_filter) - .bind(&fts) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(song_from_row) - .collect::, _>>()?; - attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; - - let mut albums = sqlx::query(concat!( - album_select!(), - " AND (? IS NULL OR al.library_id IN (SELECT value FROM json_each(?))) \ - AND al.id IN (SELECT t.album_id FROM track t WHERE t.album_id IS NOT NULL \ - AND t.id IN (SELECT track_id FROM track_fts WHERE track_fts MATCH ?)) \ - ORDER BY al.title COLLATE NOCASE, al.id" - )) - .bind(user_id.to_string()) - .bind(folder_filter) - .bind(folder_filter) - .bind(&fts) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(album_from_row) - .collect::, _>>()?; - attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; - - let artists = sqlx::query(concat!( - artist_select!(), - " AND (? IS NULL OR ar.library_id IN (SELECT value FROM json_each(?))) \ - AND ar.id IN (SELECT artist_id FROM artist_fts WHERE artist_fts MATCH ?) \ - ORDER BY ar.name COLLATE NOCASE" - )) - .bind(user_id.to_string()) - .bind(folder_filter) - .bind(folder_filter) - .bind(&fts) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(artist_from_row) - .collect::, _>>()?; - - Ok(CatalogSearch { - artists, - albums, - songs, - }) - } - - /// Albums visible to the user, ordered, filtered and paged entirely in SQL. - /// - /// See [`AlbumOrder`] for why the ten orderings live here rather than in the - /// Subsonic facade. Every mode reads a single static literal — sqlx only - /// accepts static SQL, so the composition stays injection-proof by - /// construction and the user id is always the first bind. - pub async fn list_albums( - &self, - user_id: Uuid, - query: &AlbumListQuery, - ) -> Result, ServiceError> { - let folders = (!query.library_ids.is_empty()).then(|| { - serde_json::to_string(&query.library_ids).expect("UUID list serialization cannot fail") - }); - // `byGenre` without a genre is a malformed request, not an empty one: - // answering with the whole catalogue would drop the filter in silence. - // Matching is on the canonical form, so "Hip-Hop" and "hip hop" are the - // same genre — the facade previously compared display strings with - // `eq_ignore_ascii_case`, which they are not. - let genre = match query.order { - AlbumOrder::ByGenre => Some(waveflow_core::scanner::canonical_name( - query.genre.as_deref().ok_or(ServiceError::Invalid)?, - )), - _ => None, - }; - // An absent bound is unbounded, and a reversed range is how Subsonic - // asks for descending years. - let from = query.from_year.unwrap_or(i64::MIN); - let to = query.to_year.unwrap_or(i64::MAX); - let sql = match (query.order, from <= to) { - (AlbumOrder::AlphabeticalByName, _) => concat!( - album_scope!(), - " ORDER BY title COLLATE NOCASE, id LIMIT ? OFFSET ?" - ), - (AlbumOrder::AlphabeticalByArtist, _) => concat!( - album_scope!(), - " ORDER BY COALESCE(album_artist_name, '') COLLATE NOCASE, \ - title COLLATE NOCASE, id LIMIT ? OFFSET ?" - ), - (AlbumOrder::Newest, _) => concat!( - album_scope!(), - " ORDER BY created_at DESC, id LIMIT ? OFFSET ?" - ), - (AlbumOrder::Highest, _) => concat!( - album_scope!(), - " WHERE user_rating > 0 \ - ORDER BY user_rating DESC, title COLLATE NOCASE, id LIMIT ? OFFSET ?" - ), - (AlbumOrder::Frequent, _) => concat!( - album_scope!(), - " WHERE play_count > 0 \ - ORDER BY play_count DESC, title COLLATE NOCASE, id LIMIT ? OFFSET ?" - ), - (AlbumOrder::Recent, _) => concat!( - album_scope!(), - " WHERE last_played_at IS NOT NULL \ - ORDER BY last_played_at DESC, title COLLATE NOCASE, id LIMIT ? OFFSET ?" - ), - (AlbumOrder::Starred, _) => concat!( - album_scope!(), - " WHERE starred_at IS NOT NULL \ - ORDER BY starred_at DESC, title COLLATE NOCASE, id LIMIT ? OFFSET ?" - ), - (AlbumOrder::Random, _) => { - concat!(album_scope!(), " ORDER BY RANDOM() LIMIT ? OFFSET ?") - } - (AlbumOrder::ByYear, true) => concat!( - album_scope!(), - " WHERE year IS NOT NULL AND year BETWEEN ? AND ? \ - ORDER BY year, title COLLATE NOCASE, id LIMIT ? OFFSET ?" - ), - (AlbumOrder::ByYear, false) => concat!( - album_scope!(), - " WHERE year IS NOT NULL AND year BETWEEN ? AND ? \ - ORDER BY year DESC, title COLLATE NOCASE, id LIMIT ? OFFSET ?" - ), - (AlbumOrder::ByGenre, _) => concat!( - album_scope!(), - " WHERE EXISTS (SELECT 1 FROM track t2 \ - JOIN track_genre tg ON tg.track_id=t2.id \ - JOIN genre g ON g.id=tg.genre_id \ - WHERE t2.album_id=a.id AND t2.is_available=1 AND g.canonical_name=?) \ - ORDER BY title COLLATE NOCASE, id LIMIT ? OFFSET ?" - ), - }; - let mut statement = sqlx::query(sql) - .bind(user_id.to_string()) - .bind(folders.as_deref()) - .bind(folders.as_deref()); - if let Some(genre) = genre { - statement = statement.bind(genre); - } - if query.order == AlbumOrder::ByYear { - statement = statement.bind(from.min(to)).bind(from.max(to)); - } - let mut albums = statement - .bind(query.page.limit) - .bind(query.page.offset) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(album_from_row) - .collect::, _>>()?; - attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; - Ok(albums) - } - - /// Genres visible to the user, with the size of what each holds. - /// - /// Grouping is by `genre.canonical_name`, so one genre spelled differently - /// across two libraries — or differing only in case — is a single row. The - /// facade previously grouped the raw `genre_display` fragments, which - /// listed "Rock" and "rock" as two genres with split counts. - pub async fn list_genres( - &self, - user_id: Uuid, - library_ids: &[Uuid], - ) -> Result, ServiceError> { - let folders = (!library_ids.is_empty()).then(|| { - serde_json::to_string(library_ids).expect("UUID list serialization cannot fail") - }); - Ok(sqlx::query( - "SELECT MIN(g.name) AS name, COUNT(DISTINCT t.id) AS song_count, \ - COUNT(DISTINCT t.album_id) AS album_count \ - FROM genre g JOIN library_member m ON m.library_id=g.library_id \ - JOIN track_genre tg ON tg.genre_id=g.id \ - JOIN track t ON t.id=tg.track_id AND t.is_available=1 \ - WHERE m.user_id=? AND (? IS NULL OR g.library_id IN (SELECT value FROM json_each(?))) \ - GROUP BY g.canonical_name ORDER BY name COLLATE NOCASE", - ) - .bind(user_id.to_string()) - .bind(folders.as_deref()) - .bind(folders.as_deref()) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(|row| { - Ok(GenreItem { - name: row.try_get("name")?, - song_count: row.try_get("song_count")?, - album_count: row.try_get("album_count")?, - }) - }) - .collect::, sqlx::Error>>()?) - } - - /// Songs of one genre, ordered and paged in SQL. - /// - /// Matching is on `genre.canonical_name`, the same key `list_genres` groups - /// by and `byGenre` filters on. It was `eq_ignore_ascii_case` against the - /// joined display string, which folds case but not punctuation or spacing: - /// `getGenres` answered one row for "Hip-Hop" and "Hip Hop", and asking for - /// that row returned only the tracks spelled the way the caller happened to - /// send. A client showed a genre it had just been given, and it was empty. - pub async fn songs_by_genre( - &self, - user_id: Uuid, - library_ids: &[Uuid], - genre: &str, - page: BrowsePage, - ) -> Result, ServiceError> { - let folders = folder_filter(library_ids); - let canonical = waveflow_core::scanner::canonical_name(genre); - let mut songs = sqlx::query(concat!( - song_select!(), - song_folder_clause!(), - song_genre_clause!(), - " ORDER BY t.title COLLATE NOCASE, t.id LIMIT ? OFFSET ?" - )) - .bind(user_id.to_string()) - .bind(folders.as_deref()) - .bind(folders.as_deref()) - .bind(&canonical) - .bind(page.limit) - .bind(page.offset) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(song_from_row) - .collect::, _>>()?; - attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; - Ok(songs) - } - - /// The available tracks of one library that belong to no album. - /// - /// A track without an album has no album id to be the `parent` of its - /// Subsonic `child`, so it names its library instead. That was a - /// dead end until now: browsing to that identifier listed the library's - /// artists and nothing else, so a track reachable by search was reachable - /// by no amount of browsing. Answering here is what makes the `parent` - /// it already advertised true. - /// - /// `getMusicDirectory` has no offset to page a folder with, so the caller - /// asks for a ceiling instead of a page: everything up to `limit`, in one - /// answer. A library that holds more album-less tracks than that would - /// build an unbounded response out of a request that cannot say how much - /// it wants, so the ceiling is what keeps the answer finite — and the - /// caller says so in the log rather than truncating in silence. The artist - /// list this tail follows is still bounded only by the library. - pub async fn songs_without_album( - &self, - user_id: Uuid, - library_id: Uuid, - limit: i64, - ) -> Result, ServiceError> { - if limit <= 0 { - return Err(ServiceError::Invalid); - } - let mut songs = sqlx::query(concat!( - song_select!(), - " AND t.library_id=? AND t.album_id IS NULL \ - ORDER BY t.title COLLATE NOCASE, t.id LIMIT ?" - )) - .bind(user_id.to_string()) - .bind(library_id.to_string()) - .bind(limit) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(song_from_row) - .collect::, _>>()?; - attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; - Ok(songs) - } - - /// A random selection, drawn in SQL rather than by shuffling the catalogue. - /// - /// The facade used to read every visible track, filter in Rust and shuffle - /// the result to answer with ten. `ORDER BY RANDOM() LIMIT` asks SQLite for - /// the same thing without materialising the rest, and the genre filter - /// matches the canonical name like every other genre predicate. - /// - /// A reversed year range is how Subsonic asks for one, so the bounds are - /// normalised rather than rejected. - pub async fn random_songs( - &self, - user_id: Uuid, - library_ids: &[Uuid], - genre: Option<&str>, - from_year: Option, - to_year: Option, - limit: i64, - ) -> Result, ServiceError> { - if limit <= 0 || limit > MAX_BROWSE_LIMIT { - return Err(ServiceError::Invalid); - } - let folders = folder_filter(library_ids); - let canonical = genre.map(waveflow_core::scanner::canonical_name); - let from = from_year.unwrap_or(i64::MIN); - let to = to_year.unwrap_or(i64::MAX); - let bounded = from_year.is_some() || to_year.is_some(); - let sql = match canonical.is_some() { - true => concat!( - song_select!(), - song_folder_clause!(), - song_genre_clause!(), - song_year_clause!(), - " ORDER BY RANDOM() LIMIT ?" - ), - false => concat!( - song_select!(), - song_folder_clause!(), - song_year_clause!(), - " ORDER BY RANDOM() LIMIT ?" - ), - }; - let mut statement = sqlx::query(sql) - .bind(user_id.to_string()) - .bind(folders.as_deref()) - .bind(folders.as_deref()); - if let Some(canonical) = canonical.as_deref() { - statement = statement.bind(canonical); - } - let mut songs = statement - .bind(bounded) - .bind(from.min(to)) - .bind(from.max(to)) - .bind(limit) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(song_from_row) - .collect::, _>>()?; - attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; - Ok(songs) - } - - /// Everything the account has starred, most recent first. - /// - /// The three projections already `LEFT JOIN user_star`, so this is the same - /// read with the join made mandatory. The facade used to load the whole - /// catalogue and look each starred id up inside it, which cost a full - /// catalogue read to answer a list that is usually short. - pub async fn starred( - &self, - user_id: Uuid, - library_ids: &[Uuid], - ) -> Result { - let folders = folder_filter(library_ids); - let artists = sqlx::query(concat!( - artist_select!(album_count), - " AND us.starred_at IS NOT NULL \ - AND (? IS NULL OR ar.library_id IN (SELECT value FROM json_each(?))) \ - ORDER BY us.starred_at DESC, ar.id" - )) - .bind(user_id.to_string()) - .bind(folders.as_deref()) - .bind(folders.as_deref()) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(artist_summary_from_row) - .collect::, _>>()?; - let mut albums = sqlx::query(concat!( - album_select!(), - " AND us.starred_at IS NOT NULL \ - AND (? IS NULL OR al.library_id IN (SELECT value FROM json_each(?))) \ - ORDER BY us.starred_at DESC, al.id" - )) - .bind(user_id.to_string()) - .bind(folders.as_deref()) - .bind(folders.as_deref()) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(album_from_row) - .collect::, _>>()?; - attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; - let mut songs = sqlx::query(concat!( - song_select!(), - " AND us.starred_at IS NOT NULL", - song_folder_clause!(), - " ORDER BY us.starred_at DESC, t.id" - )) - .bind(user_id.to_string()) - .bind(folders.as_deref()) - .bind(folders.as_deref()) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(song_from_row) - .collect::, _>>()?; - attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; - Ok(StarredCatalog { - artists, - albums, - songs, - }) - } - - /// One album with its tracks in sleeve order. Returns [`ServiceError::NotFound`] - /// both when the album does not exist and when it belongs to a library the - /// user cannot see, so the surface never leaks another tenant's catalogue. - pub async fn album(&self, user_id: Uuid, album_id: Uuid) -> Result { - let mut album = vec![sqlx::query(concat!(album_select!(), " AND al.id=?")) - .bind(user_id.to_string()) - .bind(album_id.to_string()) - .fetch_optional(self.db.pool()) - .await? - .map(album_from_row) - .transpose()? - .ok_or(ServiceError::NotFound)?]; - attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut album).await?; - let album = album.remove(0); - let mut songs = sqlx::query(concat!( - song_select!(), - // SQLite orders NULL first, which would put an untagged track ahead - // of track 1. Incomplete disc/track tags are common in real - // libraries, so unnumbered tracks sort to the end instead. - " AND t.album_id=? \ - ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, \ - t.title COLLATE NOCASE, t.id" - )) - .bind(user_id.to_string()) - .bind(album_id.to_string()) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(song_from_row) - .collect::, _>>()?; - attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; - Ok(AlbumDetail { album, songs }) - } - - /// Artists visible to the user, paginated, each with its album count. - pub async fn list_artists( - &self, - user_id: Uuid, - library_id: Option, - page: BrowsePage, - ) -> Result, ServiceError> { - let library = library_id.map(|id| id.to_string()); - Ok(sqlx::query(concat!( - artist_select!(album_count), - " AND (? IS NULL OR ar.library_id=?) \ - ORDER BY ar.name COLLATE NOCASE, ar.id LIMIT ? OFFSET ?" - )) - .bind(user_id.to_string()) - .bind(library.as_deref()) - .bind(library.as_deref()) - .bind(page.limit) - .bind(page.offset) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(artist_summary_from_row) - .collect::, _>>()?) - } - - /// One artist with the albums it is credited on as album artist. - pub async fn artist( - &self, - user_id: Uuid, - artist_id: Uuid, - ) -> Result { - let summary = sqlx::query(concat!(artist_select!(album_count), " AND ar.id=?")) - .bind(user_id.to_string()) - .bind(artist_id.to_string()) - .fetch_optional(self.db.pool()) - .await? - .map(artist_summary_from_row) - .transpose()? - .ok_or(ServiceError::NotFound)?; - let mut albums = sqlx::query(concat!( - album_select!(), - " AND EXISTS (SELECT 1 FROM album_participant ap \ - WHERE ap.album_id=al.id AND ap.artist_id=? AND ap.role='albumartist') \ - ORDER BY al.year NULLS LAST, al.title COLLATE NOCASE, al.id" - )) - .bind(user_id.to_string()) - .bind(artist_id.to_string()) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(album_from_row) - .collect::, _>>()?; - attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; - Ok(ArtistDetail { - artist: summary.artist, - album_count: summary.album_count, - albums, - }) - } - - /// The whole visible catalogue, each kind ordered and paged in SQL. - /// - /// Subsonic clients send the literal `""` to `search3` as the documented - /// match-all query, and page through it to build their initial library. - /// FTS5 has no expression meaning "everything", so this is not a search at - /// all — it is three ordinary listings under the search response. It used - /// to read the entire catalogue and slice it in Rust, once per page, which - /// made a client's first synchronization quadratic in the library. - /// - /// A page beyond the end is an empty list rather than an error: that is how - /// a client learns it has reached the end. - pub async fn browse_all( - &self, - user_id: Uuid, - library_ids: &[Uuid], - artists: BrowsePage, - albums: BrowsePage, - songs: BrowsePage, - ) -> Result { - let folders = folder_filter(library_ids); - let artists = sqlx::query(concat!( - artist_select!(), - " AND (? IS NULL OR ar.library_id IN (SELECT value FROM json_each(?))) \ - ORDER BY ar.name COLLATE NOCASE, ar.id LIMIT ? OFFSET ?" - )) - .bind(user_id.to_string()) - .bind(folders.as_deref()) - .bind(folders.as_deref()) - .bind(artists.limit) - .bind(artists.offset) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(artist_from_row) - .collect::, _>>()?; - let mut albums = sqlx::query(concat!( - album_select!(), - " AND (? IS NULL OR al.library_id IN (SELECT value FROM json_each(?))) \ - ORDER BY al.title COLLATE NOCASE, al.id LIMIT ? OFFSET ?" - )) - .bind(user_id.to_string()) - .bind(folders.as_deref()) - .bind(folders.as_deref()) - .bind(albums.limit) - .bind(albums.offset) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(album_from_row) - .collect::, _>>()?; - attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; - let mut songs = sqlx::query(concat!( - song_select!(), - song_folder_clause!(), - " ORDER BY t.title COLLATE NOCASE, t.id LIMIT ? OFFSET ?" - )) - .bind(user_id.to_string()) - .bind(folders.as_deref()) - .bind(folders.as_deref()) - .bind(songs.limit) - .bind(songs.offset) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(song_from_row) - .collect::, _>>()?; - attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; - Ok(CatalogSearch { - artists, - albums, - songs, - }) - } - - /// Full-text search across the user's visible catalogue. Tracks are matched - /// through the FTS5 index built in M1, which folds case and diacritics, so - /// "echo" finds "Écho". Albums and artists are derived from the same index - /// rather than a second scan, keeping one source of truth for relevance. - /// - /// Each kind is paged independently, as `search3` has always allowed: - /// a client that has read every matching song should be able to ask for - /// the next page of songs without re-reading the artists beside them. - pub async fn search( - &self, - user_id: Uuid, - query: &str, - artists: BrowsePage, - albums: BrowsePage, - songs: BrowsePage, - ) -> Result { - // Prefix on the trailing term, like the Subsonic surface: a client - // querying on each keystroke would otherwise get nothing until the word - // is complete — "ech" returned zero results while "echo" returned the - // album. Native clients type incrementally just as Subsonic ones do. - let Some(fts) = crate::catalog::fts_prefix_query(query) else { - return Ok(SearchResult { - artists: Vec::new(), - albums: Vec::new(), - songs: Vec::new(), - }); - }; - let mut songs = sqlx::query(concat!( - song_select!(), - " AND t.id IN (SELECT track_id FROM track_fts WHERE track_fts MATCH ?) \ - ORDER BY t.title COLLATE NOCASE, t.id LIMIT ? OFFSET ?" - )) - .bind(user_id.to_string()) - .bind(&fts) - .bind(songs.limit) - .bind(songs.offset) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(song_from_row) - .collect::, _>>()?; - attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; - let mut albums = sqlx::query(concat!( - album_select!(), - " AND al.id IN (SELECT t.album_id FROM track t \ - WHERE t.album_id IS NOT NULL \ - AND t.id IN (SELECT track_id FROM track_fts WHERE track_fts MATCH ?)) \ - ORDER BY al.title COLLATE NOCASE, al.id LIMIT ? OFFSET ?" - )) - .bind(user_id.to_string()) - .bind(&fts) - .bind(albums.limit) - .bind(albums.offset) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(album_from_row) - .collect::, _>>()?; - attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; - let artists = sqlx::query(concat!( - artist_select!(), - " AND ar.id IN (SELECT artist_id FROM artist_fts WHERE artist_fts MATCH ?) \ - ORDER BY ar.name COLLATE NOCASE, ar.id LIMIT ? OFFSET ?" - )) - .bind(user_id.to_string()) - .bind(&fts) - .bind(artists.limit) - .bind(artists.offset) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(artist_from_row) - .collect::, _>>()?; - Ok(SearchResult { - artists, - albums, - songs, - }) - } - - /// Issues an authorization code for a native client. - /// - /// Validation, credential generation and persistence live here rather than - /// in the handler so the grant rules hold for every surface that ever - /// issues one, and so they can be exercised without an HTTP request. - /// Returns the URL the consent screen must send the user agent to. - pub async fn authorize_native_client( - &self, - user_id: Uuid, - request: AuthorizationRequest<'_>, - ) -> Result { - crate::oauth::validate_redirect_uri(request.redirect_uri) - .map_err(|_| ServiceError::Invalid)?; - crate::oauth::validate_challenge(request.code_challenge_method, request.code_challenge) - .map_err(|_| ServiceError::Invalid)?; - let client_id = request.client_id.trim(); - let device_name = request.device_name.trim(); - // Checked before the code exists: a name the session issuer would - // reject must not burn a grant the client can never redeem. - if client_id.is_empty() || device_name.is_empty() || device_name.len() > 120 { - return Err(ServiceError::Invalid); - } - - let code = security::generate_token("wfc_"); - let now = now_ms(); - self.db - .create_authorization(crate::database::NewAuthorization { - code_hash: security::token_hash(&code), - user_id, - client_id, - redirect_uri: request.redirect_uri, - code_challenge: request.code_challenge, - device_name, - now_ms: now, - expires_at: now + crate::oauth::AUTHORIZATION_CODE_TTL_MS, - scopes: request.scopes, - }) - .await?; - Ok(crate::oauth::redirect_with_code( - request.redirect_uri, - &code, - request.state, - )) - } - - pub async fn songs_by_ids( - &self, - user_id: Uuid, - ids: &[Uuid], - ) -> Result, ServiceError> { - let mut connection = self.db.pool().acquire().await?; - self.songs_by_ids_on(&mut connection, user_id, ids).await - } - - /// Lyrics for one visible, available track. A visible track with no lyrics - /// returns an empty list; an unknown or foreign track is blurred as not - /// found, matching the rest of the catalogue API. - pub async fn lyrics(&self, user_id: Uuid, track_id: Uuid) -> Result { - let rows = sqlx::query( - "SELECT t.id, t.title, t.artist_display, tl.lang, tl.synced, tl.content \ - FROM track t JOIN library_member m ON m.library_id=t.library_id \ - LEFT JOIN track_lyrics tl ON tl.track_id=t.id AND tl.library_id=t.library_id \ - WHERE m.user_id=? AND t.id=? AND t.is_available=1 \ - ORDER BY tl.position", - ) - .bind(user_id.to_string()) - .bind(track_id.to_string()) - .fetch_all(self.db.pool()) - .await?; - lyrics_list_from_rows(track_id, rows) - } - - /// Legacy Subsonic lookup by metadata. Matching stays tenant-scoped and - /// deterministic; it is intentionally exact because fuzzy catalogue - /// reconciliation is outside the v2 contract. - pub async fn lyrics_by_metadata( - &self, - user_id: Uuid, - artist: Option<&str>, - title: Option<&str>, - ) -> Result, ServiceError> { - let row = sqlx::query_scalar::<_, String>( - "SELECT t.id FROM track t \ - JOIN library_member m ON m.library_id=t.library_id \ - WHERE m.user_id=? AND t.is_available=1 \ - AND (? IS NULL OR t.artist_display = ? COLLATE NOCASE) \ - AND (? IS NULL OR t.title = ? COLLATE NOCASE) \ - AND EXISTS (SELECT 1 FROM track_lyrics tl WHERE tl.track_id=t.id) \ - ORDER BY t.title COLLATE NOCASE, t.id LIMIT 1", - ) - .bind(user_id.to_string()) - .bind(artist) - .bind(artist) - .bind(title) - .bind(title) - .fetch_optional(self.db.pool()) - .await?; - let track_id = row - .map(|id| { - Uuid::parse_str(&id) - .map_err(|error| ServiceError::Database(sqlx::Error::Decode(error.into()))) - }) - .transpose()?; - match track_id { - Some(track_id) => self.lyrics(user_id, track_id).await.map(Some), - None => Ok(None), - } - } - - pub async fn sync_snapshot( - &self, - user_id: Uuid, - history_limit: i64, - ) -> Result { - let mut tx = self.db.pool().begin().await?; - // A global watermark, read inside the same transaction as the rows - // below so nothing committed after it can be missed. - // - // Deliberately not this user's MAX: `changes` refuses cursors below the - // journal's global floor, so a per-user watermark would hand an account - // with no surviving events a cursor beneath that floor — it would - // re-snapshot, get the same cursor, be refused again, and loop. Filtering - // by user still happens in `changes`, so a global watermark only means - // "everything up to here is already in this snapshot". - let cursor = sqlx::query_scalar("SELECT COALESCE(MAX(cursor), 0) FROM sync_event") - .fetch_one(&mut *tx) - .await?; - let playlists = self.playlists_on(&mut tx, user_id).await?; - let favorites = self.starred_ids_on(&mut tx, user_id).await?; - let ratings = self.ratings_on(&mut tx, user_id).await?; - let queue = self.queue_on(&mut tx, user_id).await?; - let history = self.history_on(&mut tx, user_id, history_limit).await?; - let shares = self.shares_on(&mut tx, user_id).await?; - let bookmarks = self.bookmarks_on(&mut tx, user_id).await?; - tx.commit().await?; - Ok(SyncSnapshotData { - cursor, - playlists, - favorites, - ratings, - queue, - history, - shares, - bookmarks, - }) - } - - async fn songs_by_ids_on( - &self, - connection: &mut SqliteConnection, - user_id: Uuid, - ids: &[Uuid], - ) -> Result, ServiceError> { - let songs = self - .songs_by_ids_lenient_on(connection, user_id, ids) - .await?; - if songs.len() == ids.len() { - Ok(songs) - } else { - Err(ServiceError::NotFound) - } - } - - async fn songs_by_ids_lenient_on( - &self, - connection: &mut SqliteConnection, - user_id: Uuid, - ids: &[Uuid], - ) -> Result, ServiceError> { - if ids.is_empty() { - return Ok(Vec::new()); - } - let ids_json = serde_json::to_string(ids).map_err(|_| ServiceError::Invalid)?; - let rows = sqlx::query(concat!( - song_select!(), - " AND t.id IN (SELECT value FROM json_each(?))" - )) - .bind(user_id.to_string()) - .bind(ids_json) - .fetch_all(&mut *connection) - .await?; - let mut resolved = rows - .into_iter() - .map(song_from_row) - .collect::, _>>()?; - attach_song_relations(connection, user_id, &mut resolved).await?; - let available = resolved - .into_iter() - .map(|song| (song.id, song)) - .collect::>(); - Ok(ids - .iter() - .filter_map(|id| available.get(id).cloned()) - .collect()) - } - - pub async fn artwork_for_user( - &self, - user_id: Uuid, - id: &str, - ) -> Result, ServiceError> { - let row = sqlx::query( - "SELECT a.hash, a.format FROM artwork a WHERE a.hash=? AND EXISTS ( \ - SELECT 1 FROM track t JOIN library_member m ON m.library_id=t.library_id WHERE t.artwork_hash=a.hash AND m.user_id=? \ - UNION SELECT 1 FROM album al JOIN library_member m ON m.library_id=al.library_id WHERE al.artwork_hash=a.hash AND m.user_id=? \ - UNION SELECT 1 FROM artist ar JOIN library_member m ON m.library_id=ar.library_id WHERE ar.artwork_hash=a.hash AND m.user_id=? \ - ) UNION ALL SELECT a.hash, a.format FROM track t JOIN library_member m ON m.library_id=t.library_id JOIN artwork a ON a.hash=t.artwork_hash WHERE t.id=? AND m.user_id=? \ - UNION ALL SELECT a.hash, a.format FROM album al JOIN library_member m ON m.library_id=al.library_id JOIN artwork a ON a.hash=al.artwork_hash WHERE al.id=? AND m.user_id=? \ - UNION ALL SELECT a.hash, a.format FROM artist ar JOIN library_member m ON m.library_id=ar.library_id JOIN artwork a ON a.hash=ar.artwork_hash WHERE ar.id=? AND m.user_id=? LIMIT 1", - ) - .bind(id).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")?))) - .transpose() - .map_err(Into::into) - } - - pub async fn playlists(&self, user_id: Uuid) -> Result, ServiceError> { - let mut connection = self.db.pool().acquire().await?; - self.playlists_on(&mut connection, user_id).await - } - - async fn playlists_on( - &self, - connection: &mut SqliteConnection, - user_id: Uuid, - ) -> Result, ServiceError> { - let rows = sqlx::query( - "SELECT id, name, comment, public, created_at, updated_at FROM playlist \ - WHERE owner_user_id=? ORDER BY updated_at DESC, id", - ) - .bind(user_id.to_string()) - .fetch_all(&mut *connection) - .await?; - let mut result = Vec::with_capacity(rows.len()); - for row in rows { - let id = parse_uuid(row.try_get("id")?)?; - result.push(PlaylistItem { - id, - name: row.try_get("name")?, - comment: row.try_get("comment")?, - public: row.try_get::("public")? != 0, - created_at: row.try_get("created_at")?, - updated_at: row.try_get("updated_at")?, - songs: self.playlist_songs_on(connection, user_id, id).await?, - }); - } - Ok(result) - } - - pub async fn playlist(&self, user_id: Uuid, id: Uuid) -> Result { - let mut connection = self.db.pool().acquire().await?; - self.playlist_on(&mut connection, user_id, id).await - } - - async fn playlist_on( - &self, - connection: &mut SqliteConnection, - user_id: Uuid, - id: Uuid, - ) -> Result { - let row = sqlx::query( - "SELECT id, name, comment, public, created_at, updated_at FROM playlist \ - WHERE id=? AND owner_user_id=?", - ) - .bind(id.to_string()) - .bind(user_id.to_string()) - .fetch_optional(&mut *connection) - .await? - .ok_or(ServiceError::NotFound)?; - Ok(PlaylistItem { - id, - name: row.try_get("name")?, - comment: row.try_get("comment")?, - public: row.try_get::("public")? != 0, - created_at: row.try_get("created_at")?, - updated_at: row.try_get("updated_at")?, - songs: self.playlist_songs_on(connection, user_id, id).await?, - }) - } - - async fn playlist_songs_on( - &self, - connection: &mut SqliteConnection, - user_id: Uuid, - playlist_id: Uuid, - ) -> Result, ServiceError> { - let ids = self - .playlist_track_ids_on(connection, user_id, playlist_id) - .await?; - self.songs_by_ids_lenient_on(connection, user_id, &ids) - .await - } - - async fn playlist_track_ids_on( - &self, - connection: &mut SqliteConnection, - user_id: Uuid, - playlist_id: Uuid, - ) -> Result, ServiceError> { - sqlx::query_scalar::<_, String>( - "SELECT pt.track_id FROM playlist_track pt JOIN playlist p ON p.id=pt.playlist_id \ - WHERE p.id=? AND p.owner_user_id=? ORDER BY pt.position", - ) - .bind(playlist_id.to_string()) - .bind(user_id.to_string()) - .fetch_all(&mut *connection) - .await? - .into_iter() - .map(parse_uuid) - .collect::, _>>() - .map_err(Into::into) - } - - pub async fn create_playlist( - &self, - user_id: Uuid, - name: &str, - track_ids: &[Uuid], - ) -> Result { - self.create_playlist_with_context( - user_id, - name, - track_ids, - MutationContext::server_generated(), - ) - .await - } - - pub async fn create_playlist_with_context( - &self, - user_id: Uuid, - name: &str, - track_ids: &[Uuid], - context: MutationContext, - ) -> Result { - let intent = MutationIntent::new( - "create", - "playlist", - &serde_json::json!({ "name": name.trim(), "track_ids": track_ids }), - ); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = self - .sync - .claim_operation(&_writer, &mut tx, user_id, context, intent) - .await? - { - tx.rollback().await?; - validate_replay_type(&receipt, "playlist")?; - let id = receipt.result_entity_id.ok_or(ServiceError::Conflict)?; - drop(_writer); - return self.playlist(user_id, id).await; - } - validate_name(name)?; - self.songs_by_ids_on(&mut tx, user_id, track_ids).await?; - let id = Uuid::new_v4(); - let now = now_ms(); - sqlx::query("INSERT INTO playlist (id, owner_user_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)") - .bind(id.to_string()).bind(user_id.to_string()).bind(name.trim()).bind(now).bind(now) - .execute(&mut *tx).await?; - replace_playlist_tracks(&mut tx, id, track_ids, now).await?; - let receipt = self - .sync - .complete_operation( - &mut tx, - user_id, - context, - "playlist", - id, - "upsert", - &serde_json::json!({ - "id": id, - "name": name.trim(), - "track_ids": track_ids, - }), - Some(id), - ) - .await?; - tx.commit().await?; - drop(_writer); - self.sync.publish(user_id, receipt); - self.playlist(user_id, id).await - } - - #[allow(clippy::too_many_arguments)] - pub async fn update_playlist( - &self, - user_id: Uuid, - id: Uuid, - name: Option<&str>, - comment: Option<&str>, - public: Option, - add: &[Uuid], - remove_indexes: &[usize], - clear: PlaylistClear, - ) -> Result { - self.update_playlist_with_context( - user_id, - id, - name, - comment, - public, - add, - remove_indexes, - clear, - MutationContext::server_generated(), - ) - .await - } - - #[allow(clippy::too_many_arguments)] - pub async fn update_playlist_with_context( - &self, - user_id: Uuid, - id: Uuid, - name: Option<&str>, - comment: Option<&str>, - public: Option, - add: &[Uuid], - remove_indexes: &[usize], - clear: PlaylistClear, - context: MutationContext, - ) -> Result { - let mut removes = remove_indexes.to_vec(); - removes.sort_unstable_by(|a, b| b.cmp(a)); - removes.dedup(); - let mut intent_payload = serde_json::json!({ - "name": name.map(str::trim), - "comment": comment, - "public": public, - "add": add, - "remove_indexes": &removes, - "clear_comment": clear.comment, - }); - // Added to the payload only when set. The intent is hashed and compared - // on replay, so naming a new field unconditionally would change the - // hash of every update this server version ever saw before, and turn a - // client's retry across an upgrade into a conflict. - if clear.tracks { - intent_payload["clear_tracks"] = serde_json::Value::Bool(true); - } - let intent = MutationIntent::new("update", &format!("playlist:{id}"), &intent_payload); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = self - .sync - .claim_operation(&_writer, &mut tx, user_id, context, intent) - .await? - { - tx.rollback().await?; - validate_replay_type(&receipt, "playlist")?; - drop(_writer); - return self.playlist(user_id, id).await; - } - let current = self.playlist_on(&mut tx, user_id, id).await?; - if let Some(name) = name { - validate_name(name)?; - } - self.songs_by_ids_on(&mut tx, user_id, add).await?; - let mut ids = if clear.tracks { - Vec::new() - } else { - self.playlist_track_ids_on(&mut tx, user_id, id).await? - }; - for index in removes { - if index >= ids.len() { - return Err(ServiceError::Invalid); - } - ids.remove(index); - } - ids.extend_from_slice(add); - let changed_at = now_ms(); - sqlx::query( - "UPDATE playlist SET name=COALESCE(?, name), \ - comment=CASE WHEN ? THEN NULL ELSE COALESCE(?, comment) END, \ - public=COALESCE(?, public), updated_at=? WHERE id=? AND owner_user_id=?", - ) - .bind(name.map(str::trim)) - .bind(clear.comment) - .bind(comment) - .bind(public.map(i64::from)) - .bind(changed_at) - .bind(id.to_string()) - .bind(user_id.to_string()) - .execute(&mut *tx) - .await?; - sqlx::query("DELETE FROM playlist_track WHERE playlist_id=?") - .bind(id.to_string()) - .execute(&mut *tx) - .await?; - replace_playlist_tracks(&mut tx, id, &ids, changed_at).await?; - let receipt = self - .sync - .complete_operation( - &mut tx, - user_id, - context, - "playlist", - id, - "upsert", - &serde_json::json!({ - "id": id, - "name": name.map(str::trim).unwrap_or(¤t.name), - "comment": comment.or(current.comment.as_deref()), - "public": public.unwrap_or(current.public), - "track_ids": ids, - }), - Some(id), - ) - .await?; - tx.commit().await?; - drop(_writer); - self.sync.publish(user_id, receipt); - self.playlist(user_id, id).await - } - - pub async fn delete_playlist(&self, user_id: Uuid, id: Uuid) -> Result<(), ServiceError> { - self.delete_playlist_with_context(user_id, id, MutationContext::server_generated()) - .await - } - - pub async fn delete_playlist_with_context( - &self, - user_id: Uuid, - id: Uuid, - context: MutationContext, - ) -> Result<(), ServiceError> { - let intent = - MutationIntent::new("delete", &format!("playlist:{id}"), &serde_json::json!({})); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = self - .sync - .claim_operation(&_writer, &mut tx, user_id, context, intent) - .await? - { - tx.rollback().await?; - validate_replay_type(&receipt, "playlist")?; - return Ok(()); - } - let changed = sqlx::query("DELETE FROM playlist WHERE id=? AND owner_user_id=?") - .bind(id.to_string()) - .bind(user_id.to_string()) - .execute(&mut *tx) - .await? - .rows_affected(); - if changed == 0 { - tx.rollback().await?; - Err(ServiceError::NotFound) - } else { - let receipt = self - .sync - .complete_operation( - &mut tx, - user_id, - context, - "playlist", - id, - "delete", - &serde_json::json!({}), - Some(id), - ) - .await?; - tx.commit().await?; - self.sync.publish(user_id, receipt); - Ok(()) - } - } - - pub async fn set_star( - &self, - user_id: Uuid, - entity_type: &str, - entity_id: Uuid, - starred: bool, - ) -> Result<(), ServiceError> { - self.set_star_with_context( - user_id, - entity_type, - entity_id, - starred, - MutationContext::server_generated(), - ) - .await - } - - pub async fn set_star_with_context( - &self, - user_id: Uuid, - entity_type: &str, - entity_id: Uuid, - starred: bool, - context: MutationContext, - ) -> Result<(), ServiceError> { - let intent = MutationIntent::new( - if starred { "star" } else { "unstar" }, - &format!("{entity_type}:{entity_id}"), - &serde_json::json!({ "starred": starred }), - ); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = self - .sync - .claim_operation(&_writer, &mut tx, user_id, context, intent) - .await? - { - tx.rollback().await?; - validate_replay_type(&receipt, "favorite")?; - return Ok(()); - } - self.authorize_entity_on(&mut tx, user_id, entity_type, entity_id) - .await?; - if starred { - sqlx::query("INSERT INTO user_star (user_id, entity_type, entity_id, starred_at) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET starred_at=excluded.starred_at") - .bind(user_id.to_string()).bind(entity_type).bind(entity_id.to_string()).bind(now_ms()) - .execute(&mut *tx).await?; - } else { - sqlx::query("DELETE FROM user_star WHERE user_id=? AND entity_type=? AND entity_id=?") - .bind(user_id.to_string()) - .bind(entity_type) - .bind(entity_id.to_string()) - .execute(&mut *tx) - .await?; - } - let receipt = self - .sync - .complete_operation( - &mut tx, - user_id, - context, - "favorite", - entity_id, - if starred { "upsert" } else { "delete" }, - &serde_json::json!({ - "entity_type": entity_type, - "entity_id": entity_id, - "starred": starred, - }), - Some(entity_id), - ) - .await?; - tx.commit().await?; - self.sync.publish(user_id, receipt); - Ok(()) - } - - pub async fn entity_kind( - &self, - user_id: Uuid, - entity_id: Uuid, - ) -> Result, ServiceError> { - let kinds: Vec = sqlx::query_scalar( - "SELECT entity_type FROM (\ - SELECT 'track' AS entity_type FROM track t \ - JOIN library_member m ON m.library_id=t.library_id \ - WHERE t.id=? AND m.user_id=? \ - UNION ALL \ - SELECT 'album' FROM album a \ - JOIN library_member m ON m.library_id=a.library_id \ - WHERE a.id=? AND m.user_id=? \ - UNION ALL \ - SELECT 'artist' FROM artist ar \ - JOIN library_member m ON m.library_id=ar.library_id \ - WHERE ar.id=? AND m.user_id=? \ - )", - ) - .bind(entity_id.to_string()) - .bind(user_id.to_string()) - .bind(entity_id.to_string()) - .bind(user_id.to_string()) - .bind(entity_id.to_string()) - .bind(user_id.to_string()) - .fetch_all(self.db.pool()) - .await?; - if kinds.len() != 1 { - return Ok(None); - } - Ok(match kinds[0].as_str() { - "track" => Some("track"), - "album" => Some("album"), - "artist" => Some("artist"), - _ => None, - }) - } - - pub async fn starred_ids( - &self, - user_id: Uuid, - ) -> Result, ServiceError> { - let mut connection = self.db.pool().acquire().await?; - self.starred_ids_on(&mut connection, user_id).await - } - - async fn starred_ids_on( - &self, - connection: &mut SqliteConnection, - user_id: Uuid, - ) -> Result, ServiceError> { - sqlx::query( - "SELECT s.entity_type, s.entity_id, s.starred_at FROM user_star s \ - WHERE s.user_id=? AND ( \ - (s.entity_type='track' AND EXISTS (SELECT 1 FROM track e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=s.entity_id AND m.user_id=s.user_id)) OR \ - (s.entity_type='album' AND EXISTS (SELECT 1 FROM album e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=s.entity_id AND m.user_id=s.user_id)) OR \ - (s.entity_type='artist' AND EXISTS (SELECT 1 FROM artist e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=s.entity_id AND m.user_id=s.user_id)) \ - ) ORDER BY s.starred_at DESC", - ) - .bind(user_id.to_string()).fetch_all(&mut *connection).await? - .into_iter().map(|row| Ok((row.try_get("entity_type")?, parse_uuid(row.try_get("entity_id")?)?, row.try_get("starred_at")?))) - .collect::, sqlx::Error>>().map_err(Into::into) - } - - pub async fn ratings(&self, user_id: Uuid) -> Result, ServiceError> { - let mut connection = self.db.pool().acquire().await?; - self.ratings_on(&mut connection, user_id).await - } - - async fn ratings_on( - &self, - connection: &mut SqliteConnection, - user_id: Uuid, - ) -> Result, ServiceError> { - sqlx::query( - "SELECT r.entity_type, r.entity_id, r.rating, r.updated_at FROM user_rating r \ - WHERE r.user_id=? AND ( \ - (r.entity_type='track' AND EXISTS (SELECT 1 FROM track e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=r.entity_id AND m.user_id=r.user_id)) OR \ - (r.entity_type='album' AND EXISTS (SELECT 1 FROM album e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=r.entity_id AND m.user_id=r.user_id)) OR \ - (r.entity_type='artist' AND EXISTS (SELECT 1 FROM artist e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=r.entity_id AND m.user_id=r.user_id)) \ - ) ORDER BY r.updated_at DESC, r.entity_type, r.entity_id", - ) - .bind(user_id.to_string()) - .fetch_all(&mut *connection) - .await? - .into_iter() - .map(|row| { - Ok(RatingItem { - entity_type: row.try_get("entity_type")?, - entity_id: parse_uuid(row.try_get("entity_id")?)?, - rating: row.try_get("rating")?, - updated_at: row.try_get("updated_at")?, - }) - }) - .collect::, sqlx::Error>>() - .map_err(Into::into) - } - - pub async fn set_rating( - &self, - user_id: Uuid, - entity_type: &str, - entity_id: Uuid, - rating: i64, - ) -> Result<(), ServiceError> { - self.set_rating_with_context( - user_id, - entity_type, - entity_id, - rating, - MutationContext::server_generated(), - ) - .await - } - - pub async fn set_rating_with_context( - &self, - user_id: Uuid, - entity_type: &str, - entity_id: Uuid, - rating: i64, - context: MutationContext, - ) -> Result<(), ServiceError> { - let intent = MutationIntent::new( - "set-rating", - &format!("{entity_type}:{entity_id}"), - &serde_json::json!({ "rating": rating }), - ); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = self - .sync - .claim_operation(&_writer, &mut tx, user_id, context, intent) - .await? - { - tx.rollback().await?; - validate_replay_type(&receipt, "rating")?; - return Ok(()); - } - if !(0..=5).contains(&rating) { - return Err(ServiceError::Invalid); - } - self.authorize_entity_on(&mut tx, user_id, entity_type, entity_id) - .await?; - if rating == 0 { - sqlx::query( - "DELETE FROM user_rating WHERE user_id=? AND entity_type=? AND entity_id=?", - ) - .bind(user_id.to_string()) - .bind(entity_type) - .bind(entity_id.to_string()) - .execute(&mut *tx) - .await?; - } else { - sqlx::query("INSERT INTO user_rating (user_id, entity_type, entity_id, rating, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET rating=excluded.rating, updated_at=excluded.updated_at") - .bind(user_id.to_string()).bind(entity_type).bind(entity_id.to_string()).bind(rating).bind(now_ms()).execute(&mut *tx).await?; - } - let receipt = self - .sync - .complete_operation( - &mut tx, - user_id, - context, - "rating", - entity_id, - if rating == 0 { "delete" } else { "upsert" }, - &serde_json::json!({ - "entity_type": entity_type, - "entity_id": entity_id, - "rating": rating, - }), - Some(entity_id), - ) - .await?; - tx.commit().await?; - self.sync.publish(user_id, receipt); - Ok(()) - } - - pub async fn scrobble( - &self, - user_id: Uuid, - track_id: Uuid, - submission: bool, - time: Option, - ) -> Result<(), ServiceError> { - self.scrobble_with_context( - user_id, - track_id, - submission, - time, - MutationContext::server_generated(), - ) - .await - } - - pub async fn scrobble_with_context( - &self, - user_id: Uuid, - track_id: Uuid, - submission: bool, - time: Option, - context: MutationContext, - ) -> Result<(), ServiceError> { - let intent = MutationIntent::new( - if submission { - "scrobble" - } else { - "now-playing" - }, - &format!("track:{track_id}"), - &serde_json::json!({ "submission": submission, "time": time }), - ); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = self - .sync - .claim_operation(&_writer, &mut tx, user_id, context, intent) - .await? - { - tx.rollback().await?; - validate_replay_type(&receipt, "scrobble")?; - return Ok(()); - } - self.authorize_entity_on(&mut tx, user_id, "track", track_id) - .await?; - let current_time = now_ms(); - let now = time.unwrap_or(current_time); - const MAX_FUTURE_SKEW_MS: i64 = 5 * 60 * 1_000; - if now < 0 || now > current_time.saturating_add(MAX_FUTURE_SKEW_MS) { - return Err(ServiceError::Invalid); - } - sqlx::query( - "INSERT INTO play_event (user_id, track_id, submission, played_at) VALUES (?, ?, ?, ?)", - ) - .bind(user_id.to_string()) - .bind(track_id.to_string()) - .bind(i64::from(submission)) - .bind(now) - .execute(&mut *tx) - .await?; - if submission { - sqlx::query("DELETE FROM now_playing WHERE user_id=?") - .bind(user_id.to_string()) - .execute(&mut *tx) - .await?; - } else { - sqlx::query("INSERT INTO now_playing (user_id, track_id, started_at, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (user_id) DO UPDATE SET track_id=excluded.track_id, started_at=excluded.started_at, updated_at=excluded.updated_at") - .bind(user_id.to_string()).bind(track_id.to_string()).bind(now).bind(now_ms()).execute(&mut *tx).await?; - } - let receipt = self - .sync - .complete_operation( - &mut tx, - user_id, - context, - "scrobble", - track_id, - if submission { "append" } else { "upsert" }, - &serde_json::json!({ - "track_id": track_id, - "submission": submission, - "played_at": now, - }), - Some(track_id), - ) - .await?; - tx.commit().await?; - self.sync.publish(user_id, receipt); - Ok(()) - } - - pub async fn now_playing( - &self, - user_id: Uuid, - ) -> Result, ServiceError> { - let rows = sqlx::query( - "SELECT a.username, n.track_id, n.started_at FROM now_playing n \ - JOIN account a ON a.id=n.user_id WHERE a.disabled=0 ORDER BY n.started_at DESC", - ) - .fetch_all(self.db.pool()) - .await?; - let mut result = Vec::new(); - for row in rows { - let id = parse_uuid(row.try_get("track_id")?)?; - match self.songs_by_ids(user_id, &[id]).await { - Ok(mut songs) => { - if let Some(song) = songs.pop() { - result.push((row.try_get("username")?, song, row.try_get("started_at")?)); - } - } - Err(ServiceError::NotFound) => continue, - Err(error) => return Err(error), - } - } - Ok(result) - } - - pub async fn history( - &self, - user_id: Uuid, - limit: i64, - ) -> Result, ServiceError> { - let mut connection = self.db.pool().acquire().await?; - self.history_on(&mut connection, user_id, limit).await - } - - async fn history_on( - &self, - connection: &mut SqliteConnection, - user_id: Uuid, - limit: i64, - ) -> Result, ServiceError> { - if !(0..=MAX_HISTORY_LIMIT).contains(&limit) { - return Err(ServiceError::Invalid); - } - sqlx::query( - "SELECT p.track_id, p.submission, p.played_at FROM play_event p \ - JOIN track t ON t.id=p.track_id JOIN library_member m ON m.library_id=t.library_id \ - WHERE p.user_id=? AND m.user_id=? ORDER BY p.played_at DESC, p.id DESC LIMIT ?", - ) - .bind(user_id.to_string()) - .bind(user_id.to_string()) - .bind(limit) - .fetch_all(&mut *connection) - .await? - .into_iter() - .map(|row| { - Ok(HistoryItem { - track_id: parse_uuid(row.try_get("track_id")?)?, - submission: row.try_get::("submission")? != 0, - played_at: row.try_get("played_at")?, - }) - }) - .collect::, sqlx::Error>>() - .map_err(Into::into) - } - - pub async fn save_queue( - &self, - user_id: Uuid, - ids: &[Uuid], - current: Option, - position_ms: i64, - client: Option<&str>, - ) -> Result<(), ServiceError> { - self.save_queue_with_context( - user_id, - ids, - current, - position_ms, - client, - MutationContext::server_generated(), - ) - .await - } - - #[allow(clippy::too_many_arguments)] - pub async fn save_queue_with_context( - &self, - user_id: Uuid, - ids: &[Uuid], - current: Option, - position_ms: i64, - client: Option<&str>, - context: MutationContext, - ) -> Result<(), ServiceError> { - let intent = MutationIntent::new( - "save", - &format!("queue:{user_id}"), - &serde_json::json!({ - "track_ids": ids, - "current": current, - "position_ms": position_ms, - "client": client, - }), - ); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = self - .sync - .claim_operation(&_writer, &mut tx, user_id, context, intent) - .await? - { - tx.rollback().await?; - validate_replay_type(&receipt, "queue")?; - return Ok(()); - } - if ids.len() > MAX_QUEUE_TRACKS { - return Err(ServiceError::Invalid); - } - if position_ms < 0 { - return Err(ServiceError::Invalid); - } - self.songs_by_ids_on(&mut tx, user_id, ids).await?; - if let Some(current) = current { - self.songs_by_ids_on(&mut tx, user_id, &[current]).await?; - } - sqlx::query("INSERT INTO play_queue (user_id, current_track_id, position_ms, changed_by, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (user_id) DO UPDATE SET current_track_id=excluded.current_track_id, position_ms=excluded.position_ms, changed_by=excluded.changed_by, updated_at=excluded.updated_at") - .bind(user_id.to_string()).bind(current.map(|id| id.to_string())).bind(position_ms).bind(client).bind(now_ms()).execute(&mut *tx).await?; - sqlx::query("DELETE FROM play_queue_track WHERE user_id=?") - .bind(user_id.to_string()) - .execute(&mut *tx) - .await?; - if !ids.is_empty() { - let ids_json = serde_json::to_string(ids).map_err(|_| ServiceError::Invalid)?; - sqlx::query( - "INSERT INTO play_queue_track (user_id, track_id, position) \ - SELECT ?, value, CAST(key AS INTEGER) FROM json_each(?)", - ) - .bind(user_id.to_string()) - .bind(ids_json) - .execute(&mut *tx) - .await?; - } - let receipt = self - .sync - .complete_operation( - &mut tx, - user_id, - context, - "queue", - user_id, - "upsert", - &serde_json::json!({ - "track_ids": ids, - "current": current, - "position_ms": position_ms, - "client": client, - }), - Some(user_id), - ) - .await?; - tx.commit().await?; - self.sync.publish(user_id, receipt); - Ok(()) - } - - pub async fn queue(&self, user_id: Uuid) -> Result, ServiceError> { - let mut connection = self.db.pool().acquire().await?; - self.queue_on(&mut connection, user_id).await - } - - async fn queue_on( - &self, - connection: &mut SqliteConnection, - user_id: Uuid, - ) -> Result, ServiceError> { - let row = sqlx::query("SELECT current_track_id, position_ms, changed_by, updated_at FROM play_queue WHERE user_id=?") - .bind(user_id.to_string()).fetch_optional(&mut *connection).await?; - let Some(row) = row else { - return Ok(None); - }; - let ids = sqlx::query_scalar::<_, String>( - "SELECT track_id FROM play_queue_track WHERE user_id=? ORDER BY position", - ) - .bind(user_id.to_string()) - .fetch_all(&mut *connection) - .await? - .into_iter() - .map(parse_uuid) - .collect::, _>>()?; - Ok(Some(QueueItem { - current: row - .try_get::, _>("current_track_id")? - .map(parse_uuid) - .transpose()?, - position_ms: row.try_get("position_ms")?, - changed_by: row.try_get("changed_by")?, - updated_at: row.try_get("updated_at")?, - songs: self - .songs_by_ids_lenient_on(connection, user_id, &ids) - .await?, - })) - } - - /// Bookmarks the user has set, most recently changed first. - pub async fn bookmarks(&self, user_id: Uuid) -> Result, ServiceError> { - let mut connection = self.db.pool().acquire().await?; - self.bookmarks_on(&mut connection, user_id).await - } - - async fn bookmarks_on( - &self, - connection: &mut SqliteConnection, - user_id: Uuid, - ) -> Result, ServiceError> { - // Joined against `song_select!` so a bookmark on a track that has become - // unavailable, or on a library the account has lost, simply stops being - // listed rather than being returned pointing at nothing. - let rows = sqlx::query(concat!( - "SELECT b.position_ms, b.comment AS bookmark_comment, \ - b.created_at AS bookmark_created_at, b.updated_at AS bookmark_updated_at, \ - song.* FROM bookmark b JOIN (", - song_select!(), - ") AS song ON song.id=b.track_id \ - WHERE b.user_id=? ORDER BY b.updated_at DESC, song.id" - )) - .bind(user_id.to_string()) - .bind(user_id.to_string()) - .fetch_all(&mut *connection) - .await?; - let mut bookmarks = Vec::with_capacity(rows.len()); - for row in rows { - bookmarks.push(BookmarkItem { - position_ms: row.try_get("position_ms")?, - comment: row.try_get("bookmark_comment")?, - created_at: row.try_get("bookmark_created_at")?, - updated_at: row.try_get("bookmark_updated_at")?, - song: song_from_row(row)?, - }); - } - let mut songs = bookmarks - .iter() - .map(|bookmark| bookmark.song.clone()) - .collect::>(); - attach_song_relations(&mut *connection, user_id, &mut songs).await?; - for (bookmark, song) in bookmarks.iter_mut().zip(songs) { - bookmark.song = song; - } - Ok(bookmarks) - } - - pub async fn set_bookmark( - &self, - user_id: Uuid, - track_id: Uuid, - position_ms: i64, - comment: Option<&str>, - ) -> Result<(), ServiceError> { - self.set_bookmark_with_context( - user_id, - track_id, - position_ms, - comment, - MutationContext::server_generated(), - ) - .await - } - - /// Sets, or moves, the bookmark on one track. - /// - /// A bookmark answers "where did I stop in this file", so there is one per - /// account and track and a second call moves it rather than adding another. - pub async fn set_bookmark_with_context( - &self, - user_id: Uuid, - track_id: Uuid, - position_ms: i64, - comment: Option<&str>, - context: MutationContext, - ) -> Result<(), ServiceError> { - if position_ms < 0 { - return Err(ServiceError::Invalid); - } - let intent = MutationIntent::new( - "set-bookmark", - &format!("bookmark:{track_id}"), - &serde_json::json!({ "position_ms": position_ms, "comment": comment }), - ); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = self - .sync - .claim_operation(&_writer, &mut tx, user_id, context, intent) - .await? - { - tx.rollback().await?; - validate_replay_type(&receipt, "bookmark")?; - return Ok(()); - } - self.authorize_entity_on(&mut tx, user_id, "track", track_id) - .await?; - let now = now_ms(); - sqlx::query( - "INSERT INTO bookmark (user_id, track_id, position_ms, comment, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?) \ - ON CONFLICT (user_id, track_id) DO UPDATE SET position_ms=excluded.position_ms, \ - comment=excluded.comment, updated_at=excluded.updated_at", - ) - .bind(user_id.to_string()) - .bind(track_id.to_string()) - .bind(position_ms) - .bind(comment) - .bind(now) - .bind(now) - .execute(&mut *tx) - .await?; - let receipt = self - .sync - .complete_operation( - &mut tx, - user_id, - context, - "bookmark", - track_id, - "upsert", - &serde_json::json!({ - "track_id": track_id, - "position_ms": position_ms, - "comment": comment, - }), - Some(track_id), - ) - .await?; - tx.commit().await?; - self.sync.publish(user_id, receipt); - Ok(()) - } - - pub async fn delete_bookmark(&self, user_id: Uuid, track_id: Uuid) -> Result<(), ServiceError> { - self.delete_bookmark_with_context(user_id, track_id, MutationContext::server_generated()) - .await - } - - /// Removes the bookmark on one track. - /// - /// Removing one that is not there succeeds: the caller asked for the track - /// to carry no bookmark, and it does not. Reporting not-found would also - /// answer a question about another account's catalogue, which the rest of - /// the surface refuses to do. - pub async fn delete_bookmark_with_context( - &self, - user_id: Uuid, - track_id: Uuid, - context: MutationContext, - ) -> Result<(), ServiceError> { - let intent = MutationIntent::new( - "delete-bookmark", - &format!("bookmark:{track_id}"), - &serde_json::json!({}), - ); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = self - .sync - .claim_operation(&_writer, &mut tx, user_id, context, intent) - .await? - { - tx.rollback().await?; - validate_replay_type(&receipt, "bookmark")?; - return Ok(()); - } - self.authorize_entity_on(&mut tx, user_id, "track", track_id) - .await?; - sqlx::query("DELETE FROM bookmark WHERE user_id=? AND track_id=?") - .bind(user_id.to_string()) - .bind(track_id.to_string()) - .execute(&mut *tx) - .await?; - let receipt = self - .sync - .complete_operation( - &mut tx, - user_id, - context, - "bookmark", - track_id, - "delete", - &serde_json::json!({ "track_id": track_id }), - Some(track_id), - ) - .await?; - tx.commit().await?; - self.sync.publish(user_id, receipt); - Ok(()) - } - - pub async fn shares(&self, user_id: Uuid) -> Result, ServiceError> { - let mut connection = self.db.pool().acquire().await?; - self.shares_on(&mut connection, user_id).await - } - - async fn shares_on( - &self, - connection: &mut SqliteConnection, - user_id: Uuid, - ) -> Result, ServiceError> { - let rows = sqlx::query("SELECT id, description, expires_at, created_at, visit_count FROM share WHERE owner_user_id=? ORDER BY created_at DESC") - .bind(user_id.to_string()).fetch_all(&mut *connection).await?; - let track_rows = sqlx::query( - "SELECT st.share_id, st.track_id FROM share_track st \ - JOIN share s ON s.id=st.share_id WHERE s.owner_user_id=? \ - ORDER BY st.share_id, st.position", - ) - .bind(user_id.to_string()) - .fetch_all(&mut *connection) - .await?; - let mut track_owners = Vec::with_capacity(track_rows.len()); - let mut track_ids = Vec::with_capacity(track_rows.len()); - for track_row in track_rows { - track_owners.push(parse_uuid(track_row.try_get("share_id")?)?); - track_ids.push(parse_uuid(track_row.try_get("track_id")?)?); - } - let songs = self - .songs_by_ids_lenient_on(connection, user_id, &track_ids) - .await? - .into_iter() - .map(|song| (song.id, song)) - .collect::>(); - let mut songs_by_share = HashMap::>::new(); - for (share_id, track_id) in track_owners.into_iter().zip(track_ids) { - if let Some(song) = songs.get(&track_id) { - songs_by_share - .entry(share_id) - .or_default() - .push(song.clone()); - } - } - - let mut shares = Vec::with_capacity(rows.len()); - for row in rows { - let id = parse_uuid(row.try_get("id")?)?; - shares.push(ShareItem { - id, - owner_id: user_id, - url_token: None, - description: row.try_get("description")?, - expires_at: row.try_get("expires_at")?, - created_at: row.try_get("created_at")?, - visit_count: row.try_get("visit_count")?, - songs: songs_by_share.remove(&id).unwrap_or_default(), - }); - } - Ok(shares) - } - - pub async fn create_share( - &self, - user_id: Uuid, - ids: &[Uuid], - description: Option<&str>, - expires_at: Option, - ) -> Result { - self.create_share_with_context( - user_id, - ids, - description, - expires_at, - MutationContext::server_generated(), - ) - .await - } - - pub async fn create_share_with_context( - &self, - user_id: Uuid, - ids: &[Uuid], - description: Option<&str>, - expires_at: Option, - context: MutationContext, - ) -> Result { - let intent = MutationIntent::new( - "create", - "share", - &serde_json::json!({ - "track_ids": ids, - "description": description, - "expires_at": expires_at, - }), - ); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = self - .sync - .claim_operation(&_writer, &mut tx, user_id, context, intent) - .await? - { - tx.rollback().await?; - validate_replay_type(&receipt, "share")?; - let id = receipt.result_entity_id.ok_or(ServiceError::Conflict)?; - drop(_writer); - let mut share = self - .shares(user_id) - .await? - .into_iter() - .find(|share| share.id == id) - .ok_or(ServiceError::NotFound)?; - share.url_token = Some(self.secret_box.derive_share_token(id)); - return Ok(share); - } - if ids.is_empty() || ids.len() > MAX_SHARE_TRACKS { - return Err(ServiceError::Invalid); - } - let songs = self.songs_by_ids_on(&mut tx, user_id, ids).await?; - let id = Uuid::new_v4(); - let token = self.secret_box.derive_share_token(id); - let token_hash = security::token_hash(&token); - let now = now_ms(); - sqlx::query("INSERT INTO share (id, owner_user_id, token_hash, description, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)") - .bind(id.to_string()).bind(user_id.to_string()).bind(token_hash.as_slice()).bind(description).bind(expires_at).bind(now).bind(now).execute(&mut *tx).await?; - for (position, track) in ids.iter().enumerate() { - sqlx::query("INSERT INTO share_track (share_id, track_id, position) VALUES (?, ?, ?)") - .bind(id.to_string()) - .bind(track.to_string()) - .bind(position as i64) - .execute(&mut *tx) - .await?; - } - let receipt = self - .sync - .complete_operation( - &mut tx, - user_id, - context, - "share", - id, - "upsert", - &serde_json::json!({ - "id": id, - "track_ids": ids, - "description": description, - "expires_at": expires_at, - }), - Some(id), - ) - .await?; - tx.commit().await?; - drop(_writer); - self.sync.publish(user_id, receipt); - Ok(ShareItem { - id, - owner_id: user_id, - url_token: Some(token), - description: description.map(str::to_owned), - expires_at, - created_at: now, - visit_count: 0, - songs, - }) - } - - pub async fn public_share(&self, token: &str) -> Result { - let hash = security::token_hash(token); - let row = sqlx::query("SELECT id, owner_user_id, description, expires_at, created_at, visit_count FROM share WHERE token_hash=? AND (expires_at IS NULL OR expires_at>?)") - .bind(hash.as_slice()).bind(now_ms()).fetch_optional(self.db.pool()).await?.ok_or(ServiceError::NotFound)?; - let id = parse_uuid(row.try_get("id")?)?; - let owner = parse_uuid(row.try_get("owner_user_id")?)?; - let ids = sqlx::query_scalar::<_, String>( - "SELECT track_id FROM share_track WHERE share_id=? ORDER BY position", - ) - .bind(id.to_string()) - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(parse_uuid) - .collect::, _>>()?; - // The rows above were read outside the writer gate, and acquiring it can - // block behind a scan. Re-check both revocation and expiry at write - // time: no affected row means the share died during that wait, and a - // visitor must not see what an owner deleted or let expire. - let _writer = self.db.writer_guard().await; - let visited_at = now_ms(); - let visited = sqlx::query( - "UPDATE share SET visit_count=visit_count+1, last_visited_at=? \ - WHERE id=? AND (expires_at IS NULL OR expires_at>?)", - ) - .bind(visited_at) - .bind(id.to_string()) - .bind(visited_at) - .execute(self.db.pool()) - .await? - .rows_affected(); - drop(_writer); - if visited == 0 { - return Err(ServiceError::NotFound); - } - Ok(ShareItem { - id, - owner_id: owner, - url_token: None, - description: row.try_get("description")?, - expires_at: row.try_get("expires_at")?, - created_at: row.try_get("created_at")?, - visit_count: row.try_get::("visit_count")? + 1, - songs: self.songs_by_ids(owner, &ids).await?, - }) - } - - pub async fn update_share( - &self, - user_id: Uuid, - id: Uuid, - description: Option<&str>, - expires_at: Option, - clear: ShareClear, - ) -> Result { - self.update_share_with_context( - user_id, - id, - description, - expires_at, - clear, - MutationContext::server_generated(), - ) - .await - } - - pub async fn update_share_with_context( - &self, - user_id: Uuid, - id: Uuid, - description: Option<&str>, - expires_at: Option, - clear: ShareClear, - context: MutationContext, - ) -> Result { - // Clearing must be part of the intent: "set expiry to X" and "remove the - // expiry" are different mutations, and an operation id replayed across - // both has to be rejected rather than silently treated as the same. - let intent = MutationIntent::new( - "update", - &format!("share:{id}"), - &serde_json::json!({ - "description": description, - "expires_at": expires_at, - "clear_description": clear.description, - "clear_expires_at": clear.expires_at, - }), - ); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = self - .sync - .claim_operation(&_writer, &mut tx, user_id, context, intent) - .await? - { - tx.rollback().await?; - validate_replay_type(&receipt, "share")?; - drop(_writer); - return self - .shares(user_id) - .await? - .into_iter() - .find(|share| share.id == id) - .ok_or(ServiceError::NotFound); - } - let persisted = sqlx::query( - "UPDATE share SET \ - description=CASE WHEN ? THEN NULL ELSE COALESCE(?, description) END, \ - expires_at=CASE WHEN ? THEN NULL ELSE COALESCE(?, expires_at) END, \ - updated_at=? \ - WHERE id=? AND owner_user_id=? RETURNING description, expires_at", - ) - .bind(clear.description) - .bind(description) - .bind(clear.expires_at) - .bind(expires_at) - .bind(now_ms()) - .bind(id.to_string()) - .bind(user_id.to_string()) - .fetch_optional(&mut *tx) - .await?; - let Some(persisted) = persisted else { - tx.rollback().await?; - return Err(ServiceError::NotFound); - }; - let persisted_description: Option = persisted.try_get("description")?; - let persisted_expires_at: Option = persisted.try_get("expires_at")?; - let receipt = self - .sync - .complete_operation( - &mut tx, - user_id, - context, - "share", - id, - "upsert", - &serde_json::json!({ - "id": id, - "description": persisted_description, - "expires_at": persisted_expires_at, - }), - Some(id), - ) - .await?; - tx.commit().await?; - drop(_writer); - self.sync.publish(user_id, receipt); - self.shares(user_id) - .await? - .into_iter() - .find(|share| share.id == id) - .ok_or(ServiceError::NotFound) - } - - pub async fn delete_share(&self, user_id: Uuid, id: Uuid) -> Result<(), ServiceError> { - self.delete_share_with_context(user_id, id, MutationContext::server_generated()) - .await - } - - pub async fn delete_share_with_context( - &self, - user_id: Uuid, - id: Uuid, - context: MutationContext, - ) -> Result<(), ServiceError> { - let intent = MutationIntent::new("delete", &format!("share:{id}"), &serde_json::json!({})); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - if let OperationClaim::Replayed(receipt) = self - .sync - .claim_operation(&_writer, &mut tx, user_id, context, intent) - .await? - { - tx.rollback().await?; - validate_replay_type(&receipt, "share")?; - return Ok(()); - } - let changed = sqlx::query("DELETE FROM share WHERE id=? AND owner_user_id=?") - .bind(id.to_string()) - .bind(user_id.to_string()) - .execute(&mut *tx) - .await? - .rows_affected(); - if changed == 0 { - tx.rollback().await?; - Err(ServiceError::NotFound) - } else { - let receipt = self - .sync - .complete_operation( - &mut tx, - user_id, - context, - "share", - id, - "delete", - &serde_json::json!({}), - Some(id), - ) - .await?; - tx.commit().await?; - self.sync.publish(user_id, receipt); - Ok(()) - } - } - - pub async fn users(&self, actor_id: Uuid) -> Result, ServiceError> { - self.require_admin(actor_id).await?; - let mut users = sqlx::query("SELECT a.id, a.username, a.role, a.disabled, c.user_id IS NOT NULL AS has_credential FROM account a LEFT JOIN subsonic_credential c ON c.user_id=a.id ORDER BY a.username COLLATE NOCASE") - .fetch_all(self.db.pool()).await?.into_iter().map(|row| Ok(UserItem { id: parse_uuid(row.try_get("id")?)?, username: row.try_get("username")?, role: AccountRole::from_str(row.try_get::<&str, _>("role")?).map_err(|error| sqlx::Error::Decode(error.into()))?, disabled: row.try_get::("disabled")? != 0, has_subsonic_credential: row.try_get::("has_credential")? != 0, folder_ids: Vec::new() })).collect::, sqlx::Error>>()?; - let memberships = sqlx::query( - "SELECT user_id, library_id FROM library_member ORDER BY user_id, library_id", - ) - .fetch_all(self.db.pool()) - .await?; - for row in memberships { - let user_id = parse_uuid(row.try_get("user_id")?)?; - let library_id = parse_uuid(row.try_get("library_id")?)?; - if let Some(user) = users.iter_mut().find(|user| user.id == user_id) { - user.folder_ids.push(library_id); - } - } - Ok(users) - } - - pub async fn create_web_user( - &self, - actor_id: Uuid, - username: &str, - password: &str, - role: AccountRole, - ) -> Result { - self.require_admin(actor_id).await?; - validate_username(username)?; - if password.len() < 12 { - return Err(ServiceError::Invalid); - } - let password = password.to_owned(); - let password_hash = tokio::task::spawn_blocking(move || security::hash_password(&password)) - .await - .map_err(|_| ServiceError::Unavailable)??; - let id = self - .db - .create_account(username.trim(), &password_hash, role, now_ms()) - .await - .map_err(|error| { - if matches!(error, sqlx::Error::Database(ref db) if db.is_unique_violation()) { - ServiceError::Conflict - } else { - ServiceError::Database(error) - } - })?; - self.users(actor_id) - .await? - .into_iter() - .find(|user| user.id == id) - .ok_or(ServiceError::NotFound) - } - - /// Sets a dedicated Subsonic password and rotates the API key. The clear - /// API key is returned once; only its hash is persisted. - pub async fn set_subsonic_credential( - &self, - actor_id: Uuid, - username: &str, - password: &str, - ) -> Result { - self.require_admin(actor_id).await?; - if password.len() < 12 { - return Err(ServiceError::Invalid); - } - let account = self - .db - .account_by_username(username) - .await? - .ok_or(ServiceError::NotFound)?; - let encrypted = self.secret_box.encrypt(password.as_bytes())?; - let api_key = security::generate_token("wfsk_"); - let api_key_hash = security::token_hash(&api_key); - self.db - .set_subsonic_credential(actor_id, account.id, &encrypted, &api_key_hash, now_ms()) - .await?; - Ok(api_key) - } - - pub async fn revoke_subsonic_credential( - &self, - actor_id: Uuid, - username: &str, - ) -> Result<(), ServiceError> { - self.require_admin(actor_id).await?; - let account = self - .db - .account_by_username(username) - .await? - .ok_or(ServiceError::NotFound)?; - if self - .db - .revoke_subsonic_credential(actor_id, account.id, now_ms()) - .await? - { - Ok(()) - } else { - Err(ServiceError::NotFound) - } - } - - /// The API tokens issued to one account. - /// - /// Administrative like the Subsonic credential routes beside it: a token - /// carries the authority of the account it belongs to, so who may mint one - /// is a question about the instance, not about the account itself. - pub async fn api_tokens( - &self, - actor_id: Uuid, - username: &str, - ) -> Result, ServiceError> { - self.require_admin(actor_id).await?; - let account = self - .db - .account_by_username(username) - .await? - .ok_or(ServiceError::NotFound)?; - Ok(self.db.api_tokens_for_user(account.id).await?) - } - - /// Issues a token and returns it beside its record. - /// - /// The secret is returned once and stored only as a SHA-256 hash, exactly - /// as `set_subsonic_credential` returns its API key: a caller that loses it - /// issues another one rather than reading it back. - pub async fn create_api_token( - &self, - actor_id: Uuid, - username: &str, - name: &str, - scopes: &[String], - ) -> Result<(ApiTokenRecord, String), ServiceError> { - self.require_admin(actor_id).await?; - let name = name.trim(); - if name.is_empty() || name.chars().count() > 120 { - return Err(ServiceError::Invalid); - } - // Normalised on the way in, so the value a listing shows is the value - // authorization compares. Trimming at the check instead would let a - // stored `" admin "` grant what a reader of the listing would not - // expect it to. - let scopes = scopes - .iter() - .map(|scope| scope.trim().to_owned()) - .collect::>(); - if scopes.iter().any(String::is_empty) { - return Err(ServiceError::Invalid); - } - let account = self - .db - .account_by_username(username) - .await? - .ok_or(ServiceError::NotFound)?; - let token = security::generate_token("wfapi_"); - let record = self - .db - .create_api_token( - account.id, - name, - &security::token_hash(&token), - &scopes, - now_ms(), - ) - .await?; - Ok((record, token)) - } - - /// Revokes one token of one account. - /// - /// A token that is not this account's, or is already revoked, answers as a - /// missing one: the caller asked for it to stop working, and naming the - /// wrong owner must not confirm that it exists elsewhere. - pub async fn revoke_api_token( - &self, - actor_id: Uuid, - username: &str, - token_id: Uuid, - ) -> Result<(), ServiceError> { - self.require_admin(actor_id).await?; - let account = self - .db - .account_by_username(username) - .await? - .ok_or(ServiceError::NotFound)?; - if self - .db - .revoke_api_token(actor_id, account.id, token_id, now_ms()) - .await? - { - Ok(()) - } else { - Err(ServiceError::NotFound) - } - } - - pub async fn create_subsonic_user( - &self, - actor_id: Uuid, - username: &str, - password: &str, - admin: bool, - folder_ids: Option<&[Uuid]>, - ) -> Result { - self.require_admin(actor_id).await?; - validate_name(username)?; - if password.is_empty() { - return Err(ServiceError::Invalid); - } - let placeholder = security::generate_token("web-disabled-"); - let password_hash = - tokio::task::spawn_blocking(move || security::hash_password(&placeholder)) - .await - .map_err(|_| ServiceError::Unavailable)??; - let encrypted = self.secret_box.encrypt(password.as_bytes())?; - let api_key = security::generate_token("wfsk_"); - let api_key_hash = security::token_hash(&api_key); - let requested_folders = self.resolve_library_ids(folder_ids).await?; - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - let user_id = Uuid::new_v4(); - let now = now_ms(); - let insert = sqlx::query( - "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?)", - ) - .bind(user_id.to_string()) - .bind(username.trim()) - .bind(password_hash) - .bind(if admin { "admin" } else { "user" }) - .bind(now) - .bind(now) - .execute(&mut *tx) - .await; - if let Err(error) = insert { - return Err( - if matches!(error, sqlx::Error::Database(ref db) if db.is_unique_violation()) { - ServiceError::Conflict - } else { - ServiceError::Database(error) - }, - ); - } - sqlx::query( - "INSERT INTO subsonic_credential \ - (user_id, password_nonce, password_ciphertext, api_key_hash, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?)", - ) - .bind(user_id.to_string()) - .bind(encrypted.nonce.as_slice()) - .bind(encrypted.ciphertext) - .bind(api_key_hash.as_slice()) - .bind(now) - .bind(now) - .execute(&mut *tx) - .await?; - for library_id in requested_folders { - sqlx::query( - "INSERT INTO library_member (library_id, user_id, role, created_at) \ - VALUES (?, ?, 'listener', ?)", - ) - .bind(library_id.to_string()) - .bind(user_id.to_string()) - .bind(now) - .execute(&mut *tx) - .await?; - } - sqlx::query( - "INSERT INTO audit_event (actor_user_id, kind, subject_id, occurred_at) \ - VALUES (?, 'subsonic.user_created', ?, ?)", - ) - .bind(actor_id.to_string()) - .bind(user_id.to_string()) - .bind(now) - .execute(&mut *tx) - .await?; - tx.commit().await?; - drop(_writer); - self.users(actor_id) - .await? - .into_iter() - .find(|user| user.id == user_id) - .ok_or(ServiceError::NotFound) - } - - pub async fn update_user( - &self, - actor_id: Uuid, - username: &str, - update: UserUpdate<'_>, - ) -> Result { - self.require_admin(actor_id).await?; - if update.subsonic_password.is_some_and(str::is_empty) - || update - .web_password - .is_some_and(|password| password.len() < 12) - { - return Err(ServiceError::Invalid); - } - let account = self - .db - .account_by_username(username) - .await? - .ok_or(ServiceError::NotFound)?; - if account.id == actor_id && (update.admin == Some(false) || update.disabled == Some(true)) - { - return Err(ServiceError::Forbidden); - } - let requested_folders = match update.folder_ids { - Some(ids) => Some(self.resolve_library_ids(Some(ids)).await?), - None => None, - }; - let encrypted = update - .subsonic_password - .map(|password| self.secret_box.encrypt(password.as_bytes())) - .transpose()?; - let web_password_hash = if let Some(password) = update.web_password { - let password = password.to_owned(); - Some( - tokio::task::spawn_blocking(move || security::hash_password(&password)) - .await - .map_err(|_| ServiceError::Unavailable)??, - ) - } else { - None - }; - let revoke_sessions = web_password_hash.is_some(); - let _writer = self.db.writer_guard().await; - let mut tx = self.db.pool().begin().await?; - sqlx::query("UPDATE account SET role=COALESCE(?, role), disabled=COALESCE(?, disabled), password_hash=COALESCE(?, password_hash), updated_at=? WHERE id=?") - .bind(update.admin.map(|value| if value { "admin" } else { "user" })).bind(update.disabled.map(i64::from)).bind(web_password_hash.as_deref()).bind(now_ms()).bind(account.id.to_string()).execute(&mut *tx).await?; - if revoke_sessions { - sqlx::query("UPDATE session SET revoked_at=? WHERE user_id=? AND revoked_at IS NULL") - .bind(now_ms()) - .bind(account.id.to_string()) - .execute(&mut *tx) - .await?; - } - if let Some(encrypted) = encrypted { - let changed = sqlx::query( - "UPDATE subsonic_credential SET password_nonce=?, password_ciphertext=?, updated_at=? WHERE user_id=?", - ) - .bind(encrypted.nonce.as_slice()) - .bind(encrypted.ciphertext) - .bind(now_ms()) - .bind(account.id.to_string()) - .execute(&mut *tx) - .await? - .rows_affected(); - if changed == 0 { - return Err(ServiceError::NotFound); - } - } - if let Some(folder_ids) = requested_folders { - sqlx::query("DELETE FROM library_member WHERE user_id=? AND role='listener'") - .bind(account.id.to_string()) - .execute(&mut *tx) - .await?; - for library_id in folder_ids { - sqlx::query( - "INSERT INTO library_member (library_id, user_id, role, created_at) \ - VALUES (?, ?, 'listener', ?) \ - ON CONFLICT (library_id, user_id) DO NOTHING", - ) - .bind(library_id.to_string()) - .bind(account.id.to_string()) - .bind(now_ms()) - .execute(&mut *tx) - .await?; - } - } - tx.commit().await?; - drop(_writer); - self.users(actor_id) - .await? - .into_iter() - .find(|user| user.id == account.id) - .ok_or(ServiceError::NotFound) - } - - pub async fn delete_user(&self, actor_id: Uuid, username: &str) -> Result<(), ServiceError> { - self.require_admin(actor_id).await?; - let account = self - .db - .account_by_username(username) - .await? - .ok_or(ServiceError::NotFound)?; - if account.id == actor_id { - return Err(ServiceError::Forbidden); - } - let _writer = self.db.writer_guard().await; - sqlx::query("DELETE FROM account WHERE id=?") - .bind(account.id.to_string()) - .execute(self.db.pool()) - .await?; - Ok(()) - } - - pub async fn change_subsonic_password( - &self, - actor_id: Uuid, - username: &str, - password: &str, - ) -> Result<(), ServiceError> { - self.require_admin(actor_id).await?; - if password.is_empty() { - return Err(ServiceError::Invalid); - } - let account = self - .db - .account_by_username(username) - .await? - .ok_or(ServiceError::NotFound)?; - let encrypted = self.secret_box.encrypt(password.as_bytes())?; - let _writer = self.db.writer_guard().await; - let changed = sqlx::query("UPDATE subsonic_credential SET password_nonce=?, password_ciphertext=?, updated_at=? WHERE user_id=?") - .bind(encrypted.nonce.as_slice()).bind(encrypted.ciphertext).bind(now_ms()).bind(account.id.to_string()).execute(self.db.pool()).await?.rows_affected(); - if changed == 0 { - Err(ServiceError::NotFound) - } else { - Ok(()) - } - } - - async fn require_admin(&self, actor_id: Uuid) -> Result<(), ServiceError> { - let account = self - .db - .account_by_id(actor_id) - .await? - .ok_or(ServiceError::Forbidden)?; - if account.role == AccountRole::Admin && !account.disabled { - Ok(()) - } else { - Err(ServiceError::Forbidden) - } - } - - async fn resolve_library_ids( - &self, - requested: Option<&[Uuid]>, - ) -> Result, ServiceError> { - let available = sqlx::query_scalar::<_, String>("SELECT id FROM library ORDER BY id") - .fetch_all(self.db.pool()) - .await? - .into_iter() - .map(parse_uuid) - .collect::, _>>()?; - let Some(requested) = requested else { - return Ok(available); - }; - let mut unique = Vec::new(); - for id in requested { - if !available.contains(id) { - return Err(ServiceError::NotFound); - } - if !unique.contains(id) { - unique.push(*id); - } - } - Ok(unique) - } - - async fn authorize_entity_on( - &self, - connection: &mut SqliteConnection, - user_id: Uuid, - kind: &str, - id: Uuid, - ) -> Result<(), ServiceError> { - let query = match kind { - "track" => "SELECT 1 FROM track e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=? AND m.user_id=?", - "album" => "SELECT 1 FROM album e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=? AND m.user_id=?", - "artist" => "SELECT 1 FROM artist e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=? AND m.user_id=?", - _ => return Err(ServiceError::Invalid), - }; - let exists = sqlx::query_scalar::<_, i64>(query) - .bind(id.to_string()) - .bind(user_id.to_string()) - .fetch_optional(&mut *connection) - .await?; - if exists.is_some() { - Ok(()) - } else { - Err(ServiceError::NotFound) - } - } -} - -/// Fills in the relations a single projected row cannot carry. -/// -/// `song_select!` collapses credited artists and genres into the display strings -/// the tags happened to contain; the structured form lives in `track_artist` and -/// `track_genre`, ordered and deduplicated by the scanner. Reading them per song -/// would be two queries per row on every listing, so both are fetched once for -/// the whole batch and distributed by track id. -/// -/// Tenancy is re-checked here rather than inherited from the caller: the batch -/// is keyed by track id alone, and a join that trusted those ids would be the -/// one place in the read path where membership is not proven. -/// The JSON library list a scoped projection binds, or `None` for "every -/// library the account can reach". Built once here because every scoped -/// query binds the same value twice and a second spelling of it would be a -/// second chance to get the empty case wrong. -fn folder_filter(library_ids: &[Uuid]) -> Option { - (!library_ids.is_empty()) - .then(|| serde_json::to_string(library_ids).expect("UUID list serialization cannot fail")) -} - -/// Fills in the album relations OpenSubsonic expects on `AlbumID3`. -/// -/// Both are derived from the album's own available tracks rather than stored: -/// an album has no genre or credit of its own in the schema, it has the union -/// of what its files carry. Loaded in one batch per relation like the song -/// relations, because an album listing is up to five hundred rows and a query -/// each would be a query per row. -/// -/// Tenancy is re-checked in the query. The batch is keyed by album id alone, so -/// the `library_member` join is what stops an id from another account resolving -/// to real names. -async fn attach_album_relations( - connection: &mut SqliteConnection, - user_id: Uuid, - albums: &mut [AlbumItem], -) -> Result<(), sqlx::Error> { - if albums.is_empty() { - return Ok(()); - } - let ids = serde_json::to_string(&albums.iter().map(|album| album.id).collect::>()) - .expect("UUID list serialization cannot fail"); - let mut artists: HashMap> = HashMap::new(); - for row in sqlx::query( - // The album's own credits, which are its album artists — not the union - // of its tracks' credits, which is what this used to answer. An album - // with a guest on one track was reporting the guest as one of its - // artists; the reference reports the two the album is credited to, and - // leaves the guest to the track that names them. - "SELECT ap.album_id, ar.id, ar.name \ - FROM album_participant ap \ - JOIN artist ar ON ar.id=ap.artist_id \ - JOIN library_member m ON m.library_id=ap.library_id \ - WHERE m.user_id=? AND ap.role='albumartist' \ - AND ap.album_id IN (SELECT value FROM json_each(?)) \ - ORDER BY ap.album_id, ap.position, ar.name COLLATE NOCASE, ar.id", - ) - .bind(user_id.to_string()) - .bind(&ids) - .fetch_all(&mut *connection) - .await? - { - artists - .entry(parse_uuid(row.try_get("album_id")?)?) - .or_default() - .push(ArtistRef { - id: parse_uuid(row.try_get("id")?)?, - name: row.try_get("name")?, - }); - } - let mut genres: HashMap> = HashMap::new(); - for row in sqlx::query( - // Grouped on the canonical name for the same reason `list_genres` is: - // otherwise one album spelling "Hip-Hop" on some tracks and "Hip Hop" - // on others reports two genres. - "SELECT t.album_id, MIN(g.name) AS name FROM track t \ - JOIN track_genre tg ON tg.track_id=t.id \ - JOIN genre g ON g.id=tg.genre_id \ - JOIN library_member m ON m.library_id=t.library_id \ - WHERE m.user_id=? AND t.is_available=1 \ - AND t.album_id IN (SELECT value FROM json_each(?)) \ - GROUP BY t.album_id, g.canonical_name \ - ORDER BY t.album_id, name COLLATE NOCASE", - ) - .bind(user_id.to_string()) - .bind(&ids) - .fetch_all(&mut *connection) - .await? - { - genres - .entry(parse_uuid(row.try_get("album_id")?)?) - .or_default() - .push(row.try_get("name")?); - } - for album in albums { - album.artists = artists.remove(&album.id).unwrap_or_default(); - album.genres = genres.remove(&album.id).unwrap_or_default(); - } - Ok(()) -} - -async fn attach_song_relations( - connection: &mut SqliteConnection, - user_id: Uuid, - songs: &mut [SongItem], -) -> Result<(), sqlx::Error> { - if songs.is_empty() { - return Ok(()); - } - let ids = serde_json::to_string(&songs.iter().map(|song| song.id).collect::>()) - .expect("UUID list serialization cannot fail"); - let mut artists: HashMap> = HashMap::new(); - for row in sqlx::query( - "SELECT tp.track_id, ar.id, ar.name FROM track_participant tp \ - JOIN artist ar ON ar.id=tp.artist_id \ - JOIN library_member m ON m.library_id=tp.library_id \ - WHERE m.user_id=? AND tp.role='artist' \ - AND tp.track_id IN (SELECT value FROM json_each(?)) \ - ORDER BY tp.track_id, tp.position", - ) - .bind(user_id.to_string()) - .bind(&ids) - .fetch_all(&mut *connection) - .await? - { - artists - .entry(parse_uuid(row.try_get("track_id")?)?) - .or_default() - .push(ArtistRef { - id: parse_uuid(row.try_get("id")?)?, - name: row.try_get("name")?, - }); - } - let mut genres: HashMap> = HashMap::new(); - for row in sqlx::query( - // `track_genre` has no position column, so the order is the genre name. - // It has to be deterministic: a client diffing two responses would - // otherwise see a change that is not one. - "SELECT tg.track_id, g.name FROM track_genre tg \ - JOIN genre g ON g.id=tg.genre_id \ - JOIN library_member m ON m.library_id=tg.library_id \ - WHERE m.user_id=? AND tg.track_id IN (SELECT value FROM json_each(?)) \ - ORDER BY tg.track_id, g.name COLLATE NOCASE, g.id", - ) - .bind(user_id.to_string()) - .bind(&ids) - .fetch_all(&mut *connection) - .await? - { - genres - .entry(parse_uuid(row.try_get("track_id")?)?) - .or_default() - .push(row.try_get("name")?); - } - // Everything credited on the track that is neither its artist nor its - // album artist. Ordered by role then position so two responses for one - // track are byte-identical — the reference emits these in map-iteration - // order and answers differently on every request. - let mut contributors: HashMap> = HashMap::new(); - for row in sqlx::query( - "SELECT tp.track_id, tp.role, tp.sub_role, ar.id, ar.name \ - FROM track_participant tp \ - JOIN artist ar ON ar.id=tp.artist_id \ - JOIN library_member m ON m.library_id=tp.library_id \ - WHERE m.user_id=? AND tp.role NOT IN ('artist', 'albumartist') \ - AND tp.track_id IN (SELECT value FROM json_each(?)) \ - ORDER BY tp.track_id, tp.role, tp.position, tp.sub_role", - ) - .bind(user_id.to_string()) - .bind(&ids) - .fetch_all(&mut *connection) - .await? - { - let sub_role: String = row.try_get("sub_role")?; - contributors - .entry(parse_uuid(row.try_get("track_id")?)?) - .or_default() - .push(Contributor { - role: row.try_get("role")?, - sub_role: (!sub_role.is_empty()).then_some(sub_role), - artist: ArtistRef { - id: parse_uuid(row.try_get("id")?)?, - name: row.try_get("name")?, - }, - }); - } - // The album's credit, which is not the track's: a guest appearance names - // the guest while the album still belongs under its album artists. Keyed - // on the album, so every track of one album answers the same list. - let album_ids = serde_json::to_string( - &songs - .iter() - .filter_map(|song| song.album_id) - .collect::>(), - ) - .expect("UUID list serialization cannot fail"); - let mut album_artists: HashMap> = HashMap::new(); - for row in sqlx::query( - "SELECT ap.album_id, ar.id, ar.name FROM album_participant ap \ - JOIN artist ar ON ar.id=ap.artist_id \ - JOIN library_member m ON m.library_id=ap.library_id \ - WHERE m.user_id=? AND ap.role='albumartist' \ - AND ap.album_id IN (SELECT value FROM json_each(?)) \ - ORDER BY ap.album_id, ap.position, ar.name COLLATE NOCASE, ar.id", - ) - .bind(user_id.to_string()) - .bind(&album_ids) - .fetch_all(&mut *connection) - .await? - { - album_artists - .entry(parse_uuid(row.try_get("album_id")?)?) - .or_default() - .push(ArtistRef { - id: parse_uuid(row.try_get("id")?)?, - name: row.try_get("name")?, - }); - } - for song in songs { - song.artists = artists.remove(&song.id).unwrap_or_default(); - song.genres = genres.remove(&song.id).unwrap_or_default(); - song.contributors = contributors.remove(&song.id).unwrap_or_default(); - song.album_artists = song - .album_id - .and_then(|album| album_artists.get(&album).cloned()) - .unwrap_or_default(); - } - Ok(()) -} - -async fn fetch_songs( - db: &Database, - user_id: Uuid, - folder_filter: Option<&str>, - id: Option, -) -> Result, sqlx::Error> { - let id = id.map(|id| id.to_string()); - let mut songs = sqlx::query(concat!( - song_select!(), - " AND (? IS NULL OR t.library_id IN (SELECT value FROM json_each(?))) \ - AND (? IS NULL OR t.id=?) ORDER BY t.title COLLATE NOCASE" - )) - .bind(user_id.to_string()) - .bind(folder_filter) - .bind(folder_filter) - .bind(id.as_deref()) - .bind(id.as_deref()) - .fetch_all(db.pool()) - .await? - .into_iter() - .map(song_from_row) - .collect::, _>>()?; - attach_song_relations(&mut *db.pool().acquire().await?, user_id, &mut songs).await?; - Ok(songs) -} - -fn credential_from_row( - row: sqlx::sqlite::SqliteRow, -) -> Result { - let nonce = row.try_get::, _>("password_nonce")?; - let nonce: [u8; 12] = nonce.try_into().map_err(|value: Vec| { - sqlx::Error::Decode(format!("invalid credential nonce length: {}", value.len()).into()) - })?; - Ok(SubsonicCredentialRecord { - account: AccountRecord { - id: parse_uuid(row.try_get("id")?)?, - username: row.try_get("username")?, - password_hash: row.try_get("password_hash")?, - role: AccountRole::from_str(row.try_get::<&str, _>("role")?) - .map_err(|error| sqlx::Error::Decode(error.into()))?, - disabled: row.try_get::("disabled")? != 0, - }, - encrypted_password: EncryptedSecret { - nonce, - ciphertext: row.try_get("password_ciphertext")?, - }, - }) -} - -fn artist_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - Ok(ArtistItem { - // Sorted here rather than trusted from `group_concat`, whose order - // SQLite does not guarantee even when the subquery feeding it is - // ordered. Two responses for one artist have to be byte-identical. - roles: { - let mut roles: Vec = row - .try_get::, _>("roles")? - .map(|roles| roles.split(',').map(str::to_owned).collect()) - .unwrap_or_default(); - roles.sort_unstable(); - roles - }, - id: parse_uuid(row.try_get("id")?)?, - library_id: parse_uuid(row.try_get("library_id")?)?, - name: row.try_get("name")?, - artwork_hash: row.try_get("artwork_hash")?, - musicbrainz_id: row.try_get("musicbrainz_id")?, - sort_name: row.try_get("sort_name")?, - starred_at: row.try_get("starred_at")?, - user_rating: row.try_get("user_rating")?, - }) -} - -fn artist_summary_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - let album_count = row.try_get("album_count")?; - Ok(ArtistSummary { - artist: artist_from_row(row)?, - album_count, - }) -} - -fn album_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - Ok(AlbumItem { - id: parse_uuid(row.try_get("id")?)?, - library_id: parse_uuid(row.try_get("library_id")?)?, - title: row.try_get("title")?, - artist: row.try_get("album_artist_name")?, - artist_id: row - .try_get::, _>("album_artist_id")? - .map(parse_uuid) - .transpose()?, - artwork_hash: row.try_get("artwork_hash")?, - year: row.try_get("year")?, - is_compilation: row.try_get::("is_compilation")? != 0, - sort_name: row.try_get("sort_name")?, - musicbrainz_id: row.try_get("musicbrainz_id")?, - // Loaded in a batch by `attach_album_relations`, never row by row. - artists: Vec::new(), - genres: Vec::new(), - created_at: row.try_get("created_at")?, - starred_at: row.try_get("starred_at")?, - user_rating: row.try_get("user_rating")?, - play_count: row.try_get("play_count")?, - last_played_at: row.try_get("last_played_at")?, - song_count: row.try_get("song_count")?, - duration_ms: row.try_get("duration_ms")?, - }) -} - -fn lyrics_list_from_rows( - track_id: Uuid, - rows: Vec, -) -> Result { - let first = rows.first().ok_or(ServiceError::NotFound)?; - let display_title: String = first.try_get("title")?; - let display_artist: Option = first.try_get("artist_display")?; - let mut structured_lyrics = Vec::new(); - for row in rows { - let Some(content) = row.try_get::, _>("content")? else { - continue; - }; - let synced = row.try_get::, _>("synced")?.unwrap_or(0) != 0; - structured_lyrics.push(StructuredLyrics { - display_artist: display_artist.clone(), - display_title: display_title.clone(), - lang: row - .try_get::, _>("lang")? - .unwrap_or_else(|| "xxx".into()), - synced, - lines: lyrics::lines(&content, synced), - }); - } - Ok(LyricsList { - track_id, - structured_lyrics, - }) -} - -/// Splits a multi-valued tag string the way the scanner stored it. -/// -/// The scanner writes these joined on `;`, so a reader that did not split -/// them would hand a client one value that is really several. -fn split_tag_values(raw: Option<&str>) -> Vec { - raw.into_iter() - .flat_map(|value| value.split(';')) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) - .collect() -} - -fn song_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - let relative: String = row.try_get("relative_path")?; - Ok(SongItem { - id: parse_uuid(row.try_get("id")?)?, - library_id: parse_uuid(row.try_get("library_id")?)?, - album_id: row - .try_get::, _>("album_id")? - .map(parse_uuid) - .transpose()?, - title: row.try_get("title")?, - album: row.try_get("album_title")?, - artist: row.try_get("artist_display")?, - artist_id: row - .try_get::, _>("artist_id")? - .map(parse_uuid) - .transpose()?, - genre: row.try_get("genre_display")?, - year: row.try_get("year")?, - track: row.try_get("track_number")?, - disc: row.try_get("disc_number")?, - duration_ms: row.try_get("duration_ms")?, - bitrate: row.try_get("bitrate")?, - codec: row.try_get("codec")?, - suffix: PathBuf::from(relative) - .extension() - .and_then(|value| value.to_str()) - .unwrap_or("") - .to_ascii_lowercase(), - size: row.try_get("file_size")?, - artwork_hash: row.try_get("artwork_hash")?, - full_hash: row.try_get("full_hash")?, - created_at: row.try_get("created_at")?, - starred_at: row.try_get("starred_at")?, - user_rating: row.try_get("user_rating")?, - sample_rate: row.try_get("sample_rate")?, - channels: row.try_get("channels")?, - bit_depth: row.try_get("bit_depth")?, - play_count: row.try_get("play_count")?, - last_played_at: row.try_get("last_played_at")?, - // Filled in by `attach_song_relations`: one row cannot carry them. - artists: Vec::new(), - album_artists: Vec::new(), - contributors: Vec::new(), - genres: Vec::new(), - album_artist: row.try_get("album_artist_name")?, - album_artist_id: row - .try_get::, _>("album_artist_id")? - .map(parse_uuid) - .transpose()?, - musicbrainz_id: row.try_get("musicbrainz_recording_id")?, - replay_gain_track_gain: row.try_get("replay_gain_track_gain")?, - replay_gain_track_peak: row.try_get("replay_gain_track_peak")?, - replay_gain_album_gain: row.try_get("replay_gain_album_gain")?, - replay_gain_album_peak: row.try_get("replay_gain_album_peak")?, - bpm: row.try_get("bpm")?, - sort_name: row.try_get("sort_title")?, - comment: row.try_get("comment")?, - isrc: split_tag_values(row.try_get::, _>("isrc")?.as_deref()), - moods: split_tag_values(row.try_get::, _>("moods")?.as_deref()), - explicit_status: row.try_get("explicit_status")?, - }) -} - -async fn replace_playlist_tracks( - tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - playlist: Uuid, - ids: &[Uuid], - now: i64, -) -> Result<(), sqlx::Error> { - for (position, id) in ids.iter().enumerate() { - sqlx::query("INSERT INTO playlist_track (playlist_id, track_id, position, added_at) VALUES (?, ?, ?, ?)") - .bind(playlist.to_string()).bind(id.to_string()).bind(position as i64).bind(now).execute(&mut **tx).await?; - } - Ok(()) -} - -fn validate_name(name: &str) -> Result<(), ServiceError> { - if (1..=200).contains(&name.trim().chars().count()) { - Ok(()) - } else { - Err(ServiceError::Invalid) - } -} - -fn validate_replay_type(receipt: &MutationReceipt, expected: &str) -> Result<(), ServiceError> { - if receipt.entity_type == expected { - Ok(()) - } else { - Err(ServiceError::Conflict) - } -} - -fn validate_username(username: &str) -> Result<(), ServiceError> { - let username = username.trim(); - if !(3..=64).contains(&username.len()) - || !username.chars().all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') - }) - { - Err(ServiceError::Invalid) - } else { - Ok(()) - } -} - -fn parse_uuid(value: String) -> Result { - Uuid::from_str(&value).map_err(|error| sqlx::Error::Decode(Box::new(error))) -} diff --git a/src/services/admin.rs b/src/services/admin.rs new file mode 100644 index 0000000..b1459d3 --- /dev/null +++ b/src/services/admin.rs @@ -0,0 +1,430 @@ +//! User accounts, API tokens and Subsonic credentials. +//! +//! Split out of `services.rs`; see [`super`] for the shared projections. + +use super::*; + +impl DomainServices { + pub async fn users(&self, actor_id: Uuid) -> Result, ServiceError> { + self.require_admin(actor_id).await?; + let mut users = sqlx::query("SELECT a.id, a.username, a.role, a.disabled, c.user_id IS NOT NULL AS has_credential FROM account a LEFT JOIN subsonic_credential c ON c.user_id=a.id ORDER BY a.username COLLATE NOCASE") + .fetch_all(self.db.pool()).await?.into_iter().map(|row| Ok(UserItem { id: parse_uuid(row.try_get("id")?)?, username: row.try_get("username")?, role: AccountRole::from_str(row.try_get::<&str, _>("role")?).map_err(|error| sqlx::Error::Decode(error.into()))?, disabled: row.try_get::("disabled")? != 0, has_subsonic_credential: row.try_get::("has_credential")? != 0, folder_ids: Vec::new() })).collect::, sqlx::Error>>()?; + let memberships = sqlx::query( + "SELECT user_id, library_id FROM library_member ORDER BY user_id, library_id", + ) + .fetch_all(self.db.pool()) + .await?; + for row in memberships { + let user_id = parse_uuid(row.try_get("user_id")?)?; + let library_id = parse_uuid(row.try_get("library_id")?)?; + if let Some(user) = users.iter_mut().find(|user| user.id == user_id) { + user.folder_ids.push(library_id); + } + } + Ok(users) + } + + pub async fn create_web_user( + &self, + actor_id: Uuid, + username: &str, + password: &str, + role: AccountRole, + ) -> Result { + self.require_admin(actor_id).await?; + validate_username(username)?; + if password.len() < 12 { + return Err(ServiceError::Invalid); + } + let password = password.to_owned(); + let password_hash = tokio::task::spawn_blocking(move || security::hash_password(&password)) + .await + .map_err(|_| ServiceError::Unavailable)??; + let id = self + .db + .create_account(username.trim(), &password_hash, role, now_ms()) + .await + .map_err(|error| { + if matches!(error, sqlx::Error::Database(ref db) if db.is_unique_violation()) { + ServiceError::Conflict + } else { + ServiceError::Database(error) + } + })?; + self.users(actor_id) + .await? + .into_iter() + .find(|user| user.id == id) + .ok_or(ServiceError::NotFound) + } + + /// Sets a dedicated Subsonic password and rotates the API key. The clear + /// API key is returned once; only its hash is persisted. + pub async fn set_subsonic_credential( + &self, + actor_id: Uuid, + username: &str, + password: &str, + ) -> Result { + self.require_admin(actor_id).await?; + if password.len() < 12 { + return Err(ServiceError::Invalid); + } + let account = self + .db + .account_by_username(username) + .await? + .ok_or(ServiceError::NotFound)?; + let encrypted = self.secret_box.encrypt(password.as_bytes())?; + let api_key = security::generate_token("wfsk_"); + let api_key_hash = security::token_hash(&api_key); + self.db + .set_subsonic_credential(actor_id, account.id, &encrypted, &api_key_hash, now_ms()) + .await?; + Ok(api_key) + } + + pub async fn revoke_subsonic_credential( + &self, + actor_id: Uuid, + username: &str, + ) -> Result<(), ServiceError> { + self.require_admin(actor_id).await?; + let account = self + .db + .account_by_username(username) + .await? + .ok_or(ServiceError::NotFound)?; + if self + .db + .revoke_subsonic_credential(actor_id, account.id, now_ms()) + .await? + { + Ok(()) + } else { + Err(ServiceError::NotFound) + } + } + + /// The API tokens issued to one account. + /// + /// Administrative like the Subsonic credential routes beside it: a token + /// carries the authority of the account it belongs to, so who may mint one + /// is a question about the instance, not about the account itself. + pub async fn api_tokens( + &self, + actor_id: Uuid, + username: &str, + ) -> Result, ServiceError> { + self.require_admin(actor_id).await?; + let account = self + .db + .account_by_username(username) + .await? + .ok_or(ServiceError::NotFound)?; + Ok(self.db.api_tokens_for_user(account.id).await?) + } + + /// Issues a token and returns it beside its record. + /// + /// The secret is returned once and stored only as a SHA-256 hash, exactly + /// as `set_subsonic_credential` returns its API key: a caller that loses it + /// issues another one rather than reading it back. + pub async fn create_api_token( + &self, + actor_id: Uuid, + username: &str, + name: &str, + scopes: &[String], + ) -> Result<(ApiTokenRecord, String), ServiceError> { + self.require_admin(actor_id).await?; + let name = name.trim(); + if name.is_empty() || name.chars().count() > 120 { + return Err(ServiceError::Invalid); + } + // Normalised on the way in, so the value a listing shows is the value + // authorization compares. Trimming at the check instead would let a + // stored `" admin "` grant what a reader of the listing would not + // expect it to. + let scopes = scopes + .iter() + .map(|scope| scope.trim().to_owned()) + .collect::>(); + if scopes.iter().any(String::is_empty) { + return Err(ServiceError::Invalid); + } + let account = self + .db + .account_by_username(username) + .await? + .ok_or(ServiceError::NotFound)?; + let token = security::generate_token("wfapi_"); + let record = self + .db + .create_api_token( + account.id, + name, + &security::token_hash(&token), + &scopes, + now_ms(), + ) + .await?; + Ok((record, token)) + } + + /// Revokes one token of one account. + /// + /// A token that is not this account's, or is already revoked, answers as a + /// missing one: the caller asked for it to stop working, and naming the + /// wrong owner must not confirm that it exists elsewhere. + pub async fn revoke_api_token( + &self, + actor_id: Uuid, + username: &str, + token_id: Uuid, + ) -> Result<(), ServiceError> { + self.require_admin(actor_id).await?; + let account = self + .db + .account_by_username(username) + .await? + .ok_or(ServiceError::NotFound)?; + if self + .db + .revoke_api_token(actor_id, account.id, token_id, now_ms()) + .await? + { + Ok(()) + } else { + Err(ServiceError::NotFound) + } + } + + pub async fn create_subsonic_user( + &self, + actor_id: Uuid, + username: &str, + password: &str, + admin: bool, + folder_ids: Option<&[Uuid]>, + ) -> Result { + self.require_admin(actor_id).await?; + validate_name(username)?; + if password.is_empty() { + return Err(ServiceError::Invalid); + } + let placeholder = security::generate_token("web-disabled-"); + let password_hash = + tokio::task::spawn_blocking(move || security::hash_password(&placeholder)) + .await + .map_err(|_| ServiceError::Unavailable)??; + let encrypted = self.secret_box.encrypt(password.as_bytes())?; + let api_key = security::generate_token("wfsk_"); + let api_key_hash = security::token_hash(&api_key); + let requested_folders = self.resolve_library_ids(folder_ids).await?; + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + let user_id = Uuid::new_v4(); + let now = now_ms(); + let insert = sqlx::query( + "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(user_id.to_string()) + .bind(username.trim()) + .bind(password_hash) + .bind(if admin { "admin" } else { "user" }) + .bind(now) + .bind(now) + .execute(&mut *tx) + .await; + if let Err(error) = insert { + return Err( + if matches!(error, sqlx::Error::Database(ref db) if db.is_unique_violation()) { + ServiceError::Conflict + } else { + ServiceError::Database(error) + }, + ); + } + sqlx::query( + "INSERT INTO subsonic_credential \ + (user_id, password_nonce, password_ciphertext, api_key_hash, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(user_id.to_string()) + .bind(encrypted.nonce.as_slice()) + .bind(encrypted.ciphertext) + .bind(api_key_hash.as_slice()) + .bind(now) + .bind(now) + .execute(&mut *tx) + .await?; + for library_id in requested_folders { + sqlx::query( + "INSERT INTO library_member (library_id, user_id, role, created_at) \ + VALUES (?, ?, 'listener', ?)", + ) + .bind(library_id.to_string()) + .bind(user_id.to_string()) + .bind(now) + .execute(&mut *tx) + .await?; + } + sqlx::query( + "INSERT INTO audit_event (actor_user_id, kind, subject_id, occurred_at) \ + VALUES (?, 'subsonic.user_created', ?, ?)", + ) + .bind(actor_id.to_string()) + .bind(user_id.to_string()) + .bind(now) + .execute(&mut *tx) + .await?; + tx.commit().await?; + drop(_writer); + self.users(actor_id) + .await? + .into_iter() + .find(|user| user.id == user_id) + .ok_or(ServiceError::NotFound) + } + + pub async fn update_user( + &self, + actor_id: Uuid, + username: &str, + update: UserUpdate<'_>, + ) -> Result { + self.require_admin(actor_id).await?; + if update.subsonic_password.is_some_and(str::is_empty) + || update + .web_password + .is_some_and(|password| password.len() < 12) + { + return Err(ServiceError::Invalid); + } + let account = self + .db + .account_by_username(username) + .await? + .ok_or(ServiceError::NotFound)?; + if account.id == actor_id && (update.admin == Some(false) || update.disabled == Some(true)) + { + return Err(ServiceError::Forbidden); + } + let requested_folders = match update.folder_ids { + Some(ids) => Some(self.resolve_library_ids(Some(ids)).await?), + None => None, + }; + let encrypted = update + .subsonic_password + .map(|password| self.secret_box.encrypt(password.as_bytes())) + .transpose()?; + let web_password_hash = if let Some(password) = update.web_password { + let password = password.to_owned(); + Some( + tokio::task::spawn_blocking(move || security::hash_password(&password)) + .await + .map_err(|_| ServiceError::Unavailable)??, + ) + } else { + None + }; + let revoke_sessions = web_password_hash.is_some(); + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + sqlx::query("UPDATE account SET role=COALESCE(?, role), disabled=COALESCE(?, disabled), password_hash=COALESCE(?, password_hash), updated_at=? WHERE id=?") + .bind(update.admin.map(|value| if value { "admin" } else { "user" })).bind(update.disabled.map(i64::from)).bind(web_password_hash.as_deref()).bind(now_ms()).bind(account.id.to_string()).execute(&mut *tx).await?; + if revoke_sessions { + sqlx::query("UPDATE session SET revoked_at=? WHERE user_id=? AND revoked_at IS NULL") + .bind(now_ms()) + .bind(account.id.to_string()) + .execute(&mut *tx) + .await?; + } + if let Some(encrypted) = encrypted { + let changed = sqlx::query( + "UPDATE subsonic_credential SET password_nonce=?, password_ciphertext=?, updated_at=? WHERE user_id=?", + ) + .bind(encrypted.nonce.as_slice()) + .bind(encrypted.ciphertext) + .bind(now_ms()) + .bind(account.id.to_string()) + .execute(&mut *tx) + .await? + .rows_affected(); + if changed == 0 { + return Err(ServiceError::NotFound); + } + } + if let Some(folder_ids) = requested_folders { + sqlx::query("DELETE FROM library_member WHERE user_id=? AND role='listener'") + .bind(account.id.to_string()) + .execute(&mut *tx) + .await?; + for library_id in folder_ids { + sqlx::query( + "INSERT INTO library_member (library_id, user_id, role, created_at) \ + VALUES (?, ?, 'listener', ?) \ + ON CONFLICT (library_id, user_id) DO NOTHING", + ) + .bind(library_id.to_string()) + .bind(account.id.to_string()) + .bind(now_ms()) + .execute(&mut *tx) + .await?; + } + } + tx.commit().await?; + drop(_writer); + self.users(actor_id) + .await? + .into_iter() + .find(|user| user.id == account.id) + .ok_or(ServiceError::NotFound) + } + + pub async fn delete_user(&self, actor_id: Uuid, username: &str) -> Result<(), ServiceError> { + self.require_admin(actor_id).await?; + let account = self + .db + .account_by_username(username) + .await? + .ok_or(ServiceError::NotFound)?; + if account.id == actor_id { + return Err(ServiceError::Forbidden); + } + let _writer = self.db.writer_guard().await; + sqlx::query("DELETE FROM account WHERE id=?") + .bind(account.id.to_string()) + .execute(self.db.pool()) + .await?; + Ok(()) + } + + pub async fn change_subsonic_password( + &self, + actor_id: Uuid, + username: &str, + password: &str, + ) -> Result<(), ServiceError> { + self.require_admin(actor_id).await?; + if password.is_empty() { + return Err(ServiceError::Invalid); + } + let account = self + .db + .account_by_username(username) + .await? + .ok_or(ServiceError::NotFound)?; + let encrypted = self.secret_box.encrypt(password.as_bytes())?; + let _writer = self.db.writer_guard().await; + let changed = sqlx::query("UPDATE subsonic_credential SET password_nonce=?, password_ciphertext=?, updated_at=? WHERE user_id=?") + .bind(encrypted.nonce.as_slice()).bind(encrypted.ciphertext).bind(now_ms()).bind(account.id.to_string()).execute(self.db.pool()).await?.rows_affected(); + if changed == 0 { + Err(ServiceError::NotFound) + } else { + Ok(()) + } + } +} diff --git a/src/services/albums.rs b/src/services/albums.rs new file mode 100644 index 0000000..6dd4df5 --- /dev/null +++ b/src/services/albums.rs @@ -0,0 +1,148 @@ +//! Album listings and detail. +//! +//! Split out of `services.rs`; see [`super`] for the shared projections. + +use super::*; + +impl DomainServices { + /// Albums visible to the user, ordered, filtered and paged entirely in SQL. + /// + /// See [`AlbumOrder`] for why the ten orderings live here rather than in the + /// Subsonic facade. Every mode reads a single static literal — sqlx only + /// accepts static SQL, so the composition stays injection-proof by + /// construction and the user id is always the first bind. + pub async fn list_albums( + &self, + user_id: Uuid, + query: &AlbumListQuery, + ) -> Result, ServiceError> { + let folders = (!query.library_ids.is_empty()).then(|| { + serde_json::to_string(&query.library_ids).expect("UUID list serialization cannot fail") + }); + // `byGenre` without a genre is a malformed request, not an empty one: + // answering with the whole catalogue would drop the filter in silence. + // Matching is on the canonical form, so "Hip-Hop" and "hip hop" are the + // same genre — the facade previously compared display strings with + // `eq_ignore_ascii_case`, which they are not. + let genre = match query.order { + AlbumOrder::ByGenre => Some(waveflow_core::scanner::canonical_name( + query.genre.as_deref().ok_or(ServiceError::Invalid)?, + )), + _ => None, + }; + // An absent bound is unbounded, and a reversed range is how Subsonic + // asks for descending years. + let from = query.from_year.unwrap_or(i64::MIN); + let to = query.to_year.unwrap_or(i64::MAX); + let sql = match (query.order, from <= to) { + (AlbumOrder::AlphabeticalByName, _) => concat!( + album_scope!(), + " ORDER BY title COLLATE NOCASE, id LIMIT ? OFFSET ?" + ), + (AlbumOrder::AlphabeticalByArtist, _) => concat!( + album_scope!(), + " ORDER BY COALESCE(album_artist_name, '') COLLATE NOCASE, \ + title COLLATE NOCASE, id LIMIT ? OFFSET ?" + ), + (AlbumOrder::Newest, _) => concat!( + album_scope!(), + " ORDER BY created_at DESC, id LIMIT ? OFFSET ?" + ), + (AlbumOrder::Highest, _) => concat!( + album_scope!(), + " WHERE user_rating > 0 \ + ORDER BY user_rating DESC, title COLLATE NOCASE, id LIMIT ? OFFSET ?" + ), + (AlbumOrder::Frequent, _) => concat!( + album_scope!(), + " WHERE play_count > 0 \ + ORDER BY play_count DESC, title COLLATE NOCASE, id LIMIT ? OFFSET ?" + ), + (AlbumOrder::Recent, _) => concat!( + album_scope!(), + " WHERE last_played_at IS NOT NULL \ + ORDER BY last_played_at DESC, title COLLATE NOCASE, id LIMIT ? OFFSET ?" + ), + (AlbumOrder::Starred, _) => concat!( + album_scope!(), + " WHERE starred_at IS NOT NULL \ + ORDER BY starred_at DESC, title COLLATE NOCASE, id LIMIT ? OFFSET ?" + ), + (AlbumOrder::Random, _) => { + concat!(album_scope!(), " ORDER BY RANDOM() LIMIT ? OFFSET ?") + } + (AlbumOrder::ByYear, true) => concat!( + album_scope!(), + " WHERE year IS NOT NULL AND year BETWEEN ? AND ? \ + ORDER BY year, title COLLATE NOCASE, id LIMIT ? OFFSET ?" + ), + (AlbumOrder::ByYear, false) => concat!( + album_scope!(), + " WHERE year IS NOT NULL AND year BETWEEN ? AND ? \ + ORDER BY year DESC, title COLLATE NOCASE, id LIMIT ? OFFSET ?" + ), + (AlbumOrder::ByGenre, _) => concat!( + album_scope!(), + " WHERE EXISTS (SELECT 1 FROM track t2 \ + JOIN track_genre tg ON tg.track_id=t2.id \ + JOIN genre g ON g.id=tg.genre_id \ + WHERE t2.album_id=a.id AND t2.is_available=1 AND g.canonical_name=?) \ + ORDER BY title COLLATE NOCASE, id LIMIT ? OFFSET ?" + ), + }; + let mut statement = sqlx::query(sql) + .bind(user_id.to_string()) + .bind(folders.as_deref()) + .bind(folders.as_deref()); + if let Some(genre) = genre { + statement = statement.bind(genre); + } + if query.order == AlbumOrder::ByYear { + statement = statement.bind(from.min(to)).bind(from.max(to)); + } + let mut albums = statement + .bind(query.page.limit) + .bind(query.page.offset) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(album_from_row) + .collect::, _>>()?; + attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; + Ok(albums) + } + + /// One album with its tracks in sleeve order. Returns [`ServiceError::NotFound`] + /// both when the album does not exist and when it belongs to a library the + /// user cannot see, so the surface never leaks another tenant's catalogue. + pub async fn album(&self, user_id: Uuid, album_id: Uuid) -> Result { + let mut album = vec![sqlx::query(concat!(album_select!(), " AND al.id=?")) + .bind(user_id.to_string()) + .bind(album_id.to_string()) + .fetch_optional(self.db.pool()) + .await? + .map(album_from_row) + .transpose()? + .ok_or(ServiceError::NotFound)?]; + attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut album).await?; + let album = album.remove(0); + let mut songs = sqlx::query(concat!( + song_select!(), + // SQLite orders NULL first, which would put an untagged track ahead + // of track 1. Incomplete disc/track tags are common in real + // libraries, so unnumbered tracks sort to the end instead. + " AND t.album_id=? \ + ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, \ + t.title COLLATE NOCASE, t.id" + )) + .bind(user_id.to_string()) + .bind(album_id.to_string()) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(song_from_row) + .collect::, _>>()?; + attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; + Ok(AlbumDetail { album, songs }) + } +} diff --git a/src/services/artists.rs b/src/services/artists.rs new file mode 100644 index 0000000..51634bc --- /dev/null +++ b/src/services/artists.rs @@ -0,0 +1,67 @@ +//! Artist listings and detail. +//! +//! Split out of `services.rs`; see [`super`] for the shared projections. + +use super::*; + +impl DomainServices { + /// Artists visible to the user, paginated, each with its album count. + pub async fn list_artists( + &self, + user_id: Uuid, + library_id: Option, + page: BrowsePage, + ) -> Result, ServiceError> { + let library = library_id.map(|id| id.to_string()); + Ok(sqlx::query(concat!( + artist_select!(album_count), + " AND (? IS NULL OR ar.library_id=?) \ + ORDER BY ar.name COLLATE NOCASE, ar.id LIMIT ? OFFSET ?" + )) + .bind(user_id.to_string()) + .bind(library.as_deref()) + .bind(library.as_deref()) + .bind(page.limit) + .bind(page.offset) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(artist_summary_from_row) + .collect::, _>>()?) + } + + /// One artist with the albums it is credited on as album artist. + pub async fn artist( + &self, + user_id: Uuid, + artist_id: Uuid, + ) -> Result { + let summary = sqlx::query(concat!(artist_select!(album_count), " AND ar.id=?")) + .bind(user_id.to_string()) + .bind(artist_id.to_string()) + .fetch_optional(self.db.pool()) + .await? + .map(artist_summary_from_row) + .transpose()? + .ok_or(ServiceError::NotFound)?; + let mut albums = sqlx::query(concat!( + album_select!(), + " AND EXISTS (SELECT 1 FROM album_participant ap \ + WHERE ap.album_id=al.id AND ap.artist_id=? AND ap.role='albumartist') \ + ORDER BY al.year NULLS LAST, al.title COLLATE NOCASE, al.id" + )) + .bind(user_id.to_string()) + .bind(artist_id.to_string()) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(album_from_row) + .collect::, _>>()?; + attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; + Ok(ArtistDetail { + artist: summary.artist, + album_count: summary.album_count, + albums, + }) + } +} diff --git a/src/services/bookmarks.rs b/src/services/bookmarks.rs new file mode 100644 index 0000000..d45d8b1 --- /dev/null +++ b/src/services/bookmarks.rs @@ -0,0 +1,199 @@ +//! Per-track playback bookmarks. +//! +//! Split out of `services.rs`; see [`super`] for the shared projections. + +use super::*; + +impl DomainServices { + /// Bookmarks the user has set, most recently changed first. + pub async fn bookmarks(&self, user_id: Uuid) -> Result, ServiceError> { + let mut connection = self.db.pool().acquire().await?; + self.bookmarks_on(&mut connection, user_id).await + } + + pub(super) async fn bookmarks_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ) -> Result, ServiceError> { + // Joined against `song_select!` so a bookmark on a track that has become + // unavailable, or on a library the account has lost, simply stops being + // listed rather than being returned pointing at nothing. + let rows = sqlx::query(concat!( + "SELECT b.position_ms, b.comment AS bookmark_comment, \ + b.created_at AS bookmark_created_at, b.updated_at AS bookmark_updated_at, \ + song.* FROM bookmark b JOIN (", + song_select!(), + ") AS song ON song.id=b.track_id \ + WHERE b.user_id=? ORDER BY b.updated_at DESC, song.id" + )) + .bind(user_id.to_string()) + .bind(user_id.to_string()) + .fetch_all(&mut *connection) + .await?; + let mut bookmarks = Vec::with_capacity(rows.len()); + for row in rows { + bookmarks.push(BookmarkItem { + position_ms: row.try_get("position_ms")?, + comment: row.try_get("bookmark_comment")?, + created_at: row.try_get("bookmark_created_at")?, + updated_at: row.try_get("bookmark_updated_at")?, + song: song_from_row(row)?, + }); + } + let mut songs = bookmarks + .iter() + .map(|bookmark| bookmark.song.clone()) + .collect::>(); + attach_song_relations(&mut *connection, user_id, &mut songs).await?; + for (bookmark, song) in bookmarks.iter_mut().zip(songs) { + bookmark.song = song; + } + Ok(bookmarks) + } + + pub async fn set_bookmark( + &self, + user_id: Uuid, + track_id: Uuid, + position_ms: i64, + comment: Option<&str>, + ) -> Result<(), ServiceError> { + self.set_bookmark_with_context( + user_id, + track_id, + position_ms, + comment, + MutationContext::server_generated(), + ) + .await + } + + /// Sets, or moves, the bookmark on one track. + /// + /// A bookmark answers "where did I stop in this file", so there is one per + /// account and track and a second call moves it rather than adding another. + pub async fn set_bookmark_with_context( + &self, + user_id: Uuid, + track_id: Uuid, + position_ms: i64, + comment: Option<&str>, + context: MutationContext, + ) -> Result<(), ServiceError> { + if position_ms < 0 { + return Err(ServiceError::Invalid); + } + let intent = MutationIntent::new( + "set-bookmark", + &format!("bookmark:{track_id}"), + &serde_json::json!({ "position_ms": position_ms, "comment": comment }), + ); + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "bookmark")?; + return Ok(()); + } + self.authorize_entity_on(&mut tx, user_id, "track", track_id) + .await?; + let now = now_ms(); + sqlx::query( + "INSERT INTO bookmark (user_id, track_id, position_ms, comment, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?) \ + ON CONFLICT (user_id, track_id) DO UPDATE SET position_ms=excluded.position_ms, \ + comment=excluded.comment, updated_at=excluded.updated_at", + ) + .bind(user_id.to_string()) + .bind(track_id.to_string()) + .bind(position_ms) + .bind(comment) + .bind(now) + .bind(now) + .execute(&mut *tx) + .await?; + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "bookmark", + track_id, + "upsert", + &serde_json::json!({ + "track_id": track_id, + "position_ms": position_ms, + "comment": comment, + }), + Some(track_id), + ) + .await?; + tx.commit().await?; + self.sync.publish(user_id, receipt); + Ok(()) + } + + pub async fn delete_bookmark(&self, user_id: Uuid, track_id: Uuid) -> Result<(), ServiceError> { + self.delete_bookmark_with_context(user_id, track_id, MutationContext::server_generated()) + .await + } + + /// Removes the bookmark on one track. + /// + /// Removing one that is not there succeeds: the caller asked for the track + /// to carry no bookmark, and it does not. Reporting not-found would also + /// answer a question about another account's catalogue, which the rest of + /// the surface refuses to do. + pub async fn delete_bookmark_with_context( + &self, + user_id: Uuid, + track_id: Uuid, + context: MutationContext, + ) -> Result<(), ServiceError> { + let intent = MutationIntent::new( + "delete-bookmark", + &format!("bookmark:{track_id}"), + &serde_json::json!({}), + ); + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "bookmark")?; + return Ok(()); + } + self.authorize_entity_on(&mut tx, user_id, "track", track_id) + .await?; + sqlx::query("DELETE FROM bookmark WHERE user_id=? AND track_id=?") + .bind(user_id.to_string()) + .bind(track_id.to_string()) + .execute(&mut *tx) + .await?; + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "bookmark", + track_id, + "delete", + &serde_json::json!({ "track_id": track_id }), + Some(track_id), + ) + .await?; + tx.commit().await?; + self.sync.publish(user_id, receipt); + Ok(()) + } +} diff --git a/src/services/catalog.rs b/src/services/catalog.rs new file mode 100644 index 0000000..5475f4c --- /dev/null +++ b/src/services/catalog.rs @@ -0,0 +1,231 @@ +//! Music folders, catalogue overviews and artwork. +//! +//! Split out of `services.rs`; see [`super`] for the shared projections. + +use super::*; + +impl DomainServices { + /// The libraries one account can reach. + /// + /// `getMusicFolders` needs nothing else, and used to read the whole + /// catalogue to answer with a handful of names. + pub async fn music_folders( + &self, + user_id: Uuid, + folder_ids: &[Uuid], + ) -> Result, ServiceError> { + let folder_filter = folder_filter(folder_ids); + Ok(sqlx::query( + "SELECT l.id, l.name FROM library l JOIN library_member m ON m.library_id=l.id \ + WHERE m.user_id=? AND (? IS NULL OR l.id IN (SELECT value FROM json_each(?))) \ + ORDER BY l.name COLLATE NOCASE", + ) + .bind(user_id.to_string()) + .bind(folder_filter.as_deref()) + .bind(folder_filter.as_deref()) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(|row| { + Ok(MusicFolderItem { + id: parse_uuid(row.try_get("id")?)?, + name: row.try_get("name")?, + }) + }) + .collect::, sqlx::Error>>()?) + } + + /// Folders, artists and albums, without the tracks. + /// + /// Most of what browses the catalogue never looks at a track: an index of + /// artists, an artist's albums, a folder's contents. Those used to read + /// every visible track anyway, because one snapshot served every browse + /// method, and the track read is by far the largest of the three — and + /// since the OpenSubsonic fields landed it carries two relation loads of + /// its own. + pub async fn catalog_overview( + &self, + user_id: Uuid, + folder_ids: &[Uuid], + ) -> Result { + let folder_filter = folder_filter(folder_ids); + let folders = self.music_folders(user_id, folder_ids).await?; + let artists = sqlx::query(concat!( + artist_select!(album_count), + // Only artists an album is credited to. A composer with no album + // of their own is reachable by identifier and by search, but does + // not belong in an index of the library's artists — which is what + // the reference answers, and what `getArtists` means. + " AND EXISTS (SELECT 1 FROM artist_role_stats ars \ + WHERE ars.artist_id=ar.id AND ars.role='albumartist') \ + AND (? IS NULL OR ar.library_id IN (SELECT value FROM json_each(?))) \ + ORDER BY ar.name COLLATE NOCASE" + )) + .bind(user_id.to_string()) + .bind(folder_filter.as_deref()) + .bind(folder_filter.as_deref()) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(artist_summary_from_row) + .collect::, _>>()?; + let mut albums = sqlx::query(concat!( + album_select!(), + " AND (? IS NULL OR al.library_id IN (SELECT value FROM json_each(?))) \ + ORDER BY al.title COLLATE NOCASE" + )) + .bind(user_id.to_string()) + .bind(folder_filter.as_deref()) + .bind(folder_filter.as_deref()) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(album_from_row) + .collect::, _>>()?; + attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; + Ok(CatalogOverview { + folders, + artists, + albums, + }) + } + + /// The overview plus every visible track. + /// + /// **No route calls this.** Every browse method that used to now asks for + /// what it renders, and nothing should reach for this again: it is the + /// shape that made one album page read a tenant's whole catalogue. + /// + /// It survives as a fixture. The integration suite builds ids from it — + /// "give me an album of this account so I can ask for it" — which is a + /// legitimate use of a full read in a test with three tracks in it, and + /// not one in a request. + pub async fn catalog_snapshot( + &self, + user_id: Uuid, + folder_ids: &[Uuid], + ) -> Result { + let overview = self.catalog_overview(user_id, folder_ids).await?; + let songs = fetch_songs( + &self.db, + user_id, + folder_filter(folder_ids).as_deref(), + None, + ) + .await?; + Ok(CatalogSnapshot { + folders: overview.folders, + artists: overview.artists, + albums: overview.albums, + songs, + }) + } + + /// Backs Subsonic `search3` with the FTS5 index instead of materialising the + /// whole catalogue and filtering it in memory. + /// + /// `track_fts` indexes title, album, artists and genres per track, so + /// selecting matching tracks and deriving their albums and artists covers + /// the same ground the in-memory pass did — a matching album title reaches + /// its own tracks through the `album` column. + /// + /// Its tokenizer folds case *and* diacritics, so "echo" now finds "Écho", + /// which the previous lowercase substring test did not. What it gives up is + /// matching inside a word: "cho" no longer finds "Echo". The trailing term + /// is treated as a prefix so search-as-you-type still works. + pub async fn catalog_search( + &self, + user_id: Uuid, + folder_ids: &[Uuid], + query: &str, + ) -> Result { + let Some(fts) = crate::catalog::fts_prefix_query(query) else { + return Ok(CatalogSearch { + artists: Vec::new(), + albums: Vec::new(), + songs: Vec::new(), + }); + }; + let folders = folder_filter(folder_ids); + let folder_filter = folders.as_deref(); + + let mut songs = sqlx::query(concat!( + song_select!(), + " AND (? IS NULL OR t.library_id IN (SELECT value FROM json_each(?))) \ + AND t.id IN (SELECT track_id FROM track_fts WHERE track_fts MATCH ?) \ + ORDER BY t.title COLLATE NOCASE, t.id" + )) + .bind(user_id.to_string()) + .bind(folder_filter) + .bind(folder_filter) + .bind(&fts) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(song_from_row) + .collect::, _>>()?; + attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; + + let mut albums = sqlx::query(concat!( + album_select!(), + " AND (? IS NULL OR al.library_id IN (SELECT value FROM json_each(?))) \ + AND al.id IN (SELECT t.album_id FROM track t WHERE t.album_id IS NOT NULL \ + AND t.id IN (SELECT track_id FROM track_fts WHERE track_fts MATCH ?)) \ + ORDER BY al.title COLLATE NOCASE, al.id" + )) + .bind(user_id.to_string()) + .bind(folder_filter) + .bind(folder_filter) + .bind(&fts) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(album_from_row) + .collect::, _>>()?; + attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; + + let artists = sqlx::query(concat!( + artist_select!(), + " AND (? IS NULL OR ar.library_id IN (SELECT value FROM json_each(?))) \ + AND ar.id IN (SELECT artist_id FROM artist_fts WHERE artist_fts MATCH ?) \ + ORDER BY ar.name COLLATE NOCASE" + )) + .bind(user_id.to_string()) + .bind(folder_filter) + .bind(folder_filter) + .bind(&fts) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(artist_from_row) + .collect::, _>>()?; + + Ok(CatalogSearch { + artists, + albums, + songs, + }) + } + + pub async fn artwork_for_user( + &self, + user_id: Uuid, + id: &str, + ) -> Result, ServiceError> { + let row = sqlx::query( + "SELECT a.hash, a.format FROM artwork a WHERE a.hash=? AND EXISTS ( \ + SELECT 1 FROM track t JOIN library_member m ON m.library_id=t.library_id WHERE t.artwork_hash=a.hash AND m.user_id=? \ + UNION SELECT 1 FROM album al JOIN library_member m ON m.library_id=al.library_id WHERE al.artwork_hash=a.hash AND m.user_id=? \ + UNION SELECT 1 FROM artist ar JOIN library_member m ON m.library_id=ar.library_id WHERE ar.artwork_hash=a.hash AND m.user_id=? \ + ) UNION ALL SELECT a.hash, a.format FROM track t JOIN library_member m ON m.library_id=t.library_id JOIN artwork a ON a.hash=t.artwork_hash WHERE t.id=? AND m.user_id=? \ + UNION ALL SELECT a.hash, a.format FROM album al JOIN library_member m ON m.library_id=al.library_id JOIN artwork a ON a.hash=al.artwork_hash WHERE al.id=? AND m.user_id=? \ + UNION ALL SELECT a.hash, a.format FROM artist ar JOIN library_member m ON m.library_id=ar.library_id JOIN artwork a ON a.hash=ar.artwork_hash WHERE ar.id=? AND m.user_id=? LIMIT 1", + ) + .bind(id).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")?))) + .transpose() + .map_err(Into::into) + } +} diff --git a/src/services/credentials.rs b/src/services/credentials.rs new file mode 100644 index 0000000..b8733a0 --- /dev/null +++ b/src/services/credentials.rs @@ -0,0 +1,116 @@ +//! Credential lookup and native client authorisation. +//! +//! Split out of `services.rs`; see [`super`] for the shared projections. + +use super::*; + +impl DomainServices { + pub async fn bootstrap_admin( + &self, + username: &str, + password: &str, + ) -> Result { + validate_username(username)?; + if password.len() < 12 { + return Err(ServiceError::Invalid); + } + let password = password.to_owned(); + let password_hash = tokio::task::spawn_blocking(move || security::hash_password(&password)) + .await + .map_err(|_| ServiceError::Unavailable)??; + self.db + .bootstrap_admin(username, &password_hash, now_ms()) + .await? + .ok_or(ServiceError::Conflict) + } + + pub async fn credential_by_username( + &self, + username: &str, + ) -> Result, ServiceError> { + let row = sqlx::query( + "SELECT a.id, a.username, a.password_hash, a.role, a.disabled, \ + c.password_nonce, c.password_ciphertext \ + FROM account a JOIN subsonic_credential c ON c.user_id=a.id \ + WHERE a.username=? COLLATE NOCASE AND a.disabled=0", + ) + .bind(username) + .fetch_optional(self.db.pool()) + .await?; + row.map(credential_from_row).transpose().map_err(Into::into) + } + + pub async fn credential_by_api_key( + &self, + api_key: &str, + ) -> Result, ServiceError> { + let hash = security::token_hash(api_key); + let row = sqlx::query( + "SELECT a.id, a.username, a.password_hash, a.role, a.disabled, \ + c.password_nonce, c.password_ciphertext \ + FROM account a JOIN subsonic_credential c ON c.user_id=a.id \ + WHERE c.api_key_hash=? AND a.disabled=0", + ) + .bind(hash.as_slice()) + .fetch_optional(self.db.pool()) + .await?; + row.map(credential_from_row).transpose().map_err(Into::into) + } + + pub fn decrypt_subsonic_password( + &self, + credential: &SubsonicCredentialRecord, + ) -> Result, ServiceError> { + self.secret_box + .decrypt( + &credential.encrypted_password.nonce, + &credential.encrypted_password.ciphertext, + ) + .map_err(Into::into) + } + + /// Issues an authorization code for a native client. + /// + /// Validation, credential generation and persistence live here rather than + /// in the handler so the grant rules hold for every surface that ever + /// issues one, and so they can be exercised without an HTTP request. + /// Returns the URL the consent screen must send the user agent to. + pub async fn authorize_native_client( + &self, + user_id: Uuid, + request: AuthorizationRequest<'_>, + ) -> Result { + crate::oauth::validate_redirect_uri(request.redirect_uri) + .map_err(|_| ServiceError::Invalid)?; + crate::oauth::validate_challenge(request.code_challenge_method, request.code_challenge) + .map_err(|_| ServiceError::Invalid)?; + let client_id = request.client_id.trim(); + let device_name = request.device_name.trim(); + // Checked before the code exists: a name the session issuer would + // reject must not burn a grant the client can never redeem. + if client_id.is_empty() || device_name.is_empty() || device_name.len() > 120 { + return Err(ServiceError::Invalid); + } + + let code = security::generate_token("wfc_"); + let now = now_ms(); + self.db + .create_authorization(crate::database::NewAuthorization { + code_hash: security::token_hash(&code), + user_id, + client_id, + redirect_uri: request.redirect_uri, + code_challenge: request.code_challenge, + device_name, + now_ms: now, + expires_at: now + crate::oauth::AUTHORIZATION_CODE_TTL_MS, + scopes: request.scopes, + }) + .await?; + Ok(crate::oauth::redirect_with_code( + request.redirect_uri, + &code, + request.state, + )) + } +} diff --git a/src/services/favorites.rs b/src/services/favorites.rs new file mode 100644 index 0000000..eb9d774 --- /dev/null +++ b/src/services/favorites.rs @@ -0,0 +1,267 @@ +//! Favourites and ratings. +//! +//! Split out of `services.rs`; see [`super`] for the shared projections. + +use super::*; + +impl DomainServices { + pub async fn set_star( + &self, + user_id: Uuid, + entity_type: &str, + entity_id: Uuid, + starred: bool, + ) -> Result<(), ServiceError> { + self.set_star_with_context( + user_id, + entity_type, + entity_id, + starred, + MutationContext::server_generated(), + ) + .await + } + + pub async fn set_star_with_context( + &self, + user_id: Uuid, + entity_type: &str, + entity_id: Uuid, + starred: bool, + context: MutationContext, + ) -> Result<(), ServiceError> { + let intent = MutationIntent::new( + if starred { "star" } else { "unstar" }, + &format!("{entity_type}:{entity_id}"), + &serde_json::json!({ "starred": starred }), + ); + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "favorite")?; + return Ok(()); + } + self.authorize_entity_on(&mut tx, user_id, entity_type, entity_id) + .await?; + if starred { + sqlx::query("INSERT INTO user_star (user_id, entity_type, entity_id, starred_at) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET starred_at=excluded.starred_at") + .bind(user_id.to_string()).bind(entity_type).bind(entity_id.to_string()).bind(now_ms()) + .execute(&mut *tx).await?; + } else { + sqlx::query("DELETE FROM user_star WHERE user_id=? AND entity_type=? AND entity_id=?") + .bind(user_id.to_string()) + .bind(entity_type) + .bind(entity_id.to_string()) + .execute(&mut *tx) + .await?; + } + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "favorite", + entity_id, + if starred { "upsert" } else { "delete" }, + &serde_json::json!({ + "entity_type": entity_type, + "entity_id": entity_id, + "starred": starred, + }), + Some(entity_id), + ) + .await?; + tx.commit().await?; + self.sync.publish(user_id, receipt); + Ok(()) + } + + pub async fn entity_kind( + &self, + user_id: Uuid, + entity_id: Uuid, + ) -> Result, ServiceError> { + let kinds: Vec = sqlx::query_scalar( + "SELECT entity_type FROM (\ + SELECT 'track' AS entity_type FROM track t \ + JOIN library_member m ON m.library_id=t.library_id \ + WHERE t.id=? AND m.user_id=? \ + UNION ALL \ + SELECT 'album' FROM album a \ + JOIN library_member m ON m.library_id=a.library_id \ + WHERE a.id=? AND m.user_id=? \ + UNION ALL \ + SELECT 'artist' FROM artist ar \ + JOIN library_member m ON m.library_id=ar.library_id \ + WHERE ar.id=? AND m.user_id=? \ + )", + ) + .bind(entity_id.to_string()) + .bind(user_id.to_string()) + .bind(entity_id.to_string()) + .bind(user_id.to_string()) + .bind(entity_id.to_string()) + .bind(user_id.to_string()) + .fetch_all(self.db.pool()) + .await?; + if kinds.len() != 1 { + return Ok(None); + } + Ok(match kinds[0].as_str() { + "track" => Some("track"), + "album" => Some("album"), + "artist" => Some("artist"), + _ => None, + }) + } + + pub async fn starred_ids( + &self, + user_id: Uuid, + ) -> Result, ServiceError> { + let mut connection = self.db.pool().acquire().await?; + self.starred_ids_on(&mut connection, user_id).await + } + + pub(super) async fn starred_ids_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ) -> Result, ServiceError> { + sqlx::query( + "SELECT s.entity_type, s.entity_id, s.starred_at FROM user_star s \ + WHERE s.user_id=? AND ( \ + (s.entity_type='track' AND EXISTS (SELECT 1 FROM track e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=s.entity_id AND m.user_id=s.user_id)) OR \ + (s.entity_type='album' AND EXISTS (SELECT 1 FROM album e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=s.entity_id AND m.user_id=s.user_id)) OR \ + (s.entity_type='artist' AND EXISTS (SELECT 1 FROM artist e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=s.entity_id AND m.user_id=s.user_id)) \ + ) ORDER BY s.starred_at DESC", + ) + .bind(user_id.to_string()).fetch_all(&mut *connection).await? + .into_iter().map(|row| Ok((row.try_get("entity_type")?, parse_uuid(row.try_get("entity_id")?)?, row.try_get("starred_at")?))) + .collect::, sqlx::Error>>().map_err(Into::into) + } + + pub async fn ratings(&self, user_id: Uuid) -> Result, ServiceError> { + let mut connection = self.db.pool().acquire().await?; + self.ratings_on(&mut connection, user_id).await + } + + pub(super) async fn ratings_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ) -> Result, ServiceError> { + sqlx::query( + "SELECT r.entity_type, r.entity_id, r.rating, r.updated_at FROM user_rating r \ + WHERE r.user_id=? AND ( \ + (r.entity_type='track' AND EXISTS (SELECT 1 FROM track e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=r.entity_id AND m.user_id=r.user_id)) OR \ + (r.entity_type='album' AND EXISTS (SELECT 1 FROM album e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=r.entity_id AND m.user_id=r.user_id)) OR \ + (r.entity_type='artist' AND EXISTS (SELECT 1 FROM artist e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=r.entity_id AND m.user_id=r.user_id)) \ + ) ORDER BY r.updated_at DESC, r.entity_type, r.entity_id", + ) + .bind(user_id.to_string()) + .fetch_all(&mut *connection) + .await? + .into_iter() + .map(|row| { + Ok(RatingItem { + entity_type: row.try_get("entity_type")?, + entity_id: parse_uuid(row.try_get("entity_id")?)?, + rating: row.try_get("rating")?, + updated_at: row.try_get("updated_at")?, + }) + }) + .collect::, sqlx::Error>>() + .map_err(Into::into) + } + + pub async fn set_rating( + &self, + user_id: Uuid, + entity_type: &str, + entity_id: Uuid, + rating: i64, + ) -> Result<(), ServiceError> { + self.set_rating_with_context( + user_id, + entity_type, + entity_id, + rating, + MutationContext::server_generated(), + ) + .await + } + + pub async fn set_rating_with_context( + &self, + user_id: Uuid, + entity_type: &str, + entity_id: Uuid, + rating: i64, + context: MutationContext, + ) -> Result<(), ServiceError> { + // Checked before the writer gate rather than after the claim: an + // out-of-range rating is refused on its own terms, without queuing + // behind a scan for the right to be told so. + if !(0..=5).contains(&rating) { + return Err(ServiceError::Invalid); + } + let intent = MutationIntent::new( + "set-rating", + &format!("{entity_type}:{entity_id}"), + &serde_json::json!({ "rating": rating }), + ); + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "rating")?; + return Ok(()); + } + self.authorize_entity_on(&mut tx, user_id, entity_type, entity_id) + .await?; + if rating == 0 { + sqlx::query( + "DELETE FROM user_rating WHERE user_id=? AND entity_type=? AND entity_id=?", + ) + .bind(user_id.to_string()) + .bind(entity_type) + .bind(entity_id.to_string()) + .execute(&mut *tx) + .await?; + } else { + sqlx::query("INSERT INTO user_rating (user_id, entity_type, entity_id, rating, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET rating=excluded.rating, updated_at=excluded.updated_at") + .bind(user_id.to_string()).bind(entity_type).bind(entity_id.to_string()).bind(rating).bind(now_ms()).execute(&mut *tx).await?; + } + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "rating", + entity_id, + if rating == 0 { "delete" } else { "upsert" }, + &serde_json::json!({ + "entity_type": entity_type, + "entity_id": entity_id, + "rating": rating, + }), + Some(entity_id), + ) + .await?; + tx.commit().await?; + self.sync.publish(user_id, receipt); + Ok(()) + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..515fe64 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,1240 @@ +//! Shared v2 domain services and tenant-filtered read models. + +use std::{collections::HashMap, path::PathBuf, str::FromStr, sync::Arc}; + +use serde::Serialize; +use sqlx::{Row, SqliteConnection}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{ + authentication::now_ms, + database::{AccountRecord, AccountRole, ApiTokenRecord, Database}, + lyrics::{self, LyricsList, StructuredLyrics}, + security::{self, EncryptedSecret, SecretBox}, + sync::{MutationContext, MutationIntent, MutationReceipt, OperationClaim, SyncService}, +}; + +/// Tenant-filtered projections shared by the Subsonic facade and the native +/// browse endpoints. Each expands to a literal ending at `WHERE m.user_id=?` so +/// callers `concat!` their own predicates onto it — sqlx only accepts static SQL, +/// which keeps these compositions injection-proof by construction. The first +/// bind is always the user id. +macro_rules! song_select { + () => { + "SELECT t.id, t.library_id, t.album_id, t.title, t.album_title, t.artist_display, \ + (SELECT tp.artist_id FROM track_participant tp \ + WHERE tp.track_id=t.id AND tp.role='artist' \ + ORDER BY tp.position LIMIT 1) AS artist_id, \ + t.genre_display, t.year, t.track_number, t.disc_number, t.duration_ms, t.bitrate, \ + t.codec, t.relative_path, t.file_size, t.artwork_hash, t.full_hash, t.created_at, \ + us.starred_at, ur.rating AS user_rating, \ + t.sample_rate, t.channels, t.bit_depth, \ + (SELECT COUNT(*) FROM play_event pe \ + WHERE pe.user_id=m.user_id AND pe.submission=1 AND pe.track_id=t.id) \ + AS play_count, \ + (SELECT MAX(pe.played_at) FROM play_event pe \ + WHERE pe.user_id=m.user_id AND pe.submission=1 AND pe.track_id=t.id) \ + AS last_played_at, \ + t.musicbrainz_recording_id, t.replay_gain_track_gain, t.replay_gain_track_peak, \ + t.replay_gain_album_gain, t.replay_gain_album_peak, t.bpm, t.sort_title, \ + t.comment, t.isrc, t.moods, t.explicit_status, \ + alb.album_artist_name, alb.album_artist_id \ + FROM track t JOIN library_member m ON m.library_id=t.library_id \ + LEFT JOIN album alb ON alb.id=t.album_id \ + LEFT JOIN user_star us ON us.user_id=m.user_id AND us.entity_type='track' AND us.entity_id=t.id \ + LEFT JOIN user_rating ur ON ur.user_id=m.user_id AND ur.entity_type='track' AND ur.entity_id=t.id \ + WHERE m.user_id=? AND t.is_available=1" + }; +} + +/// Narrows [`song_select!`] to an optional set of libraries. Binds the JSON +/// library list twice, as the album and artist scopes do. +macro_rules! song_folder_clause { + () => { + " AND (? IS NULL OR t.library_id IN (SELECT value FROM json_each(?)))" + }; +} + +/// Restricts [`song_select!`] to one genre, matched on `genre.canonical_name` +/// so case, punctuation and spacing fold exactly as they do in `getGenres` and +/// in the `byGenre` album filter. Binds the canonical name once. +macro_rules! song_genre_clause { + () => { + " AND t.id IN (SELECT tg.track_id FROM track_genre tg \ + JOIN genre g ON g.id=tg.genre_id WHERE g.canonical_name=?)" + }; +} + +/// An optional inclusive year range. Binds a flag and the two bounds, so one +/// literal serves both the filtered and the unfiltered request rather than +/// forking the statement. +macro_rules! song_year_clause { + () => { + " AND (? = 0 OR (t.year IS NOT NULL AND t.year BETWEEN ? AND ?))" + }; +} + +macro_rules! album_select { + () => { + "SELECT al.id, al.library_id, al.title, al.album_artist_name, al.album_artist_id, \ + al.artwork_hash, al.year, al.is_compilation, al.musicbrainz_id, al.sort_name, \ + al.created_at, us.starred_at, \ + ur.rating AS user_rating, \ + (SELECT COUNT(*) FROM play_event pe JOIN track pt ON pt.id=pe.track_id \ + WHERE pe.user_id=m.user_id AND pe.submission=1 AND pt.album_id=al.id) AS play_count, \ + (SELECT MAX(pe.played_at) FROM play_event pe JOIN track pt ON pt.id=pe.track_id \ + WHERE pe.user_id=m.user_id AND pe.submission=1 AND pt.album_id=al.id) AS last_played_at, \ + (SELECT COUNT(*) FROM track t2 WHERE t2.album_id=al.id AND t2.is_available=1) \ + AS song_count, \ + (SELECT COALESCE(SUM(t2.duration_ms), 0) FROM track t2 \ + WHERE t2.album_id=al.id AND t2.is_available=1) AS duration_ms \ + FROM album al JOIN library_member m ON m.library_id=al.library_id \ + LEFT JOIN user_star us ON us.user_id=m.user_id AND us.entity_type='album' AND us.entity_id=al.id \ + LEFT JOIN user_rating ur ON ur.user_id=m.user_id AND ur.entity_type='album' AND ur.entity_id=al.id \ + WHERE m.user_id=?" + }; +} + +/// [`album_select!`] narrowed to an optional set of libraries and wrapped so a +/// caller can filter and order on the projected aggregates — `play_count`, +/// `last_played_at`, `song_count` — instead of repeating their subqueries. +/// SQLite does not accept a result alias in `WHERE`, hence the wrapper rather +/// than a longer predicate list. Binds are the user id, then the JSON library +/// list twice. +macro_rules! album_scope { + () => { + concat!( + "SELECT * FROM (", + album_select!(), + " AND (? IS NULL OR al.library_id IN (SELECT value FROM json_each(?)))) AS a" + ) + }; +} + +/// The artist projection, in the two shapes the catalogue reads it. +/// +/// `artist_select!()` stops at the columns `ArtistItem` carries; +/// `artist_select!(album_count)` adds the count `ArtistSummary` needs, so a +/// browse that never renders it does not pay a correlated subquery per artist. +/// Both expand from the same column list on purpose: the browses that wanted +/// the short shape used to spell it out by hand, and one of those copies fell +/// a column behind the day this list gained one — the browse reading it then +/// failed on a column the query never selected, which nothing reading the +/// macro could have predicted. +macro_rules! artist_select { + () => { + artist_select!(@columns "") + }; + (album_count) => { + artist_select!( + @columns ", COALESCE((SELECT ars.album_count FROM artist_role_stats ars \ + WHERE ars.artist_id=ar.id AND ars.role='albumartist'), 0) \ + AS album_count" + ) + }; + (@columns $extra:expr) => { + concat!( + "SELECT ar.id, ar.library_id, ar.name, ar.artwork_hash, ar.musicbrainz_id, \ + ar.sort_name, us.starred_at, ur.rating AS user_rating, \ + (SELECT group_concat(role) FROM \ + (SELECT ars.role FROM artist_role_stats ars \ + WHERE ars.artist_id=ar.id ORDER BY ars.role)) AS roles", + $extra, + " FROM artist ar JOIN library_member m ON m.library_id=ar.library_id \ + LEFT JOIN user_star us ON us.user_id=m.user_id AND us.entity_type='artist' AND us.entity_id=ar.id \ + LEFT JOIN user_rating ur ON ur.user_id=m.user_id AND ur.entity_type='artist' AND ur.entity_id=ar.id \ + WHERE m.user_id=?" + ) + }; +} + +pub struct SubsonicCredentialRecord { + pub account: AccountRecord, + pub encrypted_password: EncryptedSecret, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct MusicFolderItem { + pub id: Uuid, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ArtistItem { + pub id: Uuid, + pub library_id: Uuid, + pub name: String, + pub artwork_hash: Option, + pub musicbrainz_id: Option, + /// The tagged sort form of the name, `None` when no file supplied one. + /// The Subsonic node emits it empty in that case rather than omitting it: + /// the field is supported, and this artist is untagged. + pub sort_name: Option, + pub starred_at: Option, + pub user_rating: Option, + /// The capacities this artist is credited in, anywhere in the catalogue. + /// + /// Derived from the credits rather than stored on the row, so an artist + /// who stops being a producer stops saying so at the next scan. + pub roles: Vec, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct AlbumItem { + pub id: Uuid, + pub library_id: Uuid, + pub title: String, + pub artist: Option, + pub artist_id: Option, + pub artwork_hash: Option, + pub year: Option, + pub is_compilation: bool, + pub musicbrainz_id: Option, + /// The tagged sort form of the title, on the same terms as the artist's. + pub sort_name: Option, + /// Every artist credited on the album's available tracks, and every + /// genre they carry. Derived rather than stored: an album has no credit + /// or genre of its own in the schema, only the union of its files'. + pub artists: Vec, + pub genres: Vec, + pub created_at: i64, + pub starred_at: Option, + pub user_rating: Option, + pub play_count: i64, + pub last_played_at: Option, + /// Available tracks in the album, and their total duration in + /// milliseconds. Projected here rather than derived by the caller: album + /// listings used to compute both by loading every track of the tenant, so + /// the counts cost a full catalogue read per request. + pub song_count: i64, + pub duration_ms: i64, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SongItem { + pub id: Uuid, + pub library_id: Uuid, + pub album_id: Option, + pub title: String, + pub album: Option, + pub artist: Option, + /// Primary credited artist, matching the first artist in `artist`. + pub artist_id: Option, + pub genre: Option, + pub year: Option, + pub track: Option, + pub disc: Option, + pub duration_ms: i64, + pub bitrate: Option, + pub codec: Option, + pub suffix: String, + pub size: i64, + pub artwork_hash: Option, + /// Content fingerprint: **BLAKE3, unkeyed, hexadecimal, over the whole + /// file** — 64 characters. A client can compute the same value locally and + /// compare, which is the only automatic link M5 accepts (a unique full-hash + /// match; MBID stays a suggestion to confirm). + /// + /// It fingerprints the *file*, not the decoded audio: two copies of one + /// recording with different tags do not match. The algorithm is part of the + /// contract — changing it means adding a field, never redefining this one. + pub full_hash: String, + pub created_at: i64, + pub starred_at: Option, + pub user_rating: Option, + pub sample_rate: Option, + pub channels: Option, + pub bit_depth: Option, + pub play_count: i64, + pub last_played_at: Option, + /// Every credited artist, in tag order. `artist` and `artist_id` stay the + /// display string and the primary credit; these are the structured form + /// `track_artist` has always held and no surface ever read. + pub artists: Vec, + /// Every genre of the track, from `track_genre` rather than from the + /// semicolon-joined `genre` display string. + pub genres: Vec, + /// The credit the album carries, which is not always the track's own: a + /// guest appearance names the guest, and the album still belongs under + /// the album artist. + pub album_artist: Option, + pub album_artist_id: Option, + /// Every artist the album is credited to, which the single `album_artist_id` + /// above can only ever name the first of. It stays because the frozen + /// `artistId` field needs one. + pub album_artists: Vec, + /// Every credit that is neither the track's artist nor its album artist: + /// composer, producer, performer and the rest, in role then tag order. + pub contributors: Vec, + /// The MusicBrainz recording identifier: the performance, which is what + /// OpenSubsonic means by a song's `musicBrainzId`. RFC-004 keeps a match + /// on it a candidate the user confirms, never an automatic link. + pub musicbrainz_id: Option, + pub replay_gain_track_gain: Option, + pub replay_gain_track_peak: Option, + pub replay_gain_album_gain: Option, + pub replay_gain_album_peak: Option, + pub bpm: Option, + pub sort_name: Option, + pub comment: Option, + /// Split from the tag the same way artists and genres are. + pub isrc: Vec, + pub moods: Vec, + /// `explicit` or `clean`; the scanner stores nothing else. + pub explicit_status: Option, +} + +/// One credited artist of a track. Only `id` and `name` are carried: those are +/// the required `ArtistID3` fields, and OpenSubsonic asks for no more than the +/// required ones inside a media item. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ArtistRef { + pub id: Uuid, + pub name: String, +} + +/// One artist credited on a track in some capacity other than being its +/// artist or its album artist. +/// +/// The role is the reference's own name for it, and `sub_role` carries the +/// instrument a performer is credited on — the only role that has one. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct Contributor { + pub role: String, + pub sub_role: Option, + pub artist: ArtistRef, +} + +/// A browse view that stops short of the tracks. +#[derive(Debug, Clone)] +pub struct CatalogOverview { + pub folders: Vec, + /// Carried as summaries because the browse that renders them needs the + /// album count, and computing it in the facade was a loop over every album + /// for every artist. + pub artists: Vec, + pub albums: Vec, +} + +#[derive(Debug, Clone)] +pub struct CatalogSnapshot { + pub folders: Vec, + pub artists: Vec, + pub albums: Vec, + pub songs: Vec, +} + +/// Everything one account has starred, across the three entity kinds. +#[derive(Debug, Clone)] +pub struct StarredCatalog { + pub artists: Vec, + pub albums: Vec, + pub songs: Vec, +} + +/// Result of a Subsonic `search3`, backed by the FTS5 index. +#[derive(Debug, Clone)] +pub struct CatalogSearch { + pub artists: Vec, + pub albums: Vec, + pub songs: Vec, +} + +/// Upper bound on a native browse page. It matches the Subsonic contract's +/// 500-item cap so both surfaces expose the same paging ceiling. +pub const MAX_BROWSE_LIMIT: i64 = 500; +const DEFAULT_BROWSE_LIMIT: i64 = 100; +pub const MAX_HISTORY_LIMIT: i64 = 500; +/// Fits a UUID-only queue request below the server's 16 KiB body limit while +/// also bounding the work performed under the global SQLite writer gate. +pub const MAX_QUEUE_TRACKS: usize = 400; +/// Applies the same request-size and writer-gate bound to public shares. +pub const MAX_SHARE_TRACKS: usize = MAX_QUEUE_TRACKS; + +/// Offset/limit pair validated once, at the HTTP boundary, so the SQL layer can +/// bind it without re-checking bounds. +#[derive(Debug, Clone, Copy)] +pub struct BrowsePage { + offset: i64, + limit: i64, +} + +impl BrowsePage { + pub fn new(offset: Option, limit: Option) -> Result { + let offset = offset.unwrap_or(0); + let limit = limit.unwrap_or(DEFAULT_BROWSE_LIMIT); + if offset < 0 || limit <= 0 || limit > MAX_BROWSE_LIMIT { + return Err(ServiceError::Invalid); + } + Ok(Self { offset, limit }) + } +} + +impl Default for BrowsePage { + fn default() -> Self { + Self { + offset: 0, + limit: DEFAULT_BROWSE_LIMIT, + } + } +} + +/// How an album listing is ordered and filtered. +/// +/// This is the single implementation of the ten Subsonic `getAlbumList2` modes, +/// and it lives in the domain services rather than in the facade for two +/// reasons. The facade used to sort in Rust over [`DomainServices::catalog_snapshot`], +/// which materialises every folder, artist, album *and track* the tenant can +/// see on each call — `byGenre` then rescanned every track once per album, and +/// `songCount` needed the whole track list just to be counted. And the native +/// API had no ordering at all, so the web client could not ask for "recently +/// added" without paging the entire catalogue itself. +/// +/// The variant names are the Subsonic `type` values verbatim, so the facade +/// stays a parameter adapter with no vocabulary of its own. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AlbumOrder { + #[default] + AlphabeticalByName, + AlphabeticalByArtist, + Newest, + Highest, + Frequent, + Recent, + Starred, + Random, + ByYear, + ByGenre, +} + +impl FromStr for AlbumOrder { + type Err = ServiceError; + + fn from_str(value: &str) -> Result { + Ok(match value { + "alphabeticalByName" => Self::AlphabeticalByName, + "alphabeticalByArtist" => Self::AlphabeticalByArtist, + "newest" => Self::Newest, + "highest" => Self::Highest, + "frequent" => Self::Frequent, + "recent" => Self::Recent, + "starred" => Self::Starred, + "random" => Self::Random, + "byYear" => Self::ByYear, + "byGenre" => Self::ByGenre, + _ => return Err(ServiceError::Invalid), + }) + } +} + +/// One album listing request. +#[derive(Debug, Clone, Default)] +pub struct AlbumListQuery { + /// Restrict to these libraries. Empty means every library the user can see. + /// It is a set because Subsonic sends repeated `musicFolderId` values. + pub library_ids: Vec, + pub order: AlbumOrder, + /// Required by [`AlbumOrder::ByGenre`], ignored otherwise. + pub genre: Option, + /// Bounds for [`AlbumOrder::ByYear`], inclusive. Supplying them reversed is + /// how Subsonic asks for descending years, and that is preserved here. + pub from_year: Option, + pub to_year: Option, + pub page: BrowsePage, +} + +/// A genre with the size of what it holds, aggregated across the libraries the +/// user can see. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct GenreItem { + pub name: String, + pub song_count: i64, + pub album_count: i64, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ArtistSummary { + #[serde(flatten)] + pub artist: ArtistItem, + pub album_count: i64, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct AlbumDetail { + #[serde(flatten)] + pub album: AlbumItem, + pub songs: Vec, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ArtistDetail { + #[serde(flatten)] + pub artist: ArtistItem, + /// Same field the list endpoint returns. `albums` below is unpaginated, so + /// its length matches — but that is an unwritten guarantee, and a client + /// should not have to depend on one. + pub album_count: i64, + pub albums: Vec, +} + +/// Inputs for a native client's authorization request. +#[derive(Debug, Clone, Copy)] +pub struct AuthorizationRequest<'a> { + pub client_id: &'a str, + pub redirect_uri: &'a str, + pub code_challenge: &'a str, + pub code_challenge_method: &'a str, + pub device_name: &'a str, + pub state: Option<&'a str>, + /// The scopes of the credential authorizing this grant. Recorded on the + /// grant so the session redeemed from it inherits them, which is what + /// keeps a session from ever being broader than what asked for it. + pub scopes: &'a [String], +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SearchResult { + pub artists: Vec, + pub albums: Vec, + pub songs: Vec, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct PlaylistItem { + pub id: Uuid, + pub name: String, + pub comment: Option, + pub public: bool, + pub created_at: i64, + pub updated_at: i64, + pub songs: Vec, +} + +/// A playback position the user saved in one track. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct BookmarkItem { + pub position_ms: i64, + pub comment: Option, + pub created_at: i64, + pub updated_at: i64, + /// The bookmarked track, resolved through the same tenant-filtered + /// projection every other surface reads. + pub song: SongItem, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct QueueItem { + pub current: Option, + pub position_ms: i64, + pub changed_by: Option, + pub updated_at: i64, + pub songs: Vec, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct RatingItem { + pub entity_type: String, + pub entity_id: Uuid, + pub rating: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct HistoryItem { + pub track_id: Uuid, + pub submission: bool, + pub played_at: i64, +} + +/// Optional fields a share update may blank out. +/// +/// `COALESCE(?, column)` cannot express this: an absent field and an explicit +/// null arrive as the same bind, so "leave it alone" and "remove it" collapse. +/// The consequence was not cosmetic — an expiry set by mistake could never be +/// lifted, and the owner's only recourse was to delete the share and mint a new +/// URL. Clearing is opt-in and named, so a client that merely omits a field can +/// never erase one by accident. +#[derive(Debug, Clone, Copy, Default)] +pub struct ShareClear { + pub description: bool, + pub expires_at: bool, +} + +/// Optional fields a playlist update may blank out. See [`ShareClear`]. +#[derive(Debug, Clone, Copy, Default)] +pub struct PlaylistClear { + pub comment: bool, + /// Drop the existing track list before applying `add`, which turns an + /// update into a replacement. Subsonic's `createPlaylist` needs it: given a + /// `playlistId`, its `songId` values are the whole playlist rather than + /// additions to it. The native surface does not expose it. + pub tracks: bool, +} + +#[derive(Debug, Clone)] +pub struct ShareItem { + pub id: Uuid, + pub owner_id: Uuid, + /// Present only in the result of a newly-created share. Persistent reads + /// deliberately cannot recover the bearer token from its lookup hash. + pub url_token: Option, + pub description: Option, + pub expires_at: Option, + pub created_at: i64, + pub visit_count: i64, + pub songs: Vec, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct UserItem { + pub id: Uuid, + pub username: String, + pub role: AccountRole, + pub disabled: bool, + pub has_subsonic_credential: bool, + pub folder_ids: Vec, +} + +pub struct UserUpdate<'a> { + pub admin: Option, + pub disabled: Option, + pub folder_ids: Option<&'a [Uuid]>, + pub subsonic_password: Option<&'a str>, + pub web_password: Option<&'a str>, +} + +pub struct SyncSnapshotData { + pub cursor: i64, + pub playlists: Vec, + pub favorites: Vec<(String, Uuid, i64)>, + pub ratings: Vec, + pub queue: Option, + pub history: Vec, + pub shares: Vec, + pub bookmarks: Vec, +} + +#[derive(Clone)] +pub struct DomainServices { + db: Database, + secret_box: Arc, + sync: SyncService, + scanner: crate::scanner::ScanManager, +} + +#[derive(Debug, thiserror::Error)] +pub enum ServiceError { + #[error("resource not found")] + NotFound, + #[error("operation is forbidden")] + Forbidden, + #[error("invalid input")] + Invalid, + #[error("conflict")] + Conflict, + #[error("service unavailable")] + Unavailable, + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + Security(#[from] security::SecurityError), +} + +impl From for ServiceError { + fn from(error: crate::sync::SyncError) -> Self { + match error { + crate::sync::SyncError::Invalid => Self::Invalid, + // Mutations claim operations; they never read the journal, so + // CursorExpired cannot reach this conversion. Folded into Conflict + // rather than given a domain variant nothing would ever construct. + crate::sync::SyncError::Conflict | crate::sync::SyncError::CursorExpired => { + Self::Conflict + } + crate::sync::SyncError::Database(error) => Self::Database(error), + } + } +} + +mod admin; +mod albums; +mod artists; +mod bookmarks; +mod catalog; +mod credentials; +mod favorites; +mod playback; +mod playlists; +mod scan; +mod search; +mod shares; +mod songs; +mod sync; + +impl DomainServices { + pub fn new( + db: Database, + secret_box: Arc, + sync: SyncService, + scanner: crate::scanner::ScanManager, + ) -> Self { + Self { + db, + secret_box, + sync, + scanner, + } + } + + async fn require_admin(&self, actor_id: Uuid) -> Result<(), ServiceError> { + let account = self + .db + .account_by_id(actor_id) + .await? + .ok_or(ServiceError::Forbidden)?; + if account.role == AccountRole::Admin && !account.disabled { + Ok(()) + } else { + Err(ServiceError::Forbidden) + } + } + + async fn resolve_library_ids( + &self, + requested: Option<&[Uuid]>, + ) -> Result, ServiceError> { + let available = sqlx::query_scalar::<_, String>("SELECT id FROM library ORDER BY id") + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(parse_uuid) + .collect::, _>>()?; + let Some(requested) = requested else { + return Ok(available); + }; + let mut unique = Vec::new(); + for id in requested { + if !available.contains(id) { + return Err(ServiceError::NotFound); + } + if !unique.contains(id) { + unique.push(*id); + } + } + Ok(unique) + } + + async fn authorize_entity_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + kind: &str, + id: Uuid, + ) -> Result<(), ServiceError> { + let query = match kind { + "track" => "SELECT 1 FROM track e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=? AND m.user_id=?", + "album" => "SELECT 1 FROM album e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=? AND m.user_id=?", + "artist" => "SELECT 1 FROM artist e JOIN library_member m ON m.library_id=e.library_id WHERE e.id=? AND m.user_id=?", + _ => return Err(ServiceError::Invalid), + }; + let exists = sqlx::query_scalar::<_, i64>(query) + .bind(id.to_string()) + .bind(user_id.to_string()) + .fetch_optional(&mut *connection) + .await?; + if exists.is_some() { + Ok(()) + } else { + Err(ServiceError::NotFound) + } + } +} + +/// The JSON library list a scoped projection binds, or `None` for "every +/// library the account can reach". Built once here because every scoped +/// query binds the same value twice and a second spelling of it would be a +/// second chance to get the empty case wrong. +fn folder_filter(library_ids: &[Uuid]) -> Option { + (!library_ids.is_empty()) + .then(|| serde_json::to_string(library_ids).expect("UUID list serialization cannot fail")) +} + +/// Fills in the album relations OpenSubsonic expects on `AlbumID3`. +/// +/// Both are derived from the album's own available tracks rather than stored: +/// an album has no genre or credit of its own in the schema, it has the union +/// of what its files carry. Loaded in one batch per relation like the song +/// relations, because an album listing is up to five hundred rows and a query +/// each would be a query per row. +/// +/// Tenancy is re-checked in the query. The batch is keyed by album id alone, so +/// the `library_member` join is what stops an id from another account resolving +/// to real names. +async fn attach_album_relations( + connection: &mut SqliteConnection, + user_id: Uuid, + albums: &mut [AlbumItem], +) -> Result<(), sqlx::Error> { + if albums.is_empty() { + return Ok(()); + } + let ids = serde_json::to_string(&albums.iter().map(|album| album.id).collect::>()) + .expect("UUID list serialization cannot fail"); + let mut artists: HashMap> = HashMap::new(); + for row in sqlx::query( + // The album's own credits, which are its album artists — not the union + // of its tracks' credits, which is what this used to answer. An album + // with a guest on one track was reporting the guest as one of its + // artists; the reference reports the two the album is credited to, and + // leaves the guest to the track that names them. + "SELECT ap.album_id, ar.id, ar.name \ + FROM album_participant ap \ + JOIN artist ar ON ar.id=ap.artist_id \ + JOIN library_member m ON m.library_id=ap.library_id \ + WHERE m.user_id=? AND ap.role='albumartist' \ + AND ap.album_id IN (SELECT value FROM json_each(?)) \ + ORDER BY ap.album_id, ap.position, ar.name COLLATE NOCASE, ar.id", + ) + .bind(user_id.to_string()) + .bind(&ids) + .fetch_all(&mut *connection) + .await? + { + artists + .entry(parse_uuid(row.try_get("album_id")?)?) + .or_default() + .push(ArtistRef { + id: parse_uuid(row.try_get("id")?)?, + name: row.try_get("name")?, + }); + } + let mut genres: HashMap> = HashMap::new(); + for row in sqlx::query( + // Grouped on the canonical name for the same reason `list_genres` is: + // otherwise one album spelling "Hip-Hop" on some tracks and "Hip Hop" + // on others reports two genres. + "SELECT t.album_id, MIN(g.name) AS name FROM track t \ + JOIN track_genre tg ON tg.track_id=t.id \ + JOIN genre g ON g.id=tg.genre_id \ + JOIN library_member m ON m.library_id=t.library_id \ + WHERE m.user_id=? AND t.is_available=1 \ + AND t.album_id IN (SELECT value FROM json_each(?)) \ + GROUP BY t.album_id, g.canonical_name \ + ORDER BY t.album_id, name COLLATE NOCASE", + ) + .bind(user_id.to_string()) + .bind(&ids) + .fetch_all(&mut *connection) + .await? + { + genres + .entry(parse_uuid(row.try_get("album_id")?)?) + .or_default() + .push(row.try_get("name")?); + } + for album in albums { + album.artists = artists.remove(&album.id).unwrap_or_default(); + album.genres = genres.remove(&album.id).unwrap_or_default(); + } + Ok(()) +} + +/// Fills in the relations a single projected row cannot carry. +/// +/// `song_select!` collapses credited artists and genres into the display strings +/// the tags happened to contain; the structured form lives in `track_artist` and +/// `track_genre`, ordered and deduplicated by the scanner. Reading them per song +/// would be two queries per row on every listing, so both are fetched once for +/// the whole batch and distributed by track id. +/// +/// Tenancy is re-checked here rather than inherited from the caller: the batch +/// is keyed by track id alone, and a join that trusted those ids would be the +/// one place in the read path where membership is not proven. +async fn attach_song_relations( + connection: &mut SqliteConnection, + user_id: Uuid, + songs: &mut [SongItem], +) -> Result<(), sqlx::Error> { + if songs.is_empty() { + return Ok(()); + } + let ids = serde_json::to_string(&songs.iter().map(|song| song.id).collect::>()) + .expect("UUID list serialization cannot fail"); + let mut artists: HashMap> = HashMap::new(); + for row in sqlx::query( + "SELECT tp.track_id, ar.id, ar.name FROM track_participant tp \ + JOIN artist ar ON ar.id=tp.artist_id \ + JOIN library_member m ON m.library_id=tp.library_id \ + WHERE m.user_id=? AND tp.role='artist' \ + AND tp.track_id IN (SELECT value FROM json_each(?)) \ + ORDER BY tp.track_id, tp.position", + ) + .bind(user_id.to_string()) + .bind(&ids) + .fetch_all(&mut *connection) + .await? + { + artists + .entry(parse_uuid(row.try_get("track_id")?)?) + .or_default() + .push(ArtistRef { + id: parse_uuid(row.try_get("id")?)?, + name: row.try_get("name")?, + }); + } + let mut genres: HashMap> = HashMap::new(); + for row in sqlx::query( + // `track_genre` has no position column, so the order is the genre name. + // It has to be deterministic: a client diffing two responses would + // otherwise see a change that is not one. + "SELECT tg.track_id, g.name FROM track_genre tg \ + JOIN genre g ON g.id=tg.genre_id \ + JOIN library_member m ON m.library_id=tg.library_id \ + WHERE m.user_id=? AND tg.track_id IN (SELECT value FROM json_each(?)) \ + ORDER BY tg.track_id, g.name COLLATE NOCASE, g.id", + ) + .bind(user_id.to_string()) + .bind(&ids) + .fetch_all(&mut *connection) + .await? + { + genres + .entry(parse_uuid(row.try_get("track_id")?)?) + .or_default() + .push(row.try_get("name")?); + } + // Everything credited on the track that is neither its artist nor its + // album artist. Ordered by role then position so two responses for one + // track are byte-identical — the reference emits these in map-iteration + // order and answers differently on every request. + let mut contributors: HashMap> = HashMap::new(); + for row in sqlx::query( + "SELECT tp.track_id, tp.role, tp.sub_role, ar.id, ar.name \ + FROM track_participant tp \ + JOIN artist ar ON ar.id=tp.artist_id \ + JOIN library_member m ON m.library_id=tp.library_id \ + WHERE m.user_id=? AND tp.role NOT IN ('artist', 'albumartist') \ + AND tp.track_id IN (SELECT value FROM json_each(?)) \ + ORDER BY tp.track_id, tp.role, tp.position, tp.sub_role", + ) + .bind(user_id.to_string()) + .bind(&ids) + .fetch_all(&mut *connection) + .await? + { + let sub_role: String = row.try_get("sub_role")?; + contributors + .entry(parse_uuid(row.try_get("track_id")?)?) + .or_default() + .push(Contributor { + role: row.try_get("role")?, + sub_role: (!sub_role.is_empty()).then_some(sub_role), + artist: ArtistRef { + id: parse_uuid(row.try_get("id")?)?, + name: row.try_get("name")?, + }, + }); + } + // The album's credit, which is not the track's: a guest appearance names + // the guest while the album still belongs under its album artists. Keyed + // on the album, so every track of one album answers the same list. + let album_ids = serde_json::to_string( + &songs + .iter() + .filter_map(|song| song.album_id) + .collect::>(), + ) + .expect("UUID list serialization cannot fail"); + let mut album_artists: HashMap> = HashMap::new(); + for row in sqlx::query( + "SELECT ap.album_id, ar.id, ar.name FROM album_participant ap \ + JOIN artist ar ON ar.id=ap.artist_id \ + JOIN library_member m ON m.library_id=ap.library_id \ + WHERE m.user_id=? AND ap.role='albumartist' \ + AND ap.album_id IN (SELECT value FROM json_each(?)) \ + ORDER BY ap.album_id, ap.position, ar.name COLLATE NOCASE, ar.id", + ) + .bind(user_id.to_string()) + .bind(&album_ids) + .fetch_all(&mut *connection) + .await? + { + album_artists + .entry(parse_uuid(row.try_get("album_id")?)?) + .or_default() + .push(ArtistRef { + id: parse_uuid(row.try_get("id")?)?, + name: row.try_get("name")?, + }); + } + for song in songs { + song.artists = artists.remove(&song.id).unwrap_or_default(); + song.genres = genres.remove(&song.id).unwrap_or_default(); + song.contributors = contributors.remove(&song.id).unwrap_or_default(); + song.album_artists = song + .album_id + .and_then(|album| album_artists.get(&album).cloned()) + .unwrap_or_default(); + } + Ok(()) +} + +async fn fetch_songs( + db: &Database, + user_id: Uuid, + folder_filter: Option<&str>, + id: Option, +) -> Result, sqlx::Error> { + let id = id.map(|id| id.to_string()); + let mut songs = sqlx::query(concat!( + song_select!(), + " AND (? IS NULL OR t.library_id IN (SELECT value FROM json_each(?))) \ + AND (? IS NULL OR t.id=?) ORDER BY t.title COLLATE NOCASE" + )) + .bind(user_id.to_string()) + .bind(folder_filter) + .bind(folder_filter) + .bind(id.as_deref()) + .bind(id.as_deref()) + .fetch_all(db.pool()) + .await? + .into_iter() + .map(song_from_row) + .collect::, _>>()?; + attach_song_relations(&mut *db.pool().acquire().await?, user_id, &mut songs).await?; + Ok(songs) +} + +fn credential_from_row( + row: sqlx::sqlite::SqliteRow, +) -> Result { + let nonce = row.try_get::, _>("password_nonce")?; + let nonce: [u8; 12] = nonce.try_into().map_err(|value: Vec| { + sqlx::Error::Decode(format!("invalid credential nonce length: {}", value.len()).into()) + })?; + Ok(SubsonicCredentialRecord { + account: AccountRecord { + id: parse_uuid(row.try_get("id")?)?, + username: row.try_get("username")?, + password_hash: row.try_get("password_hash")?, + role: AccountRole::from_str(row.try_get::<&str, _>("role")?) + .map_err(|error| sqlx::Error::Decode(error.into()))?, + disabled: row.try_get::("disabled")? != 0, + }, + encrypted_password: EncryptedSecret { + nonce, + ciphertext: row.try_get("password_ciphertext")?, + }, + }) +} + +fn artist_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + Ok(ArtistItem { + // Sorted here rather than trusted from `group_concat`, whose order + // SQLite does not guarantee even when the subquery feeding it is + // ordered. Two responses for one artist have to be byte-identical. + roles: { + let mut roles: Vec = row + .try_get::, _>("roles")? + .map(|roles| roles.split(',').map(str::to_owned).collect()) + .unwrap_or_default(); + roles.sort_unstable(); + roles + }, + id: parse_uuid(row.try_get("id")?)?, + library_id: parse_uuid(row.try_get("library_id")?)?, + name: row.try_get("name")?, + artwork_hash: row.try_get("artwork_hash")?, + musicbrainz_id: row.try_get("musicbrainz_id")?, + sort_name: row.try_get("sort_name")?, + starred_at: row.try_get("starred_at")?, + user_rating: row.try_get("user_rating")?, + }) +} + +fn artist_summary_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + let album_count = row.try_get("album_count")?; + Ok(ArtistSummary { + artist: artist_from_row(row)?, + album_count, + }) +} + +fn album_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + Ok(AlbumItem { + id: parse_uuid(row.try_get("id")?)?, + library_id: parse_uuid(row.try_get("library_id")?)?, + title: row.try_get("title")?, + artist: row.try_get("album_artist_name")?, + artist_id: row + .try_get::, _>("album_artist_id")? + .map(parse_uuid) + .transpose()?, + artwork_hash: row.try_get("artwork_hash")?, + year: row.try_get("year")?, + is_compilation: row.try_get::("is_compilation")? != 0, + sort_name: row.try_get("sort_name")?, + musicbrainz_id: row.try_get("musicbrainz_id")?, + // Loaded in a batch by `attach_album_relations`, never row by row. + artists: Vec::new(), + genres: Vec::new(), + created_at: row.try_get("created_at")?, + starred_at: row.try_get("starred_at")?, + user_rating: row.try_get("user_rating")?, + play_count: row.try_get("play_count")?, + last_played_at: row.try_get("last_played_at")?, + song_count: row.try_get("song_count")?, + duration_ms: row.try_get("duration_ms")?, + }) +} + +fn lyrics_list_from_rows( + track_id: Uuid, + rows: Vec, +) -> Result { + let first = rows.first().ok_or(ServiceError::NotFound)?; + let display_title: String = first.try_get("title")?; + let display_artist: Option = first.try_get("artist_display")?; + let mut structured_lyrics = Vec::new(); + for row in rows { + let Some(content) = row.try_get::, _>("content")? else { + continue; + }; + let synced = row.try_get::, _>("synced")?.unwrap_or(0) != 0; + structured_lyrics.push(StructuredLyrics { + display_artist: display_artist.clone(), + display_title: display_title.clone(), + lang: row + .try_get::, _>("lang")? + .unwrap_or_else(|| "xxx".into()), + synced, + lines: lyrics::lines(&content, synced), + }); + } + Ok(LyricsList { + track_id, + structured_lyrics, + }) +} + +/// Splits a multi-valued tag string the way the scanner stored it. +/// +/// The scanner writes these joined on `;`, so a reader that did not split +/// them would hand a client one value that is really several. +fn split_tag_values(raw: Option<&str>) -> Vec { + raw.into_iter() + .flat_map(|value| value.split(';')) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .collect() +} + +fn song_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + let relative: String = row.try_get("relative_path")?; + Ok(SongItem { + id: parse_uuid(row.try_get("id")?)?, + library_id: parse_uuid(row.try_get("library_id")?)?, + album_id: row + .try_get::, _>("album_id")? + .map(parse_uuid) + .transpose()?, + title: row.try_get("title")?, + album: row.try_get("album_title")?, + artist: row.try_get("artist_display")?, + artist_id: row + .try_get::, _>("artist_id")? + .map(parse_uuid) + .transpose()?, + genre: row.try_get("genre_display")?, + year: row.try_get("year")?, + track: row.try_get("track_number")?, + disc: row.try_get("disc_number")?, + duration_ms: row.try_get("duration_ms")?, + bitrate: row.try_get("bitrate")?, + codec: row.try_get("codec")?, + suffix: PathBuf::from(relative) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(), + size: row.try_get("file_size")?, + artwork_hash: row.try_get("artwork_hash")?, + full_hash: row.try_get("full_hash")?, + created_at: row.try_get("created_at")?, + starred_at: row.try_get("starred_at")?, + user_rating: row.try_get("user_rating")?, + sample_rate: row.try_get("sample_rate")?, + channels: row.try_get("channels")?, + bit_depth: row.try_get("bit_depth")?, + play_count: row.try_get("play_count")?, + last_played_at: row.try_get("last_played_at")?, + // Filled in by `attach_song_relations`: one row cannot carry them. + artists: Vec::new(), + album_artists: Vec::new(), + contributors: Vec::new(), + genres: Vec::new(), + album_artist: row.try_get("album_artist_name")?, + album_artist_id: row + .try_get::, _>("album_artist_id")? + .map(parse_uuid) + .transpose()?, + musicbrainz_id: row.try_get("musicbrainz_recording_id")?, + replay_gain_track_gain: row.try_get("replay_gain_track_gain")?, + replay_gain_track_peak: row.try_get("replay_gain_track_peak")?, + replay_gain_album_gain: row.try_get("replay_gain_album_gain")?, + replay_gain_album_peak: row.try_get("replay_gain_album_peak")?, + bpm: row.try_get("bpm")?, + sort_name: row.try_get("sort_title")?, + comment: row.try_get("comment")?, + isrc: split_tag_values(row.try_get::, _>("isrc")?.as_deref()), + moods: split_tag_values(row.try_get::, _>("moods")?.as_deref()), + explicit_status: row.try_get("explicit_status")?, + }) +} + +async fn replace_playlist_tracks( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + playlist: Uuid, + ids: &[Uuid], + now: i64, +) -> Result<(), sqlx::Error> { + for (position, id) in ids.iter().enumerate() { + sqlx::query("INSERT INTO playlist_track (playlist_id, track_id, position, added_at) VALUES (?, ?, ?, ?)") + .bind(playlist.to_string()).bind(id.to_string()).bind(position as i64).bind(now).execute(&mut **tx).await?; + } + Ok(()) +} + +fn validate_name(name: &str) -> Result<(), ServiceError> { + if (1..=200).contains(&name.trim().chars().count()) { + Ok(()) + } else { + Err(ServiceError::Invalid) + } +} + +fn validate_replay_type(receipt: &MutationReceipt, expected: &str) -> Result<(), ServiceError> { + if receipt.entity_type == expected { + Ok(()) + } else { + Err(ServiceError::Conflict) + } +} + +fn validate_username(username: &str) -> Result<(), ServiceError> { + let username = username.trim(); + if !(3..=64).contains(&username.len()) + || !username.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + { + Err(ServiceError::Invalid) + } else { + Ok(()) + } +} + +fn parse_uuid(value: String) -> Result { + Uuid::from_str(&value).map_err(|error| sqlx::Error::Decode(Box::new(error))) +} diff --git a/src/services/playback.rs b/src/services/playback.rs new file mode 100644 index 0000000..170827b --- /dev/null +++ b/src/services/playback.rs @@ -0,0 +1,309 @@ +//! Scrobbles, listening history and the saved queue. +//! +//! Split out of `services.rs`; see [`super`] for the shared projections. + +use super::*; + +impl DomainServices { + pub async fn scrobble( + &self, + user_id: Uuid, + track_id: Uuid, + submission: bool, + time: Option, + ) -> Result<(), ServiceError> { + self.scrobble_with_context( + user_id, + track_id, + submission, + time, + MutationContext::server_generated(), + ) + .await + } + + pub async fn scrobble_with_context( + &self, + user_id: Uuid, + track_id: Uuid, + submission: bool, + time: Option, + context: MutationContext, + ) -> Result<(), ServiceError> { + let intent = MutationIntent::new( + if submission { + "scrobble" + } else { + "now-playing" + }, + &format!("track:{track_id}"), + &serde_json::json!({ "submission": submission, "time": time }), + ); + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "scrobble")?; + return Ok(()); + } + self.authorize_entity_on(&mut tx, user_id, "track", track_id) + .await?; + let current_time = now_ms(); + let now = time.unwrap_or(current_time); + const MAX_FUTURE_SKEW_MS: i64 = 5 * 60 * 1_000; + if now < 0 || now > current_time.saturating_add(MAX_FUTURE_SKEW_MS) { + return Err(ServiceError::Invalid); + } + sqlx::query( + "INSERT INTO play_event (user_id, track_id, submission, played_at) VALUES (?, ?, ?, ?)", + ) + .bind(user_id.to_string()) + .bind(track_id.to_string()) + .bind(i64::from(submission)) + .bind(now) + .execute(&mut *tx) + .await?; + if submission { + sqlx::query("DELETE FROM now_playing WHERE user_id=?") + .bind(user_id.to_string()) + .execute(&mut *tx) + .await?; + } else { + sqlx::query("INSERT INTO now_playing (user_id, track_id, started_at, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (user_id) DO UPDATE SET track_id=excluded.track_id, started_at=excluded.started_at, updated_at=excluded.updated_at") + .bind(user_id.to_string()).bind(track_id.to_string()).bind(now).bind(now_ms()).execute(&mut *tx).await?; + } + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "scrobble", + track_id, + if submission { "append" } else { "upsert" }, + &serde_json::json!({ + "track_id": track_id, + "submission": submission, + "played_at": now, + }), + Some(track_id), + ) + .await?; + tx.commit().await?; + self.sync.publish(user_id, receipt); + Ok(()) + } + + pub async fn now_playing( + &self, + user_id: Uuid, + ) -> Result, ServiceError> { + let rows = sqlx::query( + "SELECT a.username, n.track_id, n.started_at FROM now_playing n \ + JOIN account a ON a.id=n.user_id WHERE a.disabled=0 ORDER BY n.started_at DESC", + ) + .fetch_all(self.db.pool()) + .await?; + let mut result = Vec::new(); + for row in rows { + let id = parse_uuid(row.try_get("track_id")?)?; + match self.songs_by_ids(user_id, &[id]).await { + Ok(mut songs) => { + if let Some(song) = songs.pop() { + result.push((row.try_get("username")?, song, row.try_get("started_at")?)); + } + } + Err(ServiceError::NotFound) => continue, + Err(error) => return Err(error), + } + } + Ok(result) + } + + pub async fn history( + &self, + user_id: Uuid, + limit: i64, + ) -> Result, ServiceError> { + let mut connection = self.db.pool().acquire().await?; + self.history_on(&mut connection, user_id, limit).await + } + + pub(super) async fn history_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + limit: i64, + ) -> Result, ServiceError> { + if !(0..=MAX_HISTORY_LIMIT).contains(&limit) { + return Err(ServiceError::Invalid); + } + sqlx::query( + "SELECT p.track_id, p.submission, p.played_at FROM play_event p \ + JOIN track t ON t.id=p.track_id JOIN library_member m ON m.library_id=t.library_id \ + WHERE p.user_id=? AND m.user_id=? ORDER BY p.played_at DESC, p.id DESC LIMIT ?", + ) + .bind(user_id.to_string()) + .bind(user_id.to_string()) + .bind(limit) + .fetch_all(&mut *connection) + .await? + .into_iter() + .map(|row| { + Ok(HistoryItem { + track_id: parse_uuid(row.try_get("track_id")?)?, + submission: row.try_get::("submission")? != 0, + played_at: row.try_get("played_at")?, + }) + }) + .collect::, sqlx::Error>>() + .map_err(Into::into) + } + + pub async fn save_queue( + &self, + user_id: Uuid, + ids: &[Uuid], + current: Option, + position_ms: i64, + client: Option<&str>, + ) -> Result<(), ServiceError> { + self.save_queue_with_context( + user_id, + ids, + current, + position_ms, + client, + MutationContext::server_generated(), + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn save_queue_with_context( + &self, + user_id: Uuid, + ids: &[Uuid], + current: Option, + position_ms: i64, + client: Option<&str>, + context: MutationContext, + ) -> Result<(), ServiceError> { + let intent = MutationIntent::new( + "save", + &format!("queue:{user_id}"), + &serde_json::json!({ + "track_ids": ids, + "current": current, + "position_ms": position_ms, + "client": client, + }), + ); + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "queue")?; + return Ok(()); + } + if ids.len() > MAX_QUEUE_TRACKS { + return Err(ServiceError::Invalid); + } + if position_ms < 0 { + return Err(ServiceError::Invalid); + } + self.songs_by_ids_on(&mut tx, user_id, ids).await?; + if let Some(current) = current { + self.songs_by_ids_on(&mut tx, user_id, &[current]).await?; + } + sqlx::query("INSERT INTO play_queue (user_id, current_track_id, position_ms, changed_by, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (user_id) DO UPDATE SET current_track_id=excluded.current_track_id, position_ms=excluded.position_ms, changed_by=excluded.changed_by, updated_at=excluded.updated_at") + .bind(user_id.to_string()).bind(current.map(|id| id.to_string())).bind(position_ms).bind(client).bind(now_ms()).execute(&mut *tx).await?; + sqlx::query("DELETE FROM play_queue_track WHERE user_id=?") + .bind(user_id.to_string()) + .execute(&mut *tx) + .await?; + if !ids.is_empty() { + let ids_json = serde_json::to_string(ids).map_err(|_| ServiceError::Invalid)?; + sqlx::query( + "INSERT INTO play_queue_track (user_id, track_id, position) \ + SELECT ?, value, CAST(key AS INTEGER) FROM json_each(?)", + ) + .bind(user_id.to_string()) + .bind(ids_json) + .execute(&mut *tx) + .await?; + } + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "queue", + user_id, + "upsert", + &serde_json::json!({ + "track_ids": ids, + "current": current, + "position_ms": position_ms, + "client": client, + }), + Some(user_id), + ) + .await?; + tx.commit().await?; + self.sync.publish(user_id, receipt); + Ok(()) + } + + pub async fn queue(&self, user_id: Uuid) -> Result, ServiceError> { + let mut connection = self.db.pool().acquire().await?; + self.queue_on(&mut connection, user_id).await + } + + pub(super) async fn queue_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ) -> Result, ServiceError> { + let row = sqlx::query("SELECT current_track_id, position_ms, changed_by, updated_at FROM play_queue WHERE user_id=?") + .bind(user_id.to_string()).fetch_optional(&mut *connection).await?; + let Some(row) = row else { + return Ok(None); + }; + let ids = sqlx::query_scalar::<_, String>( + "SELECT track_id FROM play_queue_track WHERE user_id=? ORDER BY position", + ) + .bind(user_id.to_string()) + .fetch_all(&mut *connection) + .await? + .into_iter() + .map(parse_uuid) + .collect::, _>>()?; + let current = row + .try_get::, _>("current_track_id")? + .map(parse_uuid) + .transpose()?; + let songs = self + .songs_by_ids_lenient_on(connection, user_id, &ids) + .await?; + Ok(Some(QueueItem { + // The lenient resolution above drops a track that went unavailable + // since the queue was saved. Keeping `current` on it would name a + // song the client was never handed. + current: current.filter(|id| songs.iter().any(|song| song.id == *id)), + position_ms: row.try_get("position_ms")?, + changed_by: row.try_get("changed_by")?, + updated_at: row.try_get("updated_at")?, + songs, + })) + } +} diff --git a/src/services/playlists.rs b/src/services/playlists.rs new file mode 100644 index 0000000..f8caf23 --- /dev/null +++ b/src/services/playlists.rs @@ -0,0 +1,360 @@ +//! Playlists and their track lists. +//! +//! Split out of `services.rs`; see [`super`] for the shared projections. + +use super::*; + +impl DomainServices { + pub async fn playlists(&self, user_id: Uuid) -> Result, ServiceError> { + let mut connection = self.db.pool().acquire().await?; + self.playlists_on(&mut connection, user_id).await + } + + pub(super) async fn playlists_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ) -> Result, ServiceError> { + let rows = sqlx::query( + "SELECT id, name, comment, public, created_at, updated_at FROM playlist \ + WHERE owner_user_id=? ORDER BY updated_at DESC, id", + ) + .bind(user_id.to_string()) + .fetch_all(&mut *connection) + .await?; + let mut result = Vec::with_capacity(rows.len()); + for row in rows { + let id = parse_uuid(row.try_get("id")?)?; + result.push(PlaylistItem { + id, + name: row.try_get("name")?, + comment: row.try_get("comment")?, + public: row.try_get::("public")? != 0, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + songs: self.playlist_songs_on(connection, user_id, id).await?, + }); + } + Ok(result) + } + + pub async fn playlist(&self, user_id: Uuid, id: Uuid) -> Result { + let mut connection = self.db.pool().acquire().await?; + self.playlist_on(&mut connection, user_id, id).await + } + + async fn playlist_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + id: Uuid, + ) -> Result { + let row = sqlx::query( + "SELECT id, name, comment, public, created_at, updated_at FROM playlist \ + WHERE id=? AND owner_user_id=?", + ) + .bind(id.to_string()) + .bind(user_id.to_string()) + .fetch_optional(&mut *connection) + .await? + .ok_or(ServiceError::NotFound)?; + Ok(PlaylistItem { + id, + name: row.try_get("name")?, + comment: row.try_get("comment")?, + public: row.try_get::("public")? != 0, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + songs: self.playlist_songs_on(connection, user_id, id).await?, + }) + } + + async fn playlist_songs_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + playlist_id: Uuid, + ) -> Result, ServiceError> { + let ids = self + .playlist_track_ids_on(connection, user_id, playlist_id) + .await?; + self.songs_by_ids_lenient_on(connection, user_id, &ids) + .await + } + + async fn playlist_track_ids_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + playlist_id: Uuid, + ) -> Result, ServiceError> { + sqlx::query_scalar::<_, String>( + "SELECT pt.track_id FROM playlist_track pt JOIN playlist p ON p.id=pt.playlist_id \ + WHERE p.id=? AND p.owner_user_id=? ORDER BY pt.position", + ) + .bind(playlist_id.to_string()) + .bind(user_id.to_string()) + .fetch_all(&mut *connection) + .await? + .into_iter() + .map(parse_uuid) + .collect::, _>>() + .map_err(Into::into) + } + + pub async fn create_playlist( + &self, + user_id: Uuid, + name: &str, + track_ids: &[Uuid], + ) -> Result { + self.create_playlist_with_context( + user_id, + name, + track_ids, + MutationContext::server_generated(), + ) + .await + } + + pub async fn create_playlist_with_context( + &self, + user_id: Uuid, + name: &str, + track_ids: &[Uuid], + context: MutationContext, + ) -> Result { + let intent = MutationIntent::new( + "create", + "playlist", + &serde_json::json!({ "name": name.trim(), "track_ids": track_ids }), + ); + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "playlist")?; + let id = receipt.result_entity_id.ok_or(ServiceError::Conflict)?; + drop(_writer); + return self.playlist(user_id, id).await; + } + validate_name(name)?; + self.songs_by_ids_on(&mut tx, user_id, track_ids).await?; + let id = Uuid::new_v4(); + let now = now_ms(); + sqlx::query("INSERT INTO playlist (id, owner_user_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)") + .bind(id.to_string()).bind(user_id.to_string()).bind(name.trim()).bind(now).bind(now) + .execute(&mut *tx).await?; + replace_playlist_tracks(&mut tx, id, track_ids, now).await?; + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "playlist", + id, + "upsert", + &serde_json::json!({ + "id": id, + "name": name.trim(), + "track_ids": track_ids, + }), + Some(id), + ) + .await?; + tx.commit().await?; + drop(_writer); + self.sync.publish(user_id, receipt); + self.playlist(user_id, id).await + } + + #[allow(clippy::too_many_arguments)] + pub async fn update_playlist( + &self, + user_id: Uuid, + id: Uuid, + name: Option<&str>, + comment: Option<&str>, + public: Option, + add: &[Uuid], + remove_indexes: &[usize], + clear: PlaylistClear, + ) -> Result { + self.update_playlist_with_context( + user_id, + id, + name, + comment, + public, + add, + remove_indexes, + clear, + MutationContext::server_generated(), + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn update_playlist_with_context( + &self, + user_id: Uuid, + id: Uuid, + name: Option<&str>, + comment: Option<&str>, + public: Option, + add: &[Uuid], + remove_indexes: &[usize], + clear: PlaylistClear, + context: MutationContext, + ) -> Result { + let mut removes = remove_indexes.to_vec(); + removes.sort_unstable_by(|a, b| b.cmp(a)); + removes.dedup(); + let mut intent_payload = serde_json::json!({ + "name": name.map(str::trim), + "comment": comment, + "public": public, + "add": add, + "remove_indexes": &removes, + "clear_comment": clear.comment, + }); + // Added to the payload only when set. The intent is hashed and compared + // on replay, so naming a new field unconditionally would change the + // hash of every update this server version ever saw before, and turn a + // client's retry across an upgrade into a conflict. + if clear.tracks { + intent_payload["clear_tracks"] = serde_json::Value::Bool(true); + } + let intent = MutationIntent::new("update", &format!("playlist:{id}"), &intent_payload); + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "playlist")?; + drop(_writer); + return self.playlist(user_id, id).await; + } + let current = self.playlist_on(&mut tx, user_id, id).await?; + if let Some(name) = name { + validate_name(name)?; + } + self.songs_by_ids_on(&mut tx, user_id, add).await?; + let mut ids = if clear.tracks { + Vec::new() + } else { + self.playlist_track_ids_on(&mut tx, user_id, id).await? + }; + for index in removes { + if index >= ids.len() { + return Err(ServiceError::Invalid); + } + ids.remove(index); + } + ids.extend_from_slice(add); + let changed_at = now_ms(); + sqlx::query( + "UPDATE playlist SET name=COALESCE(?, name), \ + comment=CASE WHEN ? THEN NULL ELSE COALESCE(?, comment) END, \ + public=COALESCE(?, public), updated_at=? WHERE id=? AND owner_user_id=?", + ) + .bind(name.map(str::trim)) + .bind(clear.comment) + .bind(comment) + .bind(public.map(i64::from)) + .bind(changed_at) + .bind(id.to_string()) + .bind(user_id.to_string()) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM playlist_track WHERE playlist_id=?") + .bind(id.to_string()) + .execute(&mut *tx) + .await?; + replace_playlist_tracks(&mut tx, id, &ids, changed_at).await?; + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "playlist", + id, + "upsert", + &serde_json::json!({ + "id": id, + "name": name.map(str::trim).unwrap_or(¤t.name), + "comment": comment.or(current.comment.as_deref()), + "public": public.unwrap_or(current.public), + "track_ids": ids, + }), + Some(id), + ) + .await?; + tx.commit().await?; + drop(_writer); + self.sync.publish(user_id, receipt); + self.playlist(user_id, id).await + } + + pub async fn delete_playlist(&self, user_id: Uuid, id: Uuid) -> Result<(), ServiceError> { + self.delete_playlist_with_context(user_id, id, MutationContext::server_generated()) + .await + } + + pub async fn delete_playlist_with_context( + &self, + user_id: Uuid, + id: Uuid, + context: MutationContext, + ) -> Result<(), ServiceError> { + let intent = + MutationIntent::new("delete", &format!("playlist:{id}"), &serde_json::json!({})); + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "playlist")?; + return Ok(()); + } + let changed = sqlx::query("DELETE FROM playlist WHERE id=? AND owner_user_id=?") + .bind(id.to_string()) + .bind(user_id.to_string()) + .execute(&mut *tx) + .await? + .rows_affected(); + if changed == 0 { + tx.rollback().await?; + Err(ServiceError::NotFound) + } else { + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "playlist", + id, + "delete", + &serde_json::json!({}), + Some(id), + ) + .await?; + tx.commit().await?; + self.sync.publish(user_id, receipt); + Ok(()) + } + } +} diff --git a/src/services/scan.rs b/src/services/scan.rs new file mode 100644 index 0000000..ad662d7 --- /dev/null +++ b/src/services/scan.rs @@ -0,0 +1,78 @@ +//! Library scan jobs. +//! +//! Split out of `services.rs`; see [`super`] for the shared projections. + +use super::*; + +impl DomainServices { + /// Queues a rescan of one library the user can reach. + /// + /// The single implementation behind `POST /api/v2/libraries/{id}/scans` + /// and the Subsonic `startScan`. Both surfaces have to answer the same + /// question about who may scan what, so the membership check cannot sit + /// in a handler where the two copies can drift apart. + pub async fn start_library_scan( + &self, + user_id: Uuid, + library_id: Uuid, + ) -> Result { + let library = self + .db + .library_for_user(user_id, library_id) + .await? + .ok_or(ServiceError::NotFound)?; + // The lookup above reads the root path; it is not what authorises the + // job. The insert tests `library_member` itself — membership and role + // together — so an access revoked or downgraded between the two refuses + // the job instead of queuing work the requester may no longer ask for. + let scan_id = self + .db + .create_scan_job_for_user(user_id, library_id, "manual") + .await? + .ok_or(ServiceError::NotFound)?; + self.scanner.spawn(scan_id, library); + Ok(scan_id) + } + + /// Queues a rescan of every library the user may scan, for the Subsonic + /// `startScan`, which takes no library parameter. + /// + /// Libraries the account only listens to are skipped rather than attempted + /// and reported: `startScan` names no library, so refusing the whole call + /// because one of the account's libraries is read-only would put the + /// scannable ones out of reach from Subsonic entirely. + /// + /// An account that may scan nothing therefore queues nothing and succeeds, + /// like an account that reaches no library at all: there is no missing + /// resource to report, and every other catalogue-wide method answers such + /// an account with an empty result rather than an error. + /// + /// Best effort by design: a library whose job cannot be queued does not + /// cancel the ones that can. Aborting on the first failure would leave + /// the caller reading an error while half the catalogue is already + /// rescanning, which is the worst of both answers. The error surfaces + /// only when nothing at all could be queued. + /// + /// Re-queuing a library that is already scanning is deliberately allowed, + /// exactly as calling the native endpoint twice is: [`crate::scanner::ScanManager`] + /// serialises jobs per library and a scan converges on file content, so a + /// redundant pass costs time and changes nothing. + pub async fn start_visible_scans(&self, user_id: Uuid) -> Result, ServiceError> { + let libraries = self.db.libraries_for_user(user_id).await?; + let mut queued = Vec::new(); + let mut failure = None; + for access in libraries + .into_iter() + .filter(|access| access.role.may_scan()) + { + match self.start_library_scan(user_id, access.id).await { + Ok(scan_id) => queued.push(scan_id), + Err(error) => failure = Some(error), + } + } + match failure { + Some(error) if queued.is_empty() => Err(error), + _ => Ok(queued), + } + } +} diff --git a/src/services/search.rs b/src/services/search.rs new file mode 100644 index 0000000..c295142 --- /dev/null +++ b/src/services/search.rs @@ -0,0 +1,161 @@ +//! Directory browsing and search. +//! +//! Split out of `services.rs`; see [`super`] for the shared projections. + +use super::*; + +impl DomainServices { + /// The whole visible catalogue, each kind ordered and paged in SQL. + /// + /// Subsonic clients send the literal `""` to `search3` as the documented + /// match-all query, and page through it to build their initial library. + /// FTS5 has no expression meaning "everything", so this is not a search at + /// all — it is three ordinary listings under the search response. It used + /// to read the entire catalogue and slice it in Rust, once per page, which + /// made a client's first synchronization quadratic in the library. + /// + /// A page beyond the end is an empty list rather than an error: that is how + /// a client learns it has reached the end. + pub async fn browse_all( + &self, + user_id: Uuid, + library_ids: &[Uuid], + artists: BrowsePage, + albums: BrowsePage, + songs: BrowsePage, + ) -> Result { + let folders = folder_filter(library_ids); + let artists = sqlx::query(concat!( + artist_select!(), + " AND (? IS NULL OR ar.library_id IN (SELECT value FROM json_each(?))) \ + ORDER BY ar.name COLLATE NOCASE, ar.id LIMIT ? OFFSET ?" + )) + .bind(user_id.to_string()) + .bind(folders.as_deref()) + .bind(folders.as_deref()) + .bind(artists.limit) + .bind(artists.offset) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(artist_from_row) + .collect::, _>>()?; + let mut albums = sqlx::query(concat!( + album_select!(), + " AND (? IS NULL OR al.library_id IN (SELECT value FROM json_each(?))) \ + ORDER BY al.title COLLATE NOCASE, al.id LIMIT ? OFFSET ?" + )) + .bind(user_id.to_string()) + .bind(folders.as_deref()) + .bind(folders.as_deref()) + .bind(albums.limit) + .bind(albums.offset) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(album_from_row) + .collect::, _>>()?; + attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; + let mut songs = sqlx::query(concat!( + song_select!(), + song_folder_clause!(), + " ORDER BY t.title COLLATE NOCASE, t.id LIMIT ? OFFSET ?" + )) + .bind(user_id.to_string()) + .bind(folders.as_deref()) + .bind(folders.as_deref()) + .bind(songs.limit) + .bind(songs.offset) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(song_from_row) + .collect::, _>>()?; + attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; + Ok(CatalogSearch { + artists, + albums, + songs, + }) + } + + /// Full-text search across the user's visible catalogue. Tracks are matched + /// through the FTS5 index built in M1, which folds case and diacritics, so + /// "echo" finds "Écho". Albums and artists are derived from the same index + /// rather than a second scan, keeping one source of truth for relevance. + /// + /// Each kind is paged independently, as `search3` has always allowed: + /// a client that has read every matching song should be able to ask for + /// the next page of songs without re-reading the artists beside them. + pub async fn search( + &self, + user_id: Uuid, + query: &str, + artists: BrowsePage, + albums: BrowsePage, + songs: BrowsePage, + ) -> Result { + // Prefix on the trailing term, like the Subsonic surface: a client + // querying on each keystroke would otherwise get nothing until the word + // is complete — "ech" returned zero results while "echo" returned the + // album. Native clients type incrementally just as Subsonic ones do. + let Some(fts) = crate::catalog::fts_prefix_query(query) else { + return Ok(SearchResult { + artists: Vec::new(), + albums: Vec::new(), + songs: Vec::new(), + }); + }; + let mut songs = sqlx::query(concat!( + song_select!(), + " AND t.id IN (SELECT track_id FROM track_fts WHERE track_fts MATCH ?) \ + ORDER BY t.title COLLATE NOCASE, t.id LIMIT ? OFFSET ?" + )) + .bind(user_id.to_string()) + .bind(&fts) + .bind(songs.limit) + .bind(songs.offset) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(song_from_row) + .collect::, _>>()?; + attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; + let mut albums = sqlx::query(concat!( + album_select!(), + " AND al.id IN (SELECT t.album_id FROM track t \ + WHERE t.album_id IS NOT NULL \ + AND t.id IN (SELECT track_id FROM track_fts WHERE track_fts MATCH ?)) \ + ORDER BY al.title COLLATE NOCASE, al.id LIMIT ? OFFSET ?" + )) + .bind(user_id.to_string()) + .bind(&fts) + .bind(albums.limit) + .bind(albums.offset) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(album_from_row) + .collect::, _>>()?; + attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; + let artists = sqlx::query(concat!( + artist_select!(), + " AND ar.id IN (SELECT artist_id FROM artist_fts WHERE artist_fts MATCH ?) \ + ORDER BY ar.name COLLATE NOCASE, ar.id LIMIT ? OFFSET ?" + )) + .bind(user_id.to_string()) + .bind(&fts) + .bind(artists.limit) + .bind(artists.offset) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(artist_from_row) + .collect::, _>>()?; + Ok(SearchResult { + artists, + albums, + songs, + }) + } +} diff --git a/src/services/shares.rs b/src/services/shares.rs new file mode 100644 index 0000000..f06d6f1 --- /dev/null +++ b/src/services/shares.rs @@ -0,0 +1,383 @@ +//! Public shares and their visits. +//! +//! Split out of `services.rs`; see [`super`] for the shared projections. + +use super::*; + +impl DomainServices { + pub async fn shares(&self, user_id: Uuid) -> Result, ServiceError> { + let mut connection = self.db.pool().acquire().await?; + self.shares_on(&mut connection, user_id).await + } + + pub(super) async fn shares_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ) -> Result, ServiceError> { + let rows = sqlx::query("SELECT id, description, expires_at, created_at, visit_count FROM share WHERE owner_user_id=? ORDER BY created_at DESC") + .bind(user_id.to_string()).fetch_all(&mut *connection).await?; + let track_rows = sqlx::query( + "SELECT st.share_id, st.track_id FROM share_track st \ + JOIN share s ON s.id=st.share_id WHERE s.owner_user_id=? \ + ORDER BY st.share_id, st.position", + ) + .bind(user_id.to_string()) + .fetch_all(&mut *connection) + .await?; + let mut track_owners = Vec::with_capacity(track_rows.len()); + let mut track_ids = Vec::with_capacity(track_rows.len()); + for track_row in track_rows { + track_owners.push(parse_uuid(track_row.try_get("share_id")?)?); + track_ids.push(parse_uuid(track_row.try_get("track_id")?)?); + } + let songs = self + .songs_by_ids_lenient_on(connection, user_id, &track_ids) + .await? + .into_iter() + .map(|song| (song.id, song)) + .collect::>(); + let mut songs_by_share = HashMap::>::new(); + for (share_id, track_id) in track_owners.into_iter().zip(track_ids) { + if let Some(song) = songs.get(&track_id) { + songs_by_share + .entry(share_id) + .or_default() + .push(song.clone()); + } + } + + let mut shares = Vec::with_capacity(rows.len()); + for row in rows { + let id = parse_uuid(row.try_get("id")?)?; + shares.push(ShareItem { + id, + owner_id: user_id, + url_token: None, + description: row.try_get("description")?, + expires_at: row.try_get("expires_at")?, + created_at: row.try_get("created_at")?, + visit_count: row.try_get("visit_count")?, + songs: songs_by_share.remove(&id).unwrap_or_default(), + }); + } + Ok(shares) + } + + pub async fn create_share( + &self, + user_id: Uuid, + ids: &[Uuid], + description: Option<&str>, + expires_at: Option, + ) -> Result { + self.create_share_with_context( + user_id, + ids, + description, + expires_at, + MutationContext::server_generated(), + ) + .await + } + + pub async fn create_share_with_context( + &self, + user_id: Uuid, + ids: &[Uuid], + description: Option<&str>, + expires_at: Option, + context: MutationContext, + ) -> Result { + let intent = MutationIntent::new( + "create", + "share", + &serde_json::json!({ + "track_ids": ids, + "description": description, + "expires_at": expires_at, + }), + ); + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "share")?; + let id = receipt.result_entity_id.ok_or(ServiceError::Conflict)?; + drop(_writer); + let mut share = self + .shares(user_id) + .await? + .into_iter() + .find(|share| share.id == id) + .ok_or(ServiceError::NotFound)?; + share.url_token = Some(self.secret_box.derive_share_token(id)); + return Ok(share); + } + if ids.is_empty() || ids.len() > MAX_SHARE_TRACKS { + return Err(ServiceError::Invalid); + } + let songs = self.songs_by_ids_on(&mut tx, user_id, ids).await?; + let id = Uuid::new_v4(); + let token = self.secret_box.derive_share_token(id); + let token_hash = security::token_hash(&token); + let now = now_ms(); + sqlx::query("INSERT INTO share (id, owner_user_id, token_hash, description, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)") + .bind(id.to_string()).bind(user_id.to_string()).bind(token_hash.as_slice()).bind(description).bind(expires_at).bind(now).bind(now).execute(&mut *tx).await?; + for (position, track) in ids.iter().enumerate() { + sqlx::query("INSERT INTO share_track (share_id, track_id, position) VALUES (?, ?, ?)") + .bind(id.to_string()) + .bind(track.to_string()) + .bind(position as i64) + .execute(&mut *tx) + .await?; + } + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "share", + id, + "upsert", + &serde_json::json!({ + "id": id, + "track_ids": ids, + "description": description, + "expires_at": expires_at, + }), + Some(id), + ) + .await?; + tx.commit().await?; + drop(_writer); + self.sync.publish(user_id, receipt); + Ok(ShareItem { + id, + owner_id: user_id, + url_token: Some(token), + description: description.map(str::to_owned), + expires_at, + created_at: now, + visit_count: 0, + songs, + }) + } + + pub async fn public_share(&self, token: &str) -> Result { + let hash = security::token_hash(token); + let row = sqlx::query("SELECT id, owner_user_id, description, expires_at, created_at, visit_count FROM share WHERE token_hash=? AND (expires_at IS NULL OR expires_at>?)") + .bind(hash.as_slice()).bind(now_ms()).fetch_optional(self.db.pool()).await?.ok_or(ServiceError::NotFound)?; + let id = parse_uuid(row.try_get("id")?)?; + let owner = parse_uuid(row.try_get("owner_user_id")?)?; + let ids = sqlx::query_scalar::<_, String>( + "SELECT track_id FROM share_track WHERE share_id=? ORDER BY position", + ) + .bind(id.to_string()) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(parse_uuid) + .collect::, _>>()?; + // The rows above were read outside the writer gate, and acquiring it can + // block behind a scan. Re-check both revocation and expiry at write + // time: no affected row means the share died during that wait, and a + // visitor must not see what an owner deleted or let expire. + let _writer = self.db.writer_guard().await; + let visited_at = now_ms(); + let visited = sqlx::query( + "UPDATE share SET visit_count=visit_count+1, last_visited_at=? \ + WHERE id=? AND (expires_at IS NULL OR expires_at>?)", + ) + .bind(visited_at) + .bind(id.to_string()) + .bind(visited_at) + .execute(self.db.pool()) + .await? + .rows_affected(); + drop(_writer); + if visited == 0 { + return Err(ServiceError::NotFound); + } + Ok(ShareItem { + id, + owner_id: owner, + url_token: None, + description: row.try_get("description")?, + expires_at: row.try_get("expires_at")?, + created_at: row.try_get("created_at")?, + visit_count: row.try_get::("visit_count")? + 1, + // Lenient like `shares_on`, and for a stronger reason: the visit is + // already counted above. A track gone unavailable since the share + // was created would otherwise answer 404 to the visitor while the + // owner still sees the share, and the counter would climb anyway. + songs: self + .songs_by_ids_lenient_on(&mut *self.db.pool().acquire().await?, owner, &ids) + .await?, + }) + } + + pub async fn update_share( + &self, + user_id: Uuid, + id: Uuid, + description: Option<&str>, + expires_at: Option, + clear: ShareClear, + ) -> Result { + self.update_share_with_context( + user_id, + id, + description, + expires_at, + clear, + MutationContext::server_generated(), + ) + .await + } + + pub async fn update_share_with_context( + &self, + user_id: Uuid, + id: Uuid, + description: Option<&str>, + expires_at: Option, + clear: ShareClear, + context: MutationContext, + ) -> Result { + // Clearing must be part of the intent: "set expiry to X" and "remove the + // expiry" are different mutations, and an operation id replayed across + // both has to be rejected rather than silently treated as the same. + let intent = MutationIntent::new( + "update", + &format!("share:{id}"), + &serde_json::json!({ + "description": description, + "expires_at": expires_at, + "clear_description": clear.description, + "clear_expires_at": clear.expires_at, + }), + ); + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "share")?; + drop(_writer); + return self + .shares(user_id) + .await? + .into_iter() + .find(|share| share.id == id) + .ok_or(ServiceError::NotFound); + } + let persisted = sqlx::query( + "UPDATE share SET \ + description=CASE WHEN ? THEN NULL ELSE COALESCE(?, description) END, \ + expires_at=CASE WHEN ? THEN NULL ELSE COALESCE(?, expires_at) END, \ + updated_at=? \ + WHERE id=? AND owner_user_id=? RETURNING description, expires_at", + ) + .bind(clear.description) + .bind(description) + .bind(clear.expires_at) + .bind(expires_at) + .bind(now_ms()) + .bind(id.to_string()) + .bind(user_id.to_string()) + .fetch_optional(&mut *tx) + .await?; + let Some(persisted) = persisted else { + tx.rollback().await?; + return Err(ServiceError::NotFound); + }; + let persisted_description: Option = persisted.try_get("description")?; + let persisted_expires_at: Option = persisted.try_get("expires_at")?; + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "share", + id, + "upsert", + &serde_json::json!({ + "id": id, + "description": persisted_description, + "expires_at": persisted_expires_at, + }), + Some(id), + ) + .await?; + tx.commit().await?; + drop(_writer); + self.sync.publish(user_id, receipt); + self.shares(user_id) + .await? + .into_iter() + .find(|share| share.id == id) + .ok_or(ServiceError::NotFound) + } + + pub async fn delete_share(&self, user_id: Uuid, id: Uuid) -> Result<(), ServiceError> { + self.delete_share_with_context(user_id, id, MutationContext::server_generated()) + .await + } + + pub async fn delete_share_with_context( + &self, + user_id: Uuid, + id: Uuid, + context: MutationContext, + ) -> Result<(), ServiceError> { + let intent = MutationIntent::new("delete", &format!("share:{id}"), &serde_json::json!({})); + let _writer = self.db.writer_guard().await; + let mut tx = self.db.pool().begin().await?; + if let OperationClaim::Replayed(receipt) = self + .sync + .claim_operation(&_writer, &mut tx, user_id, context, intent) + .await? + { + tx.rollback().await?; + validate_replay_type(&receipt, "share")?; + return Ok(()); + } + let changed = sqlx::query("DELETE FROM share WHERE id=? AND owner_user_id=?") + .bind(id.to_string()) + .bind(user_id.to_string()) + .execute(&mut *tx) + .await? + .rows_affected(); + if changed == 0 { + tx.rollback().await?; + Err(ServiceError::NotFound) + } else { + let receipt = self + .sync + .complete_operation( + &mut tx, + user_id, + context, + "share", + id, + "delete", + &serde_json::json!({}), + Some(id), + ) + .await?; + tx.commit().await?; + self.sync.publish(user_id, receipt); + Ok(()) + } + } +} diff --git a/src/services/songs.rs b/src/services/songs.rs new file mode 100644 index 0000000..27a1858 --- /dev/null +++ b/src/services/songs.rs @@ -0,0 +1,368 @@ +//! Song listings, genres, starred sets and lyrics. +//! +//! Split out of `services.rs`; see [`super`] for the shared projections. + +use super::*; + +impl DomainServices { + /// Genres visible to the user, with the size of what each holds. + /// + /// Grouping is by `genre.canonical_name`, so one genre spelled differently + /// across two libraries — or differing only in case — is a single row. The + /// facade previously grouped the raw `genre_display` fragments, which + /// listed "Rock" and "rock" as two genres with split counts. + pub async fn list_genres( + &self, + user_id: Uuid, + library_ids: &[Uuid], + ) -> Result, ServiceError> { + let folders = (!library_ids.is_empty()).then(|| { + serde_json::to_string(library_ids).expect("UUID list serialization cannot fail") + }); + Ok(sqlx::query( + "SELECT MIN(g.name) AS name, COUNT(DISTINCT t.id) AS song_count, \ + COUNT(DISTINCT t.album_id) AS album_count \ + FROM genre g JOIN library_member m ON m.library_id=g.library_id \ + JOIN track_genre tg ON tg.genre_id=g.id \ + JOIN track t ON t.id=tg.track_id AND t.is_available=1 \ + WHERE m.user_id=? AND (? IS NULL OR g.library_id IN (SELECT value FROM json_each(?))) \ + GROUP BY g.canonical_name ORDER BY name COLLATE NOCASE", + ) + .bind(user_id.to_string()) + .bind(folders.as_deref()) + .bind(folders.as_deref()) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(|row| { + Ok(GenreItem { + name: row.try_get("name")?, + song_count: row.try_get("song_count")?, + album_count: row.try_get("album_count")?, + }) + }) + .collect::, sqlx::Error>>()?) + } + + /// Songs of one genre, ordered and paged in SQL. + /// + /// Matching is on `genre.canonical_name`, the same key `list_genres` groups + /// by and `byGenre` filters on. It was `eq_ignore_ascii_case` against the + /// joined display string, which folds case but not punctuation or spacing: + /// `getGenres` answered one row for "Hip-Hop" and "Hip Hop", and asking for + /// that row returned only the tracks spelled the way the caller happened to + /// send. A client showed a genre it had just been given, and it was empty. + pub async fn songs_by_genre( + &self, + user_id: Uuid, + library_ids: &[Uuid], + genre: &str, + page: BrowsePage, + ) -> Result, ServiceError> { + let folders = folder_filter(library_ids); + let canonical = waveflow_core::scanner::canonical_name(genre); + let mut songs = sqlx::query(concat!( + song_select!(), + song_folder_clause!(), + song_genre_clause!(), + " ORDER BY t.title COLLATE NOCASE, t.id LIMIT ? OFFSET ?" + )) + .bind(user_id.to_string()) + .bind(folders.as_deref()) + .bind(folders.as_deref()) + .bind(&canonical) + .bind(page.limit) + .bind(page.offset) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(song_from_row) + .collect::, _>>()?; + attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; + Ok(songs) + } + + /// The available tracks of one library that belong to no album. + /// + /// A track without an album has no album id to be the `parent` of its + /// Subsonic `child`, so it names its library instead. That was a + /// dead end until now: browsing to that identifier listed the library's + /// artists and nothing else, so a track reachable by search was reachable + /// by no amount of browsing. Answering here is what makes the `parent` + /// it already advertised true. + /// + /// `getMusicDirectory` has no offset to page a folder with, so the caller + /// asks for a ceiling instead of a page: everything up to `limit`, in one + /// answer. A library that holds more album-less tracks than that would + /// build an unbounded response out of a request that cannot say how much + /// it wants, so the ceiling is what keeps the answer finite — and the + /// caller says so in the log rather than truncating in silence. The artist + /// list this tail follows is still bounded only by the library. + pub async fn songs_without_album( + &self, + user_id: Uuid, + library_id: Uuid, + limit: i64, + ) -> Result, ServiceError> { + if limit <= 0 { + return Err(ServiceError::Invalid); + } + let mut songs = sqlx::query(concat!( + song_select!(), + " AND t.library_id=? AND t.album_id IS NULL \ + ORDER BY t.title COLLATE NOCASE, t.id LIMIT ?" + )) + .bind(user_id.to_string()) + .bind(library_id.to_string()) + .bind(limit) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(song_from_row) + .collect::, _>>()?; + attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; + Ok(songs) + } + + /// A random selection, drawn in SQL rather than by shuffling the catalogue. + /// + /// The facade used to read every visible track, filter in Rust and shuffle + /// the result to answer with ten. `ORDER BY RANDOM() LIMIT` asks SQLite for + /// the same thing without materialising the rest, and the genre filter + /// matches the canonical name like every other genre predicate. + /// + /// A reversed year range is how Subsonic asks for one, so the bounds are + /// normalised rather than rejected. + pub async fn random_songs( + &self, + user_id: Uuid, + library_ids: &[Uuid], + genre: Option<&str>, + from_year: Option, + to_year: Option, + limit: i64, + ) -> Result, ServiceError> { + if limit <= 0 || limit > MAX_BROWSE_LIMIT { + return Err(ServiceError::Invalid); + } + let folders = folder_filter(library_ids); + let canonical = genre.map(waveflow_core::scanner::canonical_name); + let from = from_year.unwrap_or(i64::MIN); + let to = to_year.unwrap_or(i64::MAX); + let bounded = from_year.is_some() || to_year.is_some(); + let sql = match canonical.is_some() { + true => concat!( + song_select!(), + song_folder_clause!(), + song_genre_clause!(), + song_year_clause!(), + " ORDER BY RANDOM() LIMIT ?" + ), + false => concat!( + song_select!(), + song_folder_clause!(), + song_year_clause!(), + " ORDER BY RANDOM() LIMIT ?" + ), + }; + let mut statement = sqlx::query(sql) + .bind(user_id.to_string()) + .bind(folders.as_deref()) + .bind(folders.as_deref()); + if let Some(canonical) = canonical.as_deref() { + statement = statement.bind(canonical); + } + let mut songs = statement + .bind(bounded) + .bind(from.min(to)) + .bind(from.max(to)) + .bind(limit) + .fetch_all(self.db.pool()) + .await? + .into_iter() + .map(song_from_row) + .collect::, _>>()?; + attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; + Ok(songs) + } + + /// Everything the account has starred, most recent first. + /// + /// The three projections already `LEFT JOIN user_star`, so this is the same + /// read with the join made mandatory. The facade used to load the whole + /// catalogue and look each starred id up inside it, which cost a full + /// catalogue read to answer a list that is usually short. + pub async fn starred( + &self, + user_id: Uuid, + library_ids: &[Uuid], + ) -> Result { + let folders = folder_filter(library_ids); + // One connection for the three projections and both relation + // batches: five acquisitions from the pool answered the same + // question, and spreading them risked five different snapshots. + let mut connection = self.db.pool().acquire().await?; + let artists = sqlx::query(concat!( + artist_select!(album_count), + " AND us.starred_at IS NOT NULL \ + AND (? IS NULL OR ar.library_id IN (SELECT value FROM json_each(?))) \ + ORDER BY us.starred_at DESC, ar.id" + )) + .bind(user_id.to_string()) + .bind(folders.as_deref()) + .bind(folders.as_deref()) + .fetch_all(&mut *connection) + .await? + .into_iter() + .map(artist_summary_from_row) + .collect::, _>>()?; + let mut albums = sqlx::query(concat!( + album_select!(), + " AND us.starred_at IS NOT NULL \ + AND (? IS NULL OR al.library_id IN (SELECT value FROM json_each(?))) \ + ORDER BY us.starred_at DESC, al.id" + )) + .bind(user_id.to_string()) + .bind(folders.as_deref()) + .bind(folders.as_deref()) + .fetch_all(&mut *connection) + .await? + .into_iter() + .map(album_from_row) + .collect::, _>>()?; + attach_album_relations(&mut connection, user_id, &mut albums).await?; + let mut songs = sqlx::query(concat!( + song_select!(), + " AND us.starred_at IS NOT NULL", + song_folder_clause!(), + " ORDER BY us.starred_at DESC, t.id" + )) + .bind(user_id.to_string()) + .bind(folders.as_deref()) + .bind(folders.as_deref()) + .fetch_all(&mut *connection) + .await? + .into_iter() + .map(song_from_row) + .collect::, _>>()?; + attach_song_relations(&mut connection, user_id, &mut songs).await?; + Ok(StarredCatalog { + artists, + albums, + songs, + }) + } + + pub async fn songs_by_ids( + &self, + user_id: Uuid, + ids: &[Uuid], + ) -> Result, ServiceError> { + let mut connection = self.db.pool().acquire().await?; + self.songs_by_ids_on(&mut connection, user_id, ids).await + } + + /// Lyrics for one visible, available track. A visible track with no lyrics + /// returns an empty list; an unknown or foreign track is blurred as not + /// found, matching the rest of the catalogue API. + pub async fn lyrics(&self, user_id: Uuid, track_id: Uuid) -> Result { + let rows = sqlx::query( + "SELECT t.id, t.title, t.artist_display, tl.lang, tl.synced, tl.content \ + FROM track t JOIN library_member m ON m.library_id=t.library_id \ + LEFT JOIN track_lyrics tl ON tl.track_id=t.id AND tl.library_id=t.library_id \ + WHERE m.user_id=? AND t.id=? AND t.is_available=1 \ + ORDER BY tl.position", + ) + .bind(user_id.to_string()) + .bind(track_id.to_string()) + .fetch_all(self.db.pool()) + .await?; + lyrics_list_from_rows(track_id, rows) + } + + /// Legacy Subsonic lookup by metadata. Matching stays tenant-scoped and + /// deterministic; it is intentionally exact because fuzzy catalogue + /// reconciliation is outside the v2 contract. + pub async fn lyrics_by_metadata( + &self, + user_id: Uuid, + artist: Option<&str>, + title: Option<&str>, + ) -> Result, ServiceError> { + let row = sqlx::query_scalar::<_, String>( + "SELECT t.id FROM track t \ + JOIN library_member m ON m.library_id=t.library_id \ + WHERE m.user_id=? AND t.is_available=1 \ + AND (? IS NULL OR t.artist_display = ? COLLATE NOCASE) \ + AND (? IS NULL OR t.title = ? COLLATE NOCASE) \ + AND EXISTS (SELECT 1 FROM track_lyrics tl WHERE tl.track_id=t.id) \ + ORDER BY t.title COLLATE NOCASE, t.id LIMIT 1", + ) + .bind(user_id.to_string()) + .bind(artist) + .bind(artist) + .bind(title) + .bind(title) + .fetch_optional(self.db.pool()) + .await?; + let track_id = row + .map(|id| { + Uuid::parse_str(&id) + .map_err(|error| ServiceError::Database(sqlx::Error::Decode(error.into()))) + }) + .transpose()?; + match track_id { + Some(track_id) => self.lyrics(user_id, track_id).await.map(Some), + None => Ok(None), + } + } + + pub(super) async fn songs_by_ids_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ids: &[Uuid], + ) -> Result, ServiceError> { + let songs = self + .songs_by_ids_lenient_on(connection, user_id, ids) + .await?; + if songs.len() == ids.len() { + Ok(songs) + } else { + Err(ServiceError::NotFound) + } + } + + pub(super) async fn songs_by_ids_lenient_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ids: &[Uuid], + ) -> Result, ServiceError> { + if ids.is_empty() { + return Ok(Vec::new()); + } + let ids_json = serde_json::to_string(ids).map_err(|_| ServiceError::Invalid)?; + let rows = sqlx::query(concat!( + song_select!(), + " AND t.id IN (SELECT value FROM json_each(?))" + )) + .bind(user_id.to_string()) + .bind(ids_json) + .fetch_all(&mut *connection) + .await?; + let mut resolved = rows + .into_iter() + .map(song_from_row) + .collect::, _>>()?; + attach_song_relations(connection, user_id, &mut resolved).await?; + let available = resolved + .into_iter() + .map(|song| (song.id, song)) + .collect::>(); + Ok(ids + .iter() + .filter_map(|id| available.get(id).cloned()) + .collect()) + } +} diff --git a/src/services/sync.rs b/src/services/sync.rs new file mode 100644 index 0000000..739540f --- /dev/null +++ b/src/services/sync.rs @@ -0,0 +1,45 @@ +//! The snapshot a client rebuilds its state from. +//! +//! Split out of `services.rs`; see [`super`] for the shared projections. + +use super::*; + +impl DomainServices { + pub async fn sync_snapshot( + &self, + user_id: Uuid, + history_limit: i64, + ) -> Result { + let mut tx = self.db.pool().begin().await?; + // A global watermark, read inside the same transaction as the rows + // below so nothing committed after it can be missed. + // + // Deliberately not this user's MAX: `changes` refuses cursors below the + // journal's global floor, so a per-user watermark would hand an account + // with no surviving events a cursor beneath that floor — it would + // re-snapshot, get the same cursor, be refused again, and loop. Filtering + // by user still happens in `changes`, so a global watermark only means + // "everything up to here is already in this snapshot". + let cursor = sqlx::query_scalar("SELECT COALESCE(MAX(cursor), 0) FROM sync_event") + .fetch_one(&mut *tx) + .await?; + let playlists = self.playlists_on(&mut tx, user_id).await?; + let favorites = self.starred_ids_on(&mut tx, user_id).await?; + let ratings = self.ratings_on(&mut tx, user_id).await?; + let queue = self.queue_on(&mut tx, user_id).await?; + let history = self.history_on(&mut tx, user_id, history_limit).await?; + let shares = self.shares_on(&mut tx, user_id).await?; + let bookmarks = self.bookmarks_on(&mut tx, user_id).await?; + tx.commit().await?; + Ok(SyncSnapshotData { + cursor, + playlists, + favorites, + ratings, + queue, + history, + shares, + bookmarks, + }) + } +} diff --git a/src/subsonic.rs b/src/subsonic.rs deleted file mode 100644 index 741b56c..0000000 --- a/src/subsonic.rs +++ /dev/null @@ -1,2434 +0,0 @@ -//! Subsonic/OpenSubsonic compatibility façade. - -use std::{ - collections::{BTreeMap, HashMap, VecDeque}, - sync::{Mutex as StdMutex, OnceLock}, - time::{Duration, Instant}, -}; - -use axum::{ - body::to_bytes, - extract::{Path, Query, Request, State}, - http::{header, HeaderMap, Method, StatusCode}, - response::{IntoResponse, Response}, - routing::get, - Router, -}; -use md5::{Digest, Md5}; -use serde_json::{Map, Value}; -use uuid::Uuid; - -use crate::{ - database::AccountRole, - media::{MediaError, OutputFormat, StreamQuery}, - security, - services::{ - AlbumItem, AlbumListQuery, AlbumOrder, ArtistItem, ArtistSummary, BrowsePage, PlaylistItem, - ServiceError, SongItem, - }, - AppState, -}; - -const SUBSONIC_VERSION: &str = "1.16.1"; -const XMLNS: &str = "http://subsonic.org/restapi"; -const MAX_FORM_BYTES: usize = 64 * 1024; -const AUTH_ATTEMPTS_PER_MINUTE: usize = 20; -const MAX_AUTH_RATE_KEYS: usize = 10_000; -/// How many album-less tracks a folder listing will carry. -/// -/// `getMusicDirectory` takes no offset, so a folder cannot be paged and the -/// only bound available is a ceiling. It sits far above `MAX_BROWSE_LIMIT` -/// because reaching it costs a client the tracks beyond it — the browse limit -/// governs a listing the client can ask more of, this one governs a listing it -/// cannot. A folder that reaches it is logged. -const MAX_DIRECTORY_SONGS: i64 = 2_000; - -static AUTH_WINDOWS: OnceLock>>> = OnceLock::new(); - -#[derive(Debug, Clone)] -struct Principal { - id: Uuid, - username: String, - role: AccountRole, -} - -#[derive(Debug, Default)] -struct Params(Vec<(String, String)>); - -#[derive(Debug, Clone)] -struct Node { - name: String, - attrs: BTreeMap, - children: Vec, - text: Option, -} - -impl Node { - fn new(name: impl Into) -> Self { - Self { - name: name.into(), - attrs: BTreeMap::new(), - children: Vec::new(), - text: None, - } - } - - fn attr(mut self, key: impl Into, value: impl Into) -> Self { - self.attrs.insert(key.into(), value.into()); - self - } - - fn maybe_attr(mut self, key: &str, value: Option>) -> Self { - if let Some(value) = value { - self.attrs.insert(key.to_owned(), value.into()); - } - self - } - - fn child(mut self, child: Node) -> Self { - self.children.push(child); - self - } - - fn text(mut self, text: impl Into) -> Self { - self.text = Some(text.into()); - self - } - - fn renamed(mut self, name: &'static str) -> Self { - self.name = name.to_owned(); - self - } - - fn without(mut self, key: &str) -> Self { - self.attrs.remove(key); - self - } - - fn children(mut self, children: impl IntoIterator) -> Self { - self.children.extend(children); - self - } -} - -/// A Subsonic protocol failure. -/// -/// The transport status is deliberately not carried here. OpenSubsonic answers -/// every request it could parse with HTTP 200 and reports the failure in the -/// body, so a client reading `error/code` sees the same outcome whatever the -/// transport did. Answering 401 or 404 instead let proxies and HTTP-level -/// client error handling discard the body before the Subsonic layer read it. -#[derive(Debug)] -struct ProtocolError { - code: i64, - message: &'static str, -} - -pub fn router(state: AppState) -> Router { - Router::new() - .route("/rest/{method}", get(handle).post(handle)) - .route( - "/share/{token}/tracks/{track_id}/stream", - get(public_share_stream), - ) - .route("/share/{token}", get(public_share)) - .with_state(state) -} - -async fn public_share(State(state): State, Path(token): Path) -> Response { - match state.services.public_share(&token).await { - Ok(share) => { - let tracks = share - .songs - .iter() - .map(|song| { - let mut value = serde_json::to_value(song).expect("song serialization"); - if let Value::Object(object) = &mut value { - object.insert( - "streamUrl".into(), - Value::String(external_url( - state.public_url.as_deref(), - &format!("/share/{token}/tracks/{}/stream", song.id), - )), - ); - } - value - }) - .collect::>(); - ( - [(header::CACHE_CONTROL, "no-store")], - axum::Json(serde_json::json!({ - "id": share.id, - "description": share.description, - "expiresAt": share.expires_at, - "visitCount": share.visit_count, - "tracks": tracks, - })), - ) - .into_response() - } - Err(ServiceError::NotFound) => StatusCode::NOT_FOUND.into_response(), - Err(error) => { - tracing::error!(error = %error, "public share lookup failed"); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } -} - -async fn public_share_stream( - State(state): State, - Path((token, track_id)): Path<(String, Uuid)>, - Query(query): Query, - headers: HeaderMap, -) -> Response { - let share = match state.services.public_share(&token).await { - Ok(share) => share, - Err(ServiceError::NotFound) => return StatusCode::NOT_FOUND.into_response(), - Err(error) => { - tracing::error!(error = %error, "public share stream lookup failed"); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - if !share.songs.iter().any(|song| song.id == track_id) { - return StatusCode::NOT_FOUND.into_response(); - } - let track = match state - .db - .stream_track_for_user(share.owner_id, track_id) - .await - { - Ok(Some(track)) => track, - Ok(None) => return StatusCode::NOT_FOUND.into_response(), - Err(error) => { - tracing::error!(error = %error, "public share media lookup failed"); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - let range = headers - .get(header::RANGE) - .and_then(|value| value.to_str().ok()); - match state.media.serve(share.owner_id, track, query, range).await { - Ok(response) => response, - Err(error) => error.into_response(), - } -} - -pub async fn handle( - State(state): State, - Path(raw_method): Path, - request: Request, -) -> Response { - let request_method = request.method().clone(); - let query = request.uri().query().unwrap_or_default().to_owned(); - let range = request - .headers() - .get(header::RANGE) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - - // Format negotiation has to survive a failure, and under the `formPost` - // extension `f=json` arrives in the body rather than the query string. - // Collecting the parameters here, outside the fallible path, is what lets a - // POST that fails to authenticate still answer in the format it asked for - // instead of falling back to XML. - let mut wants_json = false; - let params = match parse_pairs(&query) { - Ok(mut params) => { - wants_json = json_requested(¶ms); - if request_method == Method::POST { - match form_params(request).await { - Ok(body) => { - params.0.extend(body.0); - wants_json = json_requested(¶ms); - Ok(params) - } - Err(error) => Err(error), - } - } else { - Ok(params) - } - } - Err(error) => Err(error), - }; - - let outcome = match params { - Ok(params) => { - handle_inner( - &state, - &raw_method, - &request_method, - ¶ms, - range.as_deref(), - ) - .await - } - Err(error) => Err(error), - }; - match outcome { - Ok(response) => response, - // A protocol failure is still an HTTP success: the Subsonic contract - // puts the outcome in the body, never in the status line. - Err(error) => render_protocol(error_node(error.code, error.message), wants_json), - } -} - -/// Parameters carried in a POST body, as the `formPost` extension allows in -/// place of a query string too long for a URL. -async fn form_params(request: Request) -> Result { - // A media type is case-insensitive and may carry parameters, so the type is - // compared on its own rather than as a prefix of the raw header value: - // `Application/X-WWW-Form-Urlencoded; charset=UTF-8` is a conformant way to - // say the same thing, and `application/x-www-form-urlencodedish` is not. - let media_type = request - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .split(';') - .next() - .unwrap_or_default() - .trim(); - if !media_type.eq_ignore_ascii_case("application/x-www-form-urlencoded") { - return Err(invalid("POST requires application/x-www-form-urlencoded")); - } - let body = to_bytes(request.into_body(), MAX_FORM_BYTES) - .await - .map_err(|_| invalid("Invalid form body"))?; - parse_pairs(std::str::from_utf8(&body).map_err(|_| invalid("Invalid form body"))?) -} - -fn json_requested(params: &Params) -> bool { - params.first("f").is_some_and(|value| value == "json") -} - -async fn handle_inner( - state: &AppState, - raw_method: &str, - request_method: &Method, - params: &Params, - range: Option<&str>, -) -> Result { - let method = raw_method.strip_suffix(".view").unwrap_or(raw_method); - let wants_json = json_requested(params); - if is_symfonium_discovery_probe(method, request_method, params) { - return Ok(render_protocol(ok_node(), wants_json)); - } - let principal = authenticate(state, params).await?; - - if matches!(method, "stream" | "download") { - return media_response(state, &principal, params, method == "download", range).await; - } - if method == "getCoverArt" { - return cover_art_response(state, &principal, params).await; - } - - let payload = dispatch(state, &principal, method, params).await?; - let root = if method == "ping" || empty_success_method(method) { - ok_node() - } else { - ok_node().child(payload) - }; - Ok(render_protocol(root, wants_json)) -} - -fn is_symfonium_discovery_probe(method: &str, request_method: &Method, params: &Params) -> bool { - method == "ping" - && request_method == Method::GET - && params.all("c") == ["Symfonium"] - && params.all("u") == ["test"] - && params.all("p") == ["test"] - && params.all("apiKey").is_empty() - && params.all("t").is_empty() - && params.all("s").is_empty() -} - -async fn dispatch( - state: &AppState, - principal: &Principal, - method: &str, - params: &Params, -) -> Result { - match method { - "ping" => Ok(Node::new("ping")), - "getLicense" => Ok(Node::new("license") - .attr("valid", true) - .attr("email", "") - .attr("licenseExpires", "2099-12-31T23:59:59Z")), - "getOpenSubsonicExtensions" => Ok(open_subsonic_extensions()), - // The other half of the apiKeyAuthentication extension: a client holding - // a key has no other way to learn which account it speaks for. - // Advertising the extension without serving this told clients a lie. - "tokenInfo" => Ok(Node::new("tokenInfo").attr("username", principal.username.clone())), - // Playback positions, one per account and track. Symfonium asks for - // them during its initial sync, and they are now read from and written - // to the catalogue rather than answered with an empty container. - "getBookmarks" => bookmarks(state, principal).await, - "createBookmark" => create_bookmark(state, principal, params).await, - "deleteBookmark" => delete_bookmark(state, principal, params).await, - // Recommendation and radio surfaces WaveFlow does not compute. The - // standard empty container is the honest answer and, unlike the - // not-implemented error, does not read to a client as a broken - // server on a page it opens by default. - "getTopSongs" => Ok(Node::new("topSongs")), - "getSimilarSongs" => Ok(Node::new("similarSongs")), - "getSimilarSongs2" => Ok(Node::new("similarSongs2")), - "getInternetRadioStations" => Ok(Node::new("internetRadioStations")), - // No avatars are stored, so the account genuinely has none. Code 70 - // says that; code 0 would blame the method instead of the data. - "getAvatar" => Err(not_found()), - "startScan" => start_scan(state, principal).await, - "getScanStatus" => scan_status(state, principal).await, - "getMusicFolders" => { - let folders = state - .services - .music_folders(principal.id, &[]) - .await - .map_err(internal)?; - Ok( - Node::new("musicFolders").children(folders.into_iter().map(|folder| { - Node::new("musicFolder") - .attr("id", folder.id.to_string()) - .attr("name", folder.name) - })), - ) - } - "getIndexes" => indexes(state, principal, params, false).await, - "getArtists" => indexes(state, principal, params, true).await, - "getArtist" => get_artist(state, principal, params).await, - // DSub requests artist information as soon as an artist page opens. - // WaveFlow does not enrich biographies yet, but a successful empty - // standard container avoids turning an optional capability into a - // blocking client error. The artist is still resolved tenant-side. - "getArtistInfo" => artist_info(state, principal, params, "artistInfo").await, - "getArtistInfo2" => artist_info(state, principal, params, "artistInfo2").await, - // Feishin and Symfonium call these as soon as an album page opens. As - // with getArtistInfo, WaveFlow enriches nothing yet, so the standard - // empty container is the honest answer — and it still resolves the - // album tenant-side, so a foreign id is indistinguishable from a - // missing one. - "getAlbumInfo" => album_info(state, principal, params, "albumInfo").await, - "getAlbumInfo2" => album_info(state, principal, params, "albumInfo2").await, - "getAlbum" => get_album(state, principal, params).await, - "getSong" => get_song(state, principal, params).await, - "getLyrics" => get_lyrics(state, principal, params).await, - "getLyricsBySongId" => get_lyrics_by_song_id(state, principal, params).await, - "getGenres" => genres(state, principal, params).await, - "getMusicDirectory" => music_directory(state, principal, params).await, - "getAlbumList2" => album_list(state, principal, params).await, - // Older clients such as DSub still use the pre-ID3 endpoint. The - // payload is identical for our UUID catalogue; only the container - // name differs from getAlbumList2. - "getAlbumList" => album_list(state, principal, params) - .await - .map(|node| node.renamed("albumList")), - "getRandomSongs" => random_songs(state, principal, params).await, - "getSongsByGenre" => songs_by_genre(state, principal, params).await, - "search3" => search(state, principal, params).await, - "search2" => search(state, principal, params) - .await - .map(|node| node.renamed("searchResult2")), - "getPlaylists" => playlists(state, principal).await, - "getPlaylist" => get_playlist(state, principal, params).await, - "createPlaylist" => create_playlist(state, principal, params).await, - "updatePlaylist" => update_playlist(state, principal, params).await, - "deletePlaylist" => delete_playlist(state, principal, params).await, - "star" => set_star(state, principal, params, true).await, - "unstar" => set_star(state, principal, params, false).await, - "getStarred2" => starred(state, principal, params).await, - // Browse-by-folder clients such as DSub and Ultrasonic still call the - // pre-ID3 method. Same payload for a UUID catalogue; only the - // container differs, exactly as for getAlbumList. - "getStarred" => starred(state, principal, params) - .await - .map(|node| node.renamed("starred")), - "setRating" => set_rating(state, principal, params).await, - "scrobble" => scrobble(state, principal, params).await, - "getNowPlaying" => now_playing(state, principal).await, - "getPlayQueue" => get_queue(state, principal).await, - "savePlayQueue" => save_queue(state, principal, params).await, - "getShares" | "createShare" | "updateShare" | "deleteShare" => { - shares(state, principal, method, params).await - } - "getUser" | "getUsers" | "createUser" | "updateUser" | "deleteUser" | "changePassword" => { - admin(state, principal, method, params).await - } - _ => Err(ProtocolError { - code: 0, - message: "Requested method is not implemented", - }), - } -} - -async fn authenticate(state: &AppState, params: &Params) -> Result { - let rate_key = params - .first("apiKey") - .or_else(|| params.first("u")) - .unwrap_or("missing"); - let rate_hash = hex::encode(security::token_hash(rate_key)); - if auth_rate_limited(&rate_hash) { - return Err(ProtocolError { - code: 40, - message: "Wrong username or password", - }); - } - - let credential = if let Some(api_key) = params.first("apiKey") { - state - .services - .credential_by_api_key(api_key) - .await - .map_err(internal)? - .ok_or_else(|| { - record_auth_failure(&rate_hash); - auth_error() - })? - } else { - let username = params.first("u").ok_or_else(missing)?; - state - .services - .credential_by_username(username) - .await - .map_err(internal)? - .ok_or_else(|| { - record_auth_failure(&rate_hash); - auth_error() - })? - }; - let password = state - .services - .decrypt_subsonic_password(&credential) - .map_err(internal)?; - if params.first("apiKey").is_none() { - let valid = if let (Some(token), Some(salt)) = (params.first("t"), params.first("s")) { - let mut digest = Md5::new(); - digest.update(&password); - digest.update(salt.as_bytes()); - let expected = hex::encode(digest.finalize()); - security::constant_time_bytes_eq(token.as_bytes(), expected.as_bytes()) - } else if let Some(provided) = params.first("p") { - let decoded = match provided.strip_prefix("enc:").map(hex::decode).transpose() { - Ok(value) => value.unwrap_or_else(|| provided.as_bytes().to_vec()), - Err(_) => { - record_auth_failure(&rate_hash); - return Err(auth_error()); - } - }; - security::constant_time_bytes_eq(&decoded, &password) - } else { - false - }; - if !valid { - record_auth_failure(&rate_hash); - return Err(auth_error()); - } - } - clear_auth_failures(&rate_hash); - Ok(Principal { - id: credential.account.id, - username: credential.account.username, - role: credential.account.role, - }) -} - -fn auth_rate_limited(key: &str) -> bool { - let now = Instant::now(); - let windows = AUTH_WINDOWS.get_or_init(|| StdMutex::new(HashMap::new())); - let Ok(mut windows) = windows.lock() else { - return false; - }; - prune_auth_windows(&mut windows, now); - let attempts = windows.entry(key.to_owned()).or_default(); - attempts.len() >= AUTH_ATTEMPTS_PER_MINUTE -} - -fn record_auth_failure(key: &str) { - let windows = AUTH_WINDOWS.get_or_init(|| StdMutex::new(HashMap::new())); - if let Ok(mut windows) = windows.lock() { - let now = Instant::now(); - prune_auth_windows(&mut windows, now); - if !windows.contains_key(key) && windows.len() >= MAX_AUTH_RATE_KEYS { - if let Some(oldest) = windows - .iter() - .min_by_key(|(_, attempts)| attempts.back().copied()) - .map(|(key, _)| key.clone()) - { - windows.remove(&oldest); - } - } - windows.entry(key.to_owned()).or_default().push_back(now); - } -} - -fn prune_auth_windows(windows: &mut HashMap>, now: Instant) { - windows.retain(|_, attempts| { - while attempts - .front() - .is_some_and(|time| now.duration_since(*time) >= Duration::from_secs(60)) - { - attempts.pop_front(); - } - !attempts.is_empty() - }); -} - -fn clear_auth_failures(key: &str) { - let windows = AUTH_WINDOWS.get_or_init(|| StdMutex::new(HashMap::new())); - if let Ok(mut windows) = windows.lock() { - windows.remove(key); - } -} - -/// Folders, artists and albums for the requested libraries. -/// -/// Preferred over [`crate::services::DomainServices::catalog_snapshot`] -/// wherever the answer does not contain tracks: the track read is the -/// expensive third of a snapshot, and since the OpenSubsonic fields landed it -/// carries two relation loads of its own. -async fn overview( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let folders = params.uuids("musicFolderId")?; - state - .services - .catalog_overview(principal.id, &folders) - .await - .map_err(internal) -} - -/// Rescans every library the account can reach. -/// -/// Subsonic has no library parameter here, so this fans out. Both the -/// authorization and the queuing live in -/// [`crate::services::DomainServices::start_visible_scans`], so this facade -/// and the native per-library endpoint cannot disagree about who may scan -/// what. -async fn start_scan(state: &AppState, principal: &Principal) -> Result { - state - .services - .start_visible_scans(principal.id) - .await - .map_err(service_protocol)?; - // The protocol answers a start with the resulting status, so a client - // that only calls startScan still learns whether anything is running. - scan_status(state, principal).await -} - -/// `count` is the number of available tracks *this* account can reach, not -/// what the instance holds: the rest of the facade never reports a total that -/// includes another tenant's catalogue, and this is no exception. -async fn scan_status(state: &AppState, principal: &Principal) -> Result { - let (scanning, count) = state - .db - .scan_progress_for_user(principal.id) - .await - .map_err(internal)?; - Ok(Node::new("scanStatus") - .attr("scanning", scanning) - .attr("count", count)) -} - -async fn indexes( - state: &AppState, - principal: &Principal, - params: &Params, - id3: bool, -) -> Result { - let overview = overview(state, principal, params).await?; - let mut groups: BTreeMap> = BTreeMap::new(); - for artist in overview.artists { - let initial = artist - .artist - .name - .chars() - .next() - .filter(char::is_ascii_alphabetic) - .map(|value| value.to_ascii_uppercase()) - .unwrap_or('#'); - groups.entry(initial).or_default().push(artist); - } - let root_name = if id3 { "artists" } else { "indexes" }; - Ok(Node::new(root_name) - .attr("ignoredArticles", "The El La Les Le L'") - .attr("lastModified", chrono::Utc::now().timestamp_millis()) - .children(groups.into_iter().map(|(letter, artists)| { - Node::new("index") - .attr("name", letter.to_string()) - .children(artists.into_iter().map(|artist| { - // The count comes from the projection now. Filtering every - // album for every artist was a loop the facade had no - // business running, and it could only ever see the album's - // first credit. - artist_node(&artist.artist, artist.album_count as usize) - })) - }))) -} - -async fn get_artist( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let detail = state - .services - .artist(principal.id, params.uuid("id")?) - .await - .map_err(service_protocol)?; - // `album_count` comes from the projection rather than from the length of - // the list below. They agree today only because `albums` is unpaginated, - // which is an unwritten guarantee the response should not rest on. - Ok(artist_node(&detail.artist, detail.album_count as usize) - .children(detail.albums.iter().map(album_node))) -} - -async fn artist_info( - state: &AppState, - principal: &Principal, - params: &Params, - container: &'static str, -) -> Result { - state - .services - .artist(principal.id, params.uuid("id")?) - .await - .map_err(service_protocol)?; - Ok(Node::new(container)) -} - -/// `getAlbumInfo` and `getAlbumInfo2`. -/// -/// WaveFlow queries no remote source, so notes and biography images stay -/// absent. The release identifier is the one part of the answer the catalogue -/// actually holds, and it is emitted when the album has one. `AlbumInfo` -/// predates the OpenSubsonic presence rule, so an album without a release id -/// omits the element rather than sending it empty. -/// -/// The lookup runs first and for its refusal: it is what turns an album the -/// caller cannot reach into the same answer as one that does not exist. -async fn album_info( - state: &AppState, - principal: &Principal, - params: &Params, - container: &'static str, -) -> Result { - let album = state - .services - .album(principal.id, params.uuid("id")?) - .await - .map_err(service_protocol)?; - Ok(Node::new(container).children( - album - .album - .musicbrainz_id - .map(|id| Node::new("musicBrainzId").text(id)), - )) -} - -async fn get_album( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let detail = state - .services - .album(principal.id, params.uuid("id")?) - .await - .map_err(service_protocol)?; - Ok(album_node(&detail.album).children(detail.songs.iter().map(song_node))) -} - -async fn get_song( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let id = params.uuid("id")?; - let song = state - .services - .songs_by_ids(principal.id, &[id]) - .await - .map_err(service_protocol)? - .into_iter() - .next() - .ok_or_else(not_found)?; - Ok(song_node(&song)) -} - -async fn get_lyrics( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let artist = params.first("artist"); - let title = params.first("title"); - let Some(lyrics) = state - .services - .lyrics_by_metadata(principal.id, artist, title) - .await - .map_err(service_protocol)? - else { - return Ok(Node::new("lyrics") - .maybe_attr("artist", artist.map(str::to_owned)) - .maybe_attr("title", title.map(str::to_owned))); - }; - let Some(first) = lyrics.structured_lyrics.first() else { - return Ok(Node::new("lyrics")); - }; - Ok(Node::new("lyrics") - .maybe_attr("artist", first.display_artist.clone()) - .attr("title", first.display_title.clone()) - .text( - first - .lines - .iter() - .map(|line| line.value.as_str()) - .collect::>() - .join("\n"), - )) -} - -async fn get_lyrics_by_song_id( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let lyrics = state - .services - .lyrics(principal.id, params.uuid("id")?) - .await - .map_err(service_protocol)?; - Ok(Node::new("lyricsList") - .children(lyrics.structured_lyrics.iter().map(structured_lyrics_node))) -} - -fn structured_lyrics_node(lyrics: &crate::lyrics::StructuredLyrics) -> Node { - Node::new("structuredLyrics") - .maybe_attr("displayArtist", lyrics.display_artist.clone()) - .attr("displayTitle", lyrics.display_title.clone()) - .attr("lang", lyrics.lang.clone()) - .attr("synced", lyrics.synced) - .children(lyrics.lines.iter().map(|line| { - Node::new("line") - .maybe_attr("start", line.start) - .text(line.value.clone()) - })) -} - -/// Parameter adapter over [`crate::services::DomainServices::list_genres`]. -async fn genres( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let genres = state - .services - .list_genres(principal.id, ¶ms.uuids("musicFolderId")?) - .await - .map_err(internal)?; - Ok( - Node::new("genres").children(genres.into_iter().map(|genre| { - Node::new("genre") - .attr("songCount", genre.song_count) - .attr("albumCount", genre.album_count) - .text(genre.name) - })), - ) -} - -/// Renders an artist or album as a browsing entry of `getMusicDirectory`. -/// -/// `musicBrainzId` is dropped on the way. On a `Child` the specification -/// defines it as the *recording* identifier, and a folder standing for an -/// artist or a release has no recording: carrying the release or artist id -/// under that name would be a different identifier wearing the same label. -/// The `album` and `artist` responses keep it, where it means what it says. -fn directory_child(node: Node) -> Node { - node.renamed("child") - .attr("isDir", true) - .without("musicBrainzId") -} - -async fn music_directory( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let id = params.uuid("id")?; - let overview = overview(state, principal, params).await?; - let mut directory = Node::new("directory").attr("id", id.to_string()); - if let Some(folder) = overview.folders.iter().find(|item| item.id == id) { - // The artists of the library, then the tracks that belong to no album. - // Those name this folder as their `parent` for want of an album id, so - // this is the level that has to answer for them — otherwise a track - // advertises a parent that does not contain it. - let orphans = state - .services - .songs_without_album(principal.id, id, MAX_DIRECTORY_SONGS) - .await - .map_err(service_protocol)?; - if orphans.len() as i64 == MAX_DIRECTORY_SONGS { - tracing::warn!( - library_id = %id, - limit = MAX_DIRECTORY_SONGS, - "album-less tracks reached the folder ceiling; the listing may be short" - ); - } - directory = directory - .attr("name", folder.name.clone()) - .children( - overview - .artists - .iter() - .filter(|artist| artist.artist.library_id == id) - .map(|artist| { - directory_child(artist_node(&artist.artist, artist.album_count as usize)) - }), - ) - .children(orphans.iter().map(|song| song_node(song).renamed("child"))); - } else if let Some(credited) = match state.services.artist(principal.id, id).await { - Ok(credited) => Some(credited), - // Only an absence justifies trying the next branch. A database - // failure has to say so rather than turn into a not-found, which is - // the answer this method gives an identifier that does not exist. - Err(ServiceError::NotFound) => None, - Err(error) => return Err(service_protocol(error)), - } { - // The albums this artist is credited to, by the same rule `getArtist` - // uses — and resolved the same way, rather than from the overview. - // The overview lists only artists an album is credited to, so looking - // the identifier up there would answer 404 for a composer that - // `getArtist` answers for. Tenancy is unchanged: the service blurs a - // foreign identifier into the same not-found this arm falls through to. - directory = directory - .attr("name", credited.artist.name.clone()) - .children( - credited - .albums - .iter() - .map(|album| directory_child(album_node(album))), - ); - } else if overview.albums.iter().any(|item| item.id == id) { - // Only this level needs tracks, and only this album's. - let detail = state - .services - .album(principal.id, id) - .await - .map_err(service_protocol)?; - directory = directory.attr("name", detail.album.title.clone()).children( - detail - .songs - .iter() - .map(|song| song_node(song).renamed("child")), - ); - } else { - return Err(not_found()); - } - Ok(directory) -} - -/// Parameter adapter over [`crate::services::DomainServices::list_albums`]. -/// -/// The ten ordering modes used to live here, sorted in Rust over a full -/// `catalog_snapshot`. They now resolve in SQL, so this maps Subsonic spelling -/// onto the shared query and does nothing else — which is what M4 asks of a -/// facade, and it stops one album page from reading the tenant's whole -/// catalogue. -async fn album_list( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let order = params - .first("type") - .unwrap_or("alphabeticalByName") - .parse::() - .map_err(|_| invalid("Invalid album list type"))?; - let offset = params.usize_or("offset", 0, 100_000)?; - let size = params.usize_or("size", 10, 500)?; - // A page of nothing is a valid Subsonic request and used to answer with an - // empty container. `BrowsePage` rejects a zero limit, so the short-circuit - // keeps that shape rather than turning it into error code 10. - if size == 0 { - return Ok(Node::new("albumList2")); - } - let query = AlbumListQuery { - library_ids: params.uuids("musicFolderId")?, - order, - genre: params.first("genre").map(str::to_owned), - from_year: params.i64_optional("fromYear")?, - to_year: params.i64_optional("toYear")?, - page: BrowsePage::new(Some(offset as i64), Some(size as i64)).map_err(service_protocol)?, - }; - let albums = state - .services - .list_albums(principal.id, &query) - .await - .map_err(service_protocol)?; - Ok(Node::new("albumList2").children(albums.iter().map(album_node))) -} - -async fn random_songs( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let size = params.usize_or("size", 10, 500)?; - // A page of nothing is a valid request, as it is for getAlbumList. - if size == 0 { - return Ok(Node::new("randomSongs")); - } - let songs = state - .services - .random_songs( - principal.id, - ¶ms.uuids("musicFolderId")?, - params.first("genre"), - params.i64_optional("fromYear")?, - params.i64_optional("toYear")?, - size as i64, - ) - .await - .map_err(service_protocol)?; - Ok(Node::new("randomSongs").children(songs.iter().map(song_node))) -} - -async fn songs_by_genre( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let genre = params.first("genre").ok_or_else(missing)?; - let offset = params.usize_or("offset", 0, 100_000)?; - let count = params.usize_or("count", 10, 500)?; - if count == 0 { - return Ok(Node::new("songsByGenre")); - } - let songs = state - .services - .songs_by_genre( - principal.id, - ¶ms.uuids("musicFolderId")?, - genre, - BrowsePage::new(Some(offset as i64), Some(count as i64)).map_err(service_protocol)?, - ) - .await - .map_err(service_protocol)?; - Ok(Node::new("songsByGenre").children(songs.iter().map(song_node))) -} - -async fn search( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let raw_query = params.first("query").ok_or_else(missing)?; - let artist_count = params.usize_or("artistCount", 20, 500)?; - let artist_offset = params.usize_or("artistOffset", 0, 100_000)?; - let album_count = params.usize_or("albumCount", 20, 500)?; - let album_offset = params.usize_or("albumOffset", 0, 100_000)?; - let song_count = params.usize_or("songCount", 20, 500)?; - let song_offset = params.usize_or("songOffset", 0, 100_000)?; - - let folders = params.uuids("musicFolderId")?; - - // Subsonic clients send the literal pair of quotes as the documented - // match-all query while paging through a complete catalogue. There is - // nothing to match and FTS5 has no expression meaning "everything", so - // this is three ordinary listings wearing the search response — paged in - // SQL rather than sliced out of a full catalogue read, which is what made - // a client's initial synchronization quadratic in the library. - if raw_query == "\"\"" { - let page = |offset: usize, count: usize| { - BrowsePage::new(Some(offset as i64), Some(count as i64)).map_err(service_protocol) - }; - let found = state - .services - .browse_all( - principal.id, - &folders, - page(artist_offset, artist_count.max(1))?, - page(album_offset, album_count.max(1))?, - page(song_offset, song_count.max(1))?, - ) - .await - .map_err(service_protocol)?; - // The service already applied the offsets, so the renderer must not. - return Ok(search_result( - found.artists.iter().take(artist_count), - found.albums.iter().take(album_count), - found.songs.iter().take(song_count), - (0, artist_count), - (0, album_count), - (0, song_count), - )); - } - - let found = state - .services - .catalog_search(principal.id, &folders, raw_query) - .await - .map_err(internal)?; - Ok(search_result( - found.artists.iter(), - found.albums.iter(), - found.songs.iter(), - (artist_offset, artist_count), - (album_offset, album_count), - (song_offset, song_count), - )) -} - -/// Renders a `searchResult3` from already-selected entities. -#[allow(clippy::too_many_arguments)] -fn search_result<'a>( - artists: impl Iterator, - albums: impl Iterator, - songs: impl Iterator, - (artist_offset, artist_count): (usize, usize), - (album_offset, album_count): (usize, usize), - (song_offset, song_count): (usize, usize), -) -> Node { - Node::new("searchResult3") - .children( - artists - .skip(artist_offset) - .take(artist_count) - .map(|artist| artist_node(artist, 0)), - ) - .children(albums.skip(album_offset).take(album_count).map(album_node)) - .children(songs.skip(song_offset).take(song_count).map(song_node)) -} - -async fn bookmarks(state: &AppState, principal: &Principal) -> Result { - let bookmarks = state - .services - .bookmarks(principal.id) - .await - .map_err(internal)?; - Ok(Node::new("bookmarks").children( - bookmarks - .iter() - .map(|bookmark| bookmark_node(bookmark, &principal.username)), - )) -} - -async fn create_bookmark( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - state - .services - .set_bookmark( - principal.id, - params.uuid("id")?, - params.i64("position")?, - params.first("comment"), - ) - .await - .map_err(service_protocol)?; - Ok(Node::new("createBookmark")) -} - -async fn delete_bookmark( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - state - .services - .delete_bookmark(principal.id, params.uuid("id")?) - .await - .map_err(service_protocol)?; - Ok(Node::new("deleteBookmark")) -} - -async fn playlists(state: &AppState, principal: &Principal) -> Result { - let playlists = state - .services - .playlists(principal.id) - .await - .map_err(internal)?; - Ok(Node::new("playlists").children( - playlists - .iter() - .map(|playlist| playlist_node(playlist, &principal.username)), - )) -} - -async fn get_playlist( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let playlist = state - .services - .playlist(principal.id, params.uuid("id")?) - .await - .map_err(service_protocol)?; - Ok(playlist_node(&playlist, &principal.username).children( - playlist - .songs - .iter() - .map(|song| song_node(song).renamed("entry")), - )) -} - -async fn create_playlist( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let ids = params.uuids("songId")?; - let playlist = if let Some(id) = params.uuid_optional("playlistId")? { - state - .services - // Given a playlistId, songId names every song of the playlist, so - // the call replaces the track list rather than adding to it. A - // client that removes a song sends back what remains, and would - // otherwise see nothing change. - // - // The Subsonic contract is frozen: it has no way to ask for a - // text field to be blanked, so clearing the comment stays off. - .update_playlist( - principal.id, - id, - None, - None, - None, - &ids, - &[], - crate::services::PlaylistClear { - comment: false, - tracks: true, - }, - ) - .await - .map_err(service_protocol)? - } else { - state - .services - .create_playlist( - principal.id, - params.first("name").ok_or_else(missing)?, - &ids, - ) - .await - .map_err(service_protocol)? - }; - Ok(playlist_node(&playlist, &principal.username).children( - playlist - .songs - .iter() - .map(|song| song_node(song).renamed("entry")), - )) -} - -async fn update_playlist( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let playlist = state - .services - .update_playlist( - principal.id, - params.uuid("playlistId")?, - params.first("name"), - params.first("comment"), - params.bool_optional("public")?, - ¶ms.uuids("songIdToAdd")?, - ¶ms.usizes("songIndexToRemove")?, - Default::default(), - ) - .await - .map_err(service_protocol)?; - Ok(playlist_node(&playlist, &principal.username)) -} - -async fn delete_playlist( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - state - .services - .delete_playlist(principal.id, params.uuid("id")?) - .await - .map_err(service_protocol)?; - Ok(Node::new("deletePlaylist")) -} - -async fn set_star( - state: &AppState, - principal: &Principal, - params: &Params, - starred: bool, -) -> Result { - for id in params.uuids("id")? { - let kind = state - .services - .entity_kind(principal.id, id) - .await - .map_err(service_protocol)? - .ok_or_else(not_found)?; - state - .services - .set_star(principal.id, kind, id, starred) - .await - .map_err(service_protocol)?; - } - for (key, kind) in [("albumId", "album"), ("artistId", "artist")] { - for id in params.uuids(key)? { - state - .services - .set_star(principal.id, kind, id, starred) - .await - .map_err(service_protocol)?; - } - } - Ok(Node::new(if starred { "star" } else { "unstar" })) -} - -async fn starred( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - // The three projections already carry `starred_at`, so the nodes emit - // `starred` themselves. This used to read the whole catalogue and look - // each starred id up inside it. - let starred = state - .services - .starred(principal.id, ¶ms.uuids("musicFolderId")?) - .await - .map_err(service_protocol)?; - let mut node = Node::new("starred2"); - node.children.extend( - starred - .artists - .iter() - .map(|summary| artist_node(&summary.artist, summary.album_count as usize)), - ); - node.children.extend(starred.albums.iter().map(album_node)); - node.children.extend(starred.songs.iter().map(song_node)); - Ok(node) -} - -async fn set_rating( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let id = params.uuid("id")?; - let rating = params.i64("rating")?; - let kind = state - .services - .entity_kind(principal.id, id) - .await - .map_err(service_protocol)? - .ok_or_else(not_found)?; - state - .services - .set_rating(principal.id, kind, id, rating) - .await - .map_err(service_protocol)?; - Ok(Node::new("setRating")) -} - -async fn scrobble( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let ids = params.uuids("id")?; - if ids.is_empty() { - return Err(missing()); - } - let times = params - .all("time") - .iter() - .map(|value| value.parse::().map_err(|_| invalid("Invalid time"))) - .collect::, _>>()?; - let submission = params.bool_optional("submission")?.unwrap_or(true); - for (index, id) in ids.into_iter().enumerate() { - state - .services - .scrobble(principal.id, id, submission, times.get(index).copied()) - .await - .map_err(service_protocol)?; - } - Ok(Node::new("scrobble")) -} - -async fn now_playing(state: &AppState, principal: &Principal) -> Result { - let entries = state - .services - .now_playing(principal.id) - .await - .map_err(internal)?; - Ok( - Node::new("nowPlaying").children(entries.iter().map(|(username, song, started)| { - song_node(song).attr("username", username.clone()).attr( - "minutesAgo", - ((chrono::Utc::now().timestamp_millis() - started) / 60_000).max(0), - ) - })), - ) -} - -async fn get_queue(state: &AppState, principal: &Principal) -> Result { - let Some(queue) = state.services.queue(principal.id).await.map_err(internal)? else { - return Ok(Node::new("playQueue")); - }; - Ok(Node::new("playQueue") - .maybe_attr("current", queue.current.map(|id| id.to_string())) - .attr("position", queue.position_ms) - .maybe_attr("changedBy", queue.changed_by) - .attr("changed", iso_time(queue.updated_at)) - .children(queue.songs.iter().map(song_node))) -} - -async fn save_queue( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - state - .services - .save_queue( - principal.id, - ¶ms.uuids("id")?, - params.uuid_optional("current")?, - params.i64_or("position", 0)?, - params.first("c"), - ) - .await - .map_err(service_protocol)?; - Ok(Node::new("savePlayQueue")) -} - -async fn media_response( - state: &AppState, - principal: &Principal, - params: &Params, - download: bool, - range: Option<&str>, -) -> Result { - let id = params.uuid("id")?; - let track = state - .db - .stream_track_for_user(principal.id, id) - .await - .map_err(internal)? - .ok_or_else(not_found)?; - let requested_bitrate = params - .first("maxBitRate") - .map(|value| value.parse::()) - .transpose() - .map_err(|_| invalid("Invalid bitrate"))? - .filter(|bitrate| *bitrate > 0); - let format = if download { - OutputFormat::Raw - } else if let Some(format) = params.first("format") { - match format { - "raw" => OutputFormat::Raw, - "mp3" => OutputFormat::Mp3, - "opus" | "ogg" => OutputFormat::Opus, - _ => return Err(invalid("Unsupported format")), - } - } else if requested_bitrate.is_some_and(|limit| { - track - .bitrate - .and_then(|bitrate| u32::try_from(bitrate).ok()) - .is_none_or(|source| source > limit) - }) { - // Legacy Subsonic clients such as DSub always send maxBitRate, even - // when it matches the source. Downsample only when the cap is lower; - // otherwise preserve direct playback just like Navidrome. - OutputFormat::Mp3 - } else { - OutputFormat::Raw - }; - let query = StreamQuery { - format, - bitrate: (format != OutputFormat::Raw) - .then_some(requested_bitrate) - .flatten(), - offset_ms: params - .first("timeOffset") - .map(|value| { - value - .parse::() - .ok() - .and_then(|seconds| seconds.checked_mul(1000)) - .ok_or(()) - }) - .transpose() - .map_err(|_| invalid("Invalid time offset"))? - .unwrap_or(0), - }; - match state.media.serve(principal.id, track, query, range).await { - Ok(mut response) => { - if download { - response.headers_mut().insert( - header::CONTENT_DISPOSITION, - "attachment".parse().expect("static header value"), - ); - } - Ok(response) - } - Err(MediaError::NotFound | MediaError::Unauthorized) => Err(not_found()), - Err(MediaError::InvalidRequest) => Err(invalid("Invalid media parameters")), - Err(error @ (MediaError::RangeNotSatisfiable(_) | MediaError::Busy)) => { - Ok(error.into_response()) - } - Err(MediaError::Internal) => Err(internal("media service failed")), - } -} - -async fn cover_art_response( - state: &AppState, - principal: &Principal, - params: &Params, -) -> Result { - let id = params.first("id").ok_or_else(missing)?; - let (hash, format) = state - .services - .artwork_for_user(principal.id, id) - .await - .map_err(internal)? - .ok_or_else(not_found)?; - let (mime, bytes) = crate::media::read_artwork(&state.artwork_dir, &hash, &format) - .await - .ok_or_else(not_found)?; - Ok(( - StatusCode::OK, - [ - (header::CONTENT_TYPE, mime), - (header::CACHE_CONTROL, "private, max-age=86400"), - ], - bytes, - ) - .into_response()) -} - -async fn shares( - state: &AppState, - principal: &Principal, - method: &str, - params: &Params, -) -> Result { - match method { - "getShares" => Ok(Node::new("shares").children( - state - .services - .shares(principal.id) - .await - .map_err(internal)? - .iter() - .map(|share| share_node(share, &principal.username, state.public_url.as_deref())), - )), - "createShare" => { - let share = state - .services - .create_share( - principal.id, - ¶ms.uuids("id")?, - params.first("description"), - params.first("expires").map(parse_time).transpose()?, - ) - .await - .map_err(service_protocol)?; - Ok(Node::new("shares").child(share_node( - &share, - &principal.username, - state.public_url.as_deref(), - ))) - } - "updateShare" => { - let share = state - .services - .update_share( - principal.id, - params.uuid("id")?, - params.first("description"), - params.first("expires").map(parse_time).transpose()?, - Default::default(), - ) - .await - .map_err(service_protocol)?; - Ok(Node::new("shares").child(share_node( - &share, - &principal.username, - state.public_url.as_deref(), - ))) - } - "deleteShare" => { - state - .services - .delete_share(principal.id, params.uuid("id")?) - .await - .map_err(service_protocol)?; - Ok(Node::new("deleteShare")) - } - _ => unreachable!("share method dispatch is exhaustive"), - } -} - -async fn admin( - state: &AppState, - principal: &Principal, - method: &str, - params: &Params, -) -> Result { - if principal.role != AccountRole::Admin { - return Err(ProtocolError { - code: 50, - message: "User is not authorized for the given operation", - }); - } - match method { - "getUser" => { - let username = params.first("username").unwrap_or(&principal.username); - let user = state - .services - .users(principal.id) - .await - .map_err(service_protocol)? - .into_iter() - .find(|user| user.username.eq_ignore_ascii_case(username)) - .ok_or_else(not_found)?; - Ok(user_node(&user)) - } - "getUsers" => Ok(Node::new("users").children( - state - .services - .users(principal.id) - .await - .map_err(service_protocol)? - .iter() - .map(user_node), - )), - "createUser" => { - let password = - decode_credential_password(params.first("password").ok_or_else(missing)?)?; - let folders = params.uuids("musicFolderId")?; - let folders = params.first("musicFolderId").is_some().then_some(folders); - let user = state - .services - .create_subsonic_user( - principal.id, - params.first("username").ok_or_else(missing)?, - &password, - params.bool_optional("adminRole")?.unwrap_or(false), - folders.as_deref(), - ) - .await - .map_err(service_protocol)?; - Ok(user_node(&user)) - } - "updateUser" => { - let folders = params.uuids("musicFolderId")?; - let folders = params.first("musicFolderId").is_some().then_some(folders); - let password = params - .first("password") - .map(decode_credential_password) - .transpose()?; - let user = state - .services - .update_user( - principal.id, - params.first("username").ok_or_else(missing)?, - crate::services::UserUpdate { - admin: params.bool_optional("adminRole")?, - disabled: params.bool_optional("locked")?, - folder_ids: folders.as_deref(), - subsonic_password: password.as_deref(), - web_password: None, - }, - ) - .await - .map_err(service_protocol)?; - Ok(user_node(&user)) - } - "deleteUser" => { - state - .services - .delete_user(principal.id, params.first("username").ok_or_else(missing)?) - .await - .map_err(service_protocol)?; - Ok(Node::new("deleteUser")) - } - "changePassword" => { - let password = - decode_credential_password(params.first("password").ok_or_else(missing)?)?; - state - .services - .change_subsonic_password( - principal.id, - params.first("username").ok_or_else(missing)?, - &password, - ) - .await - .map_err(service_protocol)?; - Ok(Node::new("changePassword")) - } - _ => unreachable!("admin method dispatch is exhaustive"), - } -} - -fn artist_node(artist: &ArtistItem, album_count: usize) -> Node { - Node::new("artist") - .attr("id", artist.id.to_string()) - .attr("name", artist.name.clone()) - .attr("albumCount", album_count as i64) - .maybe_attr("coverArt", artist.artwork_hash.clone()) - .maybe_attr("starred", artist.starred_at.map(iso_time)) - .maybe_attr("userRating", artist.user_rating) - // An OpenSubsonic addition, under the presence rule the media items - // already follow: on an artist the identifier means the artist, and it - // is emitted empty rather than omitted so a client can tell an untagged - // artist from a server that does not read the tag at all. - .attr( - "musicBrainzId", - artist.musicbrainz_id.clone().unwrap_or_default(), - ) - // Now that the column exists the field is supported, so it is emitted - // with its default rather than omitted: absent would go on saying the - // server cannot answer, which stopped being true. - .attr("sortName", artist.sort_name.clone().unwrap_or_default()) - // The capacities this artist is credited in. Ordered by name inside - // the projection, where the reference emits them in map-iteration - // order and answers differently on every request. Its two synthetic - // roles — `total` and `maincredit` — are not OpenSubsonic role names - // and are not stored, so they cannot leak here. - .children( - artist - .roles - .iter() - .map(|role| Node::new("roles").text(role.clone())), - ) -} - -/// `songCount` and `duration` come from the album projection rather than from -/// a slice of loaded tracks: counting them caller-side is what forced every -/// album listing to materialise the tenant's whole track list first. -fn album_node(album: &AlbumItem) -> Node { - Node::new("album") - .attr("id", album.id.to_string()) - .attr("name", album.title.clone()) - .attr("title", album.title.clone()) - .maybe_attr("artist", album.artist.clone()) - .maybe_attr("artistId", album.artist_id.map(|id| id.to_string())) - .maybe_attr("coverArt", album.artwork_hash.clone()) - .maybe_attr("year", album.year) - .maybe_attr("starred", album.starred_at.map(iso_time)) - .maybe_attr("userRating", album.user_rating) - .attr("songCount", album.song_count) - .attr("duration", album.duration_ms / 1000) - .attr("created", iso_time(album.created_at)) - // OpenSubsonic additions, under the same presence rule as `song`. - .attr("isCompilation", album.is_compilation) - .attr("playCount", album.play_count) - .attr("displayArtist", album.artist.clone().unwrap_or_default()) - .attr("sortName", album.sort_name.clone().unwrap_or_default()) - .maybe_attr("played", album.last_played_at.map(iso_time)) - .children(album.artists.iter().map(|artist| { - Node::new("artists") - .attr("id", artist.id.to_string()) - .attr("name", artist.name.clone()) - })) - .children( - album - .genres - .iter() - .map(|genre| Node::new("genres").attr("name", genre.clone())), - ) - // On an album the identifier means the release, not the recording the - // song carries. It is derived from the album's own tracks at scan time, - // so it is a plain column read here. - .attr( - "musicBrainzId", - album.musicbrainz_id.clone().unwrap_or_default(), - ) -} - -fn song_node(song: &SongItem) -> Node { - Node::new("song") - .attr("id", song.id.to_string()) - .attr( - "parent", - song.album_id.unwrap_or(song.library_id).to_string(), - ) - .attr("isDir", false) - .attr("title", song.title.clone()) - .maybe_attr("album", song.album.clone()) - .maybe_attr("artist", song.artist.clone()) - .maybe_attr("genre", song.genre.clone()) - .maybe_attr("year", song.year) - .maybe_attr("track", song.track) - .maybe_attr("discNumber", song.disc) - .attr("duration", song.duration_ms / 1000) - .maybe_attr("bitRate", song.bitrate) - .attr("size", song.size) - .attr("suffix", song.suffix.clone()) - .attr("contentType", content_type(&song.suffix)) - .attr("type", "music") - .maybe_attr("coverArt", song.artwork_hash.clone()) - .maybe_attr("albumId", song.album_id.map(|id| id.to_string())) - .maybe_attr("artistId", song.artist_id.map(|id| id.to_string())) - .maybe_attr("starred", song.starred_at.map(iso_time)) - .maybe_attr("userRating", song.user_rating) - .attr("created", iso_time(song.created_at)) - // From here down the fields are OpenSubsonic additions, and they follow - // its presence rule rather than the omission rule the frozen 1.16 - // fields above use: a field the server supports is emitted even when - // the value is unknown, because presence is the only way a client can - // tell "this server does not implement it" from "this track has none". - .attr("mediaType", "song") - .attr("isVideo", false) - .attr("samplingRate", song.sample_rate.unwrap_or_default()) - .attr("channelCount", song.channels.unwrap_or_default()) - .attr("bitDepth", song.bit_depth.unwrap_or_default()) - .attr("playCount", song.play_count) - .attr("displayArtist", song.artist.clone().unwrap_or_default()) - // `played` is the one exception. Its default would be the empty - // string, which is not a timestamp: a client parsing it strictly would - // fail on every track nobody has played. `playCount` is always present - // and already tells the client play statistics are supported. - .maybe_attr("played", song.last_played_at.map(iso_time)) - .children(song.artists.iter().map(|artist| { - Node::new("artists") - .attr("id", artist.id.to_string()) - .attr("name", artist.name.clone()) - })) - .children( - song.genres - .iter() - .map(|genre| Node::new("genres").attr("name", genre.clone())), - ) - // Every artist the album is credited to, not just the one the frozen - // `artistId` field can name. - .children(song.album_artists.iter().map(|artist| { - Node::new("albumArtists") - .attr("id", artist.id.to_string()) - .attr("name", artist.name.clone()) - })) - // Everyone else the file credits: composer, producer, performer and - // the rest, each naming what it did. `subRole` is the instrument a - // performer is credited on, and only a performer has one. - .children(song.contributors.iter().map(|credit| { - Node::new("contributors") - .attr("role", credit.role.clone()) - .maybe_attr("subRole", credit.sub_role.clone()) - .child( - Node::new("artist") - .attr("id", credit.artist.id.to_string()) - .attr("name", credit.artist.name.clone()), - ) - })) - .attr( - "displayComposer", - song.contributors - .iter() - .filter(|credit| credit.role == "composer") - .map(|credit| credit.artist.name.as_str()) - .collect::>() - .join(" \u{2022} "), - ) - .attr( - "displayAlbumArtist", - song.album_artist.clone().unwrap_or_default(), - ) - .attr( - "musicBrainzId", - song.musicbrainz_id.clone().unwrap_or_default(), - ) - .attr("bpm", song.bpm.unwrap_or_default()) - .attr("sortName", song.sort_name.clone().unwrap_or_default()) - .attr("comment", song.comment.clone().unwrap_or_default()) - .children( - song.isrc - .iter() - .map(|isrc| Node::new("isrc").text(isrc.clone())), - ) - .children( - song.moods - .iter() - .map(|mood| Node::new("moods").text(mood.clone())), - ) - .attr( - "explicitStatus", - song.explicit_status.clone().unwrap_or_default(), - ) - // ReplayGain is the one addition whose *members* are omitted when - // unknown, on the specification's own instruction. The container is - // still always present, because that is what says the server reads - // gain tags at all; an untagged track carries an empty one. - .child( - Node::new("replayGain") - .maybe_attr("trackGain", song.replay_gain_track_gain) - .maybe_attr("trackPeak", song.replay_gain_track_peak) - .maybe_attr("albumGain", song.replay_gain_album_gain) - .maybe_attr("albumPeak", song.replay_gain_album_peak), - ) -} - -/// `owner` is the caller: playlist reads are already scoped to their owner, so -/// there is no other name this could carry. Leaving it empty made Feishin -/// treat every playlist as someone else's and refuse to edit it. -/// `bookmarkPosition` is set on the entry rather than on every song node: it -/// is a legacy optional field, and a track only has a position inside the -/// bookmark that holds it. -fn bookmark_node(bookmark: &crate::services::BookmarkItem, owner: &str) -> Node { - Node::new("bookmark") - .attr("position", bookmark.position_ms) - .attr("username", owner) - .maybe_attr("comment", bookmark.comment.clone()) - .attr("created", iso_time(bookmark.created_at)) - .attr("changed", iso_time(bookmark.updated_at)) - .child( - song_node(&bookmark.song) - .renamed("entry") - .attr("bookmarkPosition", bookmark.position_ms), - ) -} - -fn playlist_node(playlist: &PlaylistItem, owner: &str) -> Node { - Node::new("playlist") - .attr("id", playlist.id.to_string()) - .attr("name", playlist.name.clone()) - .maybe_attr("comment", playlist.comment.clone()) - .attr("owner", owner) - .attr("public", playlist.public) - .attr("songCount", playlist.songs.len() as i64) - .attr( - "duration", - playlist - .songs - .iter() - .map(|song| song.duration_ms / 1000) - .sum::(), - ) - .attr("created", iso_time(playlist.created_at)) - .attr("changed", iso_time(playlist.updated_at)) -} - -fn user_node(user: &crate::services::UserItem) -> Node { - Node::new("user") - .attr("username", user.username.clone()) - .attr("scrobblingEnabled", true) - .attr("adminRole", user.role == AccountRole::Admin) - .attr("settingsRole", user.role == AccountRole::Admin) - .attr("downloadRole", true) - .attr("uploadRole", false) - .attr("playlistRole", true) - .attr("coverArtRole", true) - .attr("commentRole", false) - .attr("podcastRole", false) - .attr("streamRole", true) - .attr("jukeboxRole", false) - .attr("shareRole", true) - .attr("videoConversionRole", false) - .children( - user.folder_ids - .iter() - .map(|id| Node::new("folder").text(id.to_string())), - ) -} - -fn share_node(share: &crate::services::ShareItem, owner: &str, public_url: Option<&str>) -> Node { - let url = share.url_token.as_ref().map(|token| { - let path = format!("/share/{token}"); - external_url(public_url, &path) - }); - Node::new("share") - .attr("id", share.id.to_string()) - .maybe_attr("url", url) - .maybe_attr("description", share.description.clone()) - .maybe_attr("expires", share.expires_at.map(iso_time)) - .attr("username", owner) - .attr("created", iso_time(share.created_at)) - .attr("visitCount", share.visit_count) - .children( - share - .songs - .iter() - .map(|song| song_node(song).renamed("entry")), - ) -} - -fn external_url(base: Option<&str>, path: &str) -> String { - base.map_or_else(|| path.to_owned(), |base| format!("{base}{path}")) -} - -fn ok_node() -> Node { - Node::new("subsonic-response") - .attr("xmlns", XMLNS) - .attr("status", "ok") - .attr("version", SUBSONIC_VERSION) - .attr("type", "waveflow") - .attr("serverVersion", env!("CARGO_PKG_VERSION")) - .attr("openSubsonic", true) -} - -fn error_node(code: i64, message: &'static str) -> Node { - Node::new("subsonic-response") - .attr("xmlns", XMLNS) - .attr("status", "failed") - .attr("version", SUBSONIC_VERSION) - .attr("type", "waveflow") - .attr("serverVersion", env!("CARGO_PKG_VERSION")) - .attr("openSubsonic", true) - .child( - Node::new("error") - .attr("code", code) - .attr("message", message), - ) -} - -fn render_protocol(node: Node, json: bool) -> Response { - if json { - let mut root = Map::new(); - root.insert(node.name.clone(), node_json(&node, "")); - ( - StatusCode::OK, - [(header::CONTENT_TYPE, "application/json; charset=utf-8")], - Value::Object(root).to_string(), - ) - .into_response() - } else { - ( - StatusCode::OK, - [(header::CONTENT_TYPE, "application/xml; charset=utf-8")], - node_xml(&node), - ) - .into_response() - } -} - -fn node_json(node: &Node, parent: &str) -> Value { - // An array-typed element is its children, not an object wrapping them — - // whether it holds none or several. Applying this only when empty would - // hand a strictly typed client `[]` on an empty catalogue and an object on - // a populated one, which is worse than being wrong consistently. - if json_array_node(&node.name) { - return Value::Array( - node.children - .iter() - .map(|child| node_json(child, &node.name)) - .collect(), - ); - } - if node.attrs.is_empty() && node.children.is_empty() { - if let Some(text) = &node.text { - return Value::String(text.clone()); - } - } - let mut map = Map::new(); - for (key, value) in &node.attrs { - if key != "xmlns" { - map.insert(key.clone(), value.clone()); - } - } - let mut grouped: BTreeMap<&str, Vec<&Node>> = BTreeMap::new(); - for child in &node.children { - grouped.entry(&child.name).or_default().push(child); - } - for (name, children) in grouped { - let value = if children.len() == 1 && !json_array_field(&node.name, name) { - node_json(children[0], &node.name) - } else { - Value::Array( - children - .into_iter() - .map(|child| node_json(child, &node.name)) - .collect(), - ) - }; - map.insert(name.to_owned(), value); - } - // A browsing child is a song, an album or an artist under one element - // name, and its own fields are what tell them apart: an artist carries - // `albumCount`, an album `songCount`, a song neither. Injecting a song's - // relations into a folder entry would have an artist answer `isrc: []`, - // and injecting an album's would have it answer `artists: []` — a list of - // the artists of an artist. - let entry_kind = match ( - node.attrs.contains_key("albumCount"), - node.attrs.contains_key("songCount"), - ) { - (true, _) => EntryKind::Artist, - (_, true) => EntryKind::Album, - _ => EntryKind::Song, - }; - for name in json_required_array_fields(parent, &node.name) { - let injected = match entry_kind { - // An artist keeps its own array and takes nobody else's: a folder - // entry answering `isrc: []` would say the server read a recording - // identifier off a directory, and `artists: []` would be the list - // of the artists of an artist. - EntryKind::Artist => *name == "roles", - EntryKind::Album => matches!(*name, "artists" | "genres"), - EntryKind::Song => true, - }; - if !injected { - continue; - } - map.entry((*name).to_owned()) - .or_insert_with(|| Value::Array(Vec::new())); - } - if let Some(text) = &node.text { - map.insert("value".into(), Value::String(text.clone())); - } - Value::Object(map) -} - -/// Elements the OpenSubsonic specification types as a JSON array rather than an -/// object. They must serialise as `[]` when empty; an empty object breaks -/// strictly typed clients that decode the field into a list. -fn json_array_node(name: &str) -> bool { - matches!(name, "openSubsonicExtensions") -} - -fn json_required_array_fields(parent: &str, name: &str) -> &'static [&'static str] { - // A contributor's artist is a reference — an identifier and a display - // name — and shares its element name with the record. Without the parent - // to tell them apart, every array the record carries would be injected - // into the reference, which is exactly what - // `an_artist_reference_is_not_an_artist_record` forbids. - if parent == "contributors" && name == "artist" { - return &[]; - } - match name { - "lyricsList" => &["structuredLyrics"], - "structuredLyrics" => &["line"], - // Emitted as `[]` rather than omitted when a track has no credited - // artist or no genre: under the OpenSubsonic presence rule an absent - // key means the server does not support the field at all. - "song" | "entry" | "child" => &[ - "artists", - "genres", - "isrc", - "moods", - "albumArtists", - "contributors", - ], - "album" => &["artists", "genres"], - // The roles an artist is credited in, empty rather than absent for - // the same reason: absent would say the server does not read them. - "artist" => &["roles"], - _ => &[], - } -} - -/// Extensions this server actually implements, with their supported versions. -/// -/// The list was empty, which told every third-party client that WaveFlow -/// supports nothing optional — so a client that could have posted a long -/// request, authenticated with an API key or seeked a transcode fell back to -/// the lowest common denominator instead. -/// -/// **Only advertise what is implemented and covered by tests.** Announcing an -/// extension the server does not honour is worse than announcing none: the -/// client stops probing and starts relying on it. -/// -/// The specification defines no XML shape for this method, so `versions` -/// renders as a JSON array here and stringifies as `"[1]"` in the XML branch. -/// Clients that use the method request JSON. -fn open_subsonic_extensions() -> Node { - let extension = |name: &str, versions: Vec| { - Node::new("openSubsonicExtension").attr("name", name).attr( - "versions", - Value::Array(versions.into_iter().map(Value::from).collect()), - ) - }; - Node::new("openSubsonicExtensions") - // POST with application/x-www-form-urlencoded, for requests too long - // for a query string. - .child(extension("formPost", vec![1])) - // `apiKey` in place of the u/p and u/t/s pairs. - .child(extension("apiKeyAuthentication", vec![1])) - // `timeOffset` on stream, honoured for transcoded output. - .child(extension("transcodeOffset", vec![1])) - // Structured plain or line-synchronised lyrics by stable song UUID. - .child(extension("songLyrics", vec![1])) -} - -fn json_array_field(parent: &str, name: &str) -> bool { - matches!( - (parent, name), - ("musicFolders", "musicFolder") - | ("indexes", "index") - | ("artists", "index") - | ("index", "artist") - | ("artist", "album") - | ("album", "song") - | ("genres", "genre") - | ("directory", "child") - | ("albumList", "album") - | ("albumList2", "album") - | ("randomSongs", "song") - | ("songsByGenre", "song") - | ("searchResult3" | "searchResult2", "artist" | "album" | "song") - | ("playlists", "playlist") - | ("playlist", "entry") - | ("bookmarks", "bookmark") - | ("starred2" | "starred", "artist" | "album" | "song") - | ("nowPlaying", "song") - | ("playQueue", "song") - | ("shares", "share") - | ("share", "entry") - | ("users", "user") - | ("user", "folder") - | ("openSubsonicExtensions", "openSubsonicExtension") - | ("lyricsList", "structuredLyrics") - | ("structuredLyrics", "line") - // A media item is rendered as `song`, and renamed to `entry` inside - // a playlist or share and to `child` inside a directory. Its - // OpenSubsonic relations are arrays under all three names. - | ("song" | "entry" | "child" | "album", "artists" | "genres") - | ("song" | "entry" | "child", "isrc" | "moods" | "albumArtists") - | ("song" | "entry" | "child", "contributors") - // An artist rendered as a browsing child keeps the record's shape, - // so its roles stay an array there too — otherwise the field - // collapses into a bare object the moment a directory carries it. - | ("artist" | "child", "roles") - ) -} - -fn empty_success_method(method: &str) -> bool { - matches!( - method, - "updatePlaylist" - | "deletePlaylist" - | "star" - | "unstar" - | "setRating" - | "scrobble" - | "savePlayQueue" - | "createBookmark" - | "deleteBookmark" - | "deleteShare" - | "createUser" - | "updateUser" - | "deleteUser" - | "changePassword" - ) -} - -fn decode_credential_password(value: &str) -> Result { - let bytes = match value.strip_prefix("enc:") { - Some(encoded) => hex::decode(encoded).map_err(|_| invalid("Invalid password encoding"))?, - None => value.as_bytes().to_vec(), - }; - String::from_utf8(bytes).map_err(|_| invalid("Invalid password encoding")) -} - -fn node_xml(node: &Node) -> String { - let mut output = String::new(); - write_xml(node, &mut output); - output -} - -fn write_xml(node: &Node, output: &mut String) { - output.push('<'); - output.push_str(&node.name); - for (key, value) in &node.attrs { - output.push(' '); - output.push_str(key); - output.push_str("=\""); - output.push_str(&xml_escape(&value_string(value))); - output.push('"'); - } - if node.children.is_empty() && node.text.is_none() { - output.push_str("/>"); - return; - } - output.push('>'); - if let Some(text) = &node.text { - output.push_str(&xml_escape(text)); - } - for child in &node.children { - write_xml(child, output); - } - output.push_str("'); -} - -impl Params { - fn first(&self, key: &str) -> Option<&str> { - self.0 - .iter() - .find(|(name, _)| name == key) - .map(|(_, value)| value.as_str()) - } - fn all(&self, key: &str) -> Vec<&str> { - self.0 - .iter() - .filter(|(name, _)| name == key) - .map(|(_, value)| value.as_str()) - .collect() - } - fn uuid(&self, key: &str) -> Result { - self.first(key) - .ok_or_else(missing)? - .parse() - .map_err(|_| invalid("Invalid UUID")) - } - fn uuid_optional(&self, key: &str) -> Result, ProtocolError> { - self.first(key) - .map(|value| value.parse().map_err(|_| invalid("Invalid UUID"))) - .transpose() - } - fn uuids(&self, key: &str) -> Result, ProtocolError> { - self.all(key) - .into_iter() - .map(|value| value.parse().map_err(|_| invalid("Invalid UUID"))) - .collect() - } - fn usizes(&self, key: &str) -> Result, ProtocolError> { - self.all(key) - .into_iter() - .map(|value| value.parse().map_err(|_| invalid("Invalid number"))) - .collect() - } - fn i64(&self, key: &str) -> Result { - self.first(key) - .ok_or_else(missing)? - .parse() - .map_err(|_| invalid("Invalid number")) - } - fn i64_optional(&self, key: &str) -> Result, ProtocolError> { - self.first(key) - .map(|value| value.parse().map_err(|_| invalid("Invalid number"))) - .transpose() - } - fn i64_or(&self, key: &str, default: i64) -> Result { - self.first(key) - .map(|value| value.parse().map_err(|_| invalid("Invalid number"))) - .transpose() - .map(|value| value.unwrap_or(default)) - } - fn usize_or(&self, key: &str, default: usize, max: usize) -> Result { - let value = self - .first(key) - .map(|value| value.parse().map_err(|_| invalid("Invalid number"))) - .transpose()? - .unwrap_or(default); - Ok(value.min(max)) - } - fn bool_optional(&self, key: &str) -> Result, ProtocolError> { - self.first(key) - .map(|value| match value { - "true" | "1" => Ok(true), - "false" | "0" => Ok(false), - _ => Err(invalid("Invalid boolean")), - }) - .transpose() - } -} - -fn parse_pairs(raw: &str) -> Result { - serde_urlencoded::from_str::>(raw) - .map(Params) - .map_err(|_| invalid("Invalid parameters")) -} - -fn content_type(suffix: &str) -> &'static str { - match suffix { - "mp3" => "audio/mpeg", - "flac" => "audio/flac", - "wav" => "audio/wav", - "ogg" | "opus" => "audio/ogg", - "m4a" | "mp4" => "audio/mp4", - "aac" => "audio/aac", - "dsf" | "dff" => "audio/dsd", - _ => "application/octet-stream", - } -} -fn iso_time(millis: i64) -> String { - chrono::DateTime::from_timestamp_millis(millis) - .unwrap_or_default() - .to_rfc3339() -} - -fn parse_time(value: &str) -> Result { - if let Ok(millis) = value.parse::() { - return Ok(millis); - } - chrono::DateTime::parse_from_rfc3339(value) - .map(|value| value.timestamp_millis()) - .map_err(|_| invalid("Invalid date")) -} -fn value_string(value: &Value) -> String { - match value { - Value::String(value) => value.clone(), - Value::Bool(value) => value.to_string(), - Value::Number(value) => value.to_string(), - _ => value.to_string(), - } -} -fn xml_escape(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} -fn auth_error() -> ProtocolError { - ProtocolError { - code: 40, - message: "Wrong username or password", - } -} -fn missing() -> ProtocolError { - ProtocolError { - code: 10, - message: "Required parameter is missing", - } -} -fn invalid(message: &'static str) -> ProtocolError { - ProtocolError { code: 10, message } -} -fn not_found() -> ProtocolError { - ProtocolError { - code: 70, - message: "The requested data was not found", - } -} -fn internal(error: impl std::fmt::Display) -> ProtocolError { - tracing::error!(error = %error, "Subsonic service failure"); - ProtocolError { - code: 0, - message: "Internal server error", - } -} -fn service_protocol(error: ServiceError) -> ProtocolError { - match error { - ServiceError::NotFound => not_found(), - ServiceError::Forbidden => ProtocolError { - code: 50, - message: "User is not authorized for the given operation", - }, - ServiceError::Invalid => invalid("Invalid parameters"), - ServiceError::Conflict => ProtocolError { - code: 0, - message: "Conflict", - }, - other => internal(other), - } -} - -/// What a rendered node is, told from the fields it carries rather than from -/// its element name — which `getMusicDirectory` collapses to `child` for all -/// three. -enum EntryKind { - Artist, - Album, - Song, -} diff --git a/src/subsonic/admin.rs b/src/subsonic/admin.rs new file mode 100644 index 0000000..6e6113a --- /dev/null +++ b/src/subsonic/admin.rs @@ -0,0 +1,145 @@ +//! User administration and scan control. +//! +//! Split out of `subsonic.rs`; the wire contract is frozen, so this moved nothing. + +use super::*; + +pub(super) async fn admin( + state: &AppState, + principal: &Principal, + method: &str, + params: &Params, +) -> Result { + if principal.role != AccountRole::Admin { + return Err(ProtocolError { + code: 50, + message: "User is not authorized for the given operation", + }); + } + match method { + "getUser" => { + let username = params.first("username").unwrap_or(&principal.username); + let user = state + .services + .users(principal.id) + .await + .map_err(service_protocol)? + .into_iter() + .find(|user| user.username.eq_ignore_ascii_case(username)) + .ok_or_else(not_found)?; + Ok(user_node(&user)) + } + "getUsers" => Ok(Node::new("users").children( + state + .services + .users(principal.id) + .await + .map_err(service_protocol)? + .iter() + .map(user_node), + )), + "createUser" => { + let password = + decode_credential_password(params.first("password").ok_or_else(missing)?)?; + let folders = params.uuids("musicFolderId")?; + let folders = params.first("musicFolderId").is_some().then_some(folders); + let user = state + .services + .create_subsonic_user( + principal.id, + params.first("username").ok_or_else(missing)?, + &password, + params.bool_optional("adminRole")?.unwrap_or(false), + folders.as_deref(), + ) + .await + .map_err(service_protocol)?; + Ok(user_node(&user)) + } + "updateUser" => { + let folders = params.uuids("musicFolderId")?; + let folders = params.first("musicFolderId").is_some().then_some(folders); + let password = params + .first("password") + .map(decode_credential_password) + .transpose()?; + let user = state + .services + .update_user( + principal.id, + params.first("username").ok_or_else(missing)?, + crate::services::UserUpdate { + admin: params.bool_optional("adminRole")?, + disabled: params.bool_optional("locked")?, + folder_ids: folders.as_deref(), + subsonic_password: password.as_deref(), + web_password: None, + }, + ) + .await + .map_err(service_protocol)?; + Ok(user_node(&user)) + } + "deleteUser" => { + state + .services + .delete_user(principal.id, params.first("username").ok_or_else(missing)?) + .await + .map_err(service_protocol)?; + Ok(Node::new("deleteUser")) + } + "changePassword" => { + let password = + decode_credential_password(params.first("password").ok_or_else(missing)?)?; + state + .services + .change_subsonic_password( + principal.id, + params.first("username").ok_or_else(missing)?, + &password, + ) + .await + .map_err(service_protocol)?; + Ok(Node::new("changePassword")) + } + _ => unreachable!("admin method dispatch is exhaustive"), + } +} + +/// Rescans every library the account can reach. +/// +/// Subsonic has no library parameter here, so this fans out. Both the +/// authorization and the queuing live in +/// [`crate::services::DomainServices::start_visible_scans`], so this facade +/// and the native per-library endpoint cannot disagree about who may scan +/// what. +pub(super) async fn start_scan( + state: &AppState, + principal: &Principal, +) -> Result { + state + .services + .start_visible_scans(principal.id) + .await + .map_err(service_protocol)?; + // The protocol answers a start with the resulting status, so a client + // that only calls startScan still learns whether anything is running. + scan_status(state, principal).await +} + +/// `count` is the number of available tracks *this* account can reach, not +/// what the instance holds: the rest of the facade never reports a total that +/// includes another tenant's catalogue, and this is no exception. +pub(super) async fn scan_status( + state: &AppState, + principal: &Principal, +) -> Result { + let (scanning, count) = state + .db + .scan_progress_for_user(principal.id) + .await + .map_err(internal)?; + Ok(Node::new("scanStatus") + .attr("scanning", scanning) + .attr("count", count)) +} diff --git a/src/subsonic/auth.rs b/src/subsonic/auth.rs new file mode 100644 index 0000000..dba4b79 --- /dev/null +++ b/src/subsonic/auth.rs @@ -0,0 +1,135 @@ +//! Authentication, and the rate limit on failed attempts. +//! +//! Split out of `subsonic.rs`; the wire contract is frozen, so this moved nothing. + +use super::*; + +pub(super) async fn authenticate( + state: &AppState, + params: &Params, +) -> Result { + let rate_key = params + .first("apiKey") + .or_else(|| params.first("u")) + .unwrap_or("missing"); + let rate_hash = hex::encode(security::token_hash(rate_key)); + if auth_rate_limited(&rate_hash) { + return Err(ProtocolError { + code: 40, + message: "Wrong username or password", + }); + } + + let credential = if let Some(api_key) = params.first("apiKey") { + state + .services + .credential_by_api_key(api_key) + .await + .map_err(internal)? + .ok_or_else(|| { + record_auth_failure(&rate_hash); + auth_error() + })? + } else { + let username = params.first("u").ok_or_else(missing)?; + state + .services + .credential_by_username(username) + .await + .map_err(internal)? + .ok_or_else(|| { + record_auth_failure(&rate_hash); + auth_error() + })? + }; + let password = state + .services + .decrypt_subsonic_password(&credential) + .map_err(internal)?; + if params.first("apiKey").is_none() { + let valid = if let (Some(token), Some(salt)) = (params.first("t"), params.first("s")) { + let mut digest = Md5::new(); + digest.update(&password); + digest.update(salt.as_bytes()); + let expected = hex::encode(digest.finalize()); + security::constant_time_bytes_eq(token.as_bytes(), expected.as_bytes()) + } else if let Some(provided) = params.first("p") { + let decoded = match provided.strip_prefix("enc:").map(hex::decode).transpose() { + Ok(value) => value.unwrap_or_else(|| provided.as_bytes().to_vec()), + Err(_) => { + record_auth_failure(&rate_hash); + return Err(auth_error()); + } + }; + security::constant_time_bytes_eq(&decoded, &password) + } else { + false + }; + if !valid { + record_auth_failure(&rate_hash); + return Err(auth_error()); + } + } + clear_auth_failures(&rate_hash); + Ok(Principal { + id: credential.account.id, + username: credential.account.username, + role: credential.account.role, + }) +} + +pub(super) fn auth_rate_limited(key: &str) -> bool { + let now = Instant::now(); + let windows = AUTH_WINDOWS.get_or_init(|| StdMutex::new(HashMap::new())); + let Ok(mut windows) = windows.lock() else { + return false; + }; + prune_auth_windows(&mut windows, now); + let attempts = windows.entry(key.to_owned()).or_default(); + attempts.len() >= AUTH_ATTEMPTS_PER_MINUTE +} + +pub(super) fn record_auth_failure(key: &str) { + let windows = AUTH_WINDOWS.get_or_init(|| StdMutex::new(HashMap::new())); + if let Ok(mut windows) = windows.lock() { + let now = Instant::now(); + prune_auth_windows(&mut windows, now); + if !windows.contains_key(key) && windows.len() >= MAX_AUTH_RATE_KEYS { + if let Some(oldest) = windows + .iter() + .min_by_key(|(_, attempts)| attempts.back().copied()) + .map(|(key, _)| key.clone()) + { + windows.remove(&oldest); + } + } + windows.entry(key.to_owned()).or_default().push_back(now); + } +} + +pub(super) fn prune_auth_windows(windows: &mut HashMap>, now: Instant) { + windows.retain(|_, attempts| { + while attempts + .front() + .is_some_and(|time| now.duration_since(*time) >= Duration::from_secs(60)) + { + attempts.pop_front(); + } + !attempts.is_empty() + }); +} + +pub(super) fn clear_auth_failures(key: &str) { + let windows = AUTH_WINDOWS.get_or_init(|| StdMutex::new(HashMap::new())); + if let Ok(mut windows) = windows.lock() { + windows.remove(key); + } +} + +pub(super) fn decode_credential_password(value: &str) -> Result { + let bytes = match value.strip_prefix("enc:") { + Some(encoded) => hex::decode(encoded).map_err(|_| invalid("Invalid password encoding"))?, + None => value.as_bytes().to_vec(), + }; + String::from_utf8(bytes).map_err(|_| invalid("Invalid password encoding")) +} diff --git a/src/subsonic/browse.rs b/src/subsonic/browse.rs new file mode 100644 index 0000000..57f398c --- /dev/null +++ b/src/subsonic/browse.rs @@ -0,0 +1,435 @@ +//! Browsing, listing and searching the catalogue. +//! +//! Split out of `subsonic.rs`; the wire contract is frozen, so this moved nothing. + +use super::*; + +/// Folders, artists and albums for the requested libraries. +/// +/// Preferred over [`crate::services::DomainServices::catalog_snapshot`] +/// wherever the answer does not contain tracks: the track read is the +/// expensive third of a snapshot, and since the OpenSubsonic fields landed it +/// carries two relation loads of its own. +pub(super) async fn overview( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let folders = params.uuids("musicFolderId")?; + state + .services + .catalog_overview(principal.id, &folders) + .await + .map_err(internal) +} + +pub(super) async fn indexes( + state: &AppState, + principal: &Principal, + params: &Params, + id3: bool, +) -> Result { + let overview = overview(state, principal, params).await?; + let mut groups: BTreeMap> = BTreeMap::new(); + for artist in overview.artists { + let initial = artist + .artist + .name + .chars() + .next() + .filter(char::is_ascii_alphabetic) + .map(|value| value.to_ascii_uppercase()) + .unwrap_or('#'); + groups.entry(initial).or_default().push(artist); + } + let root_name = if id3 { "artists" } else { "indexes" }; + Ok(Node::new(root_name) + .attr("ignoredArticles", "The El La Les Le L'") + .attr("lastModified", chrono::Utc::now().timestamp_millis()) + .children(groups.into_iter().map(|(letter, artists)| { + Node::new("index") + .attr("name", letter.to_string()) + .children(artists.into_iter().map(|artist| { + // The count comes from the projection now. Filtering every + // album for every artist was a loop the facade had no + // business running, and it could only ever see the album's + // first credit. + artist_node(&artist.artist, artist.album_count as usize) + })) + }))) +} + +pub(super) async fn get_artist( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let detail = state + .services + .artist(principal.id, params.uuid("id")?) + .await + .map_err(service_protocol)?; + // `album_count` comes from the projection rather than from the length of + // the list below. They agree today only because `albums` is unpaginated, + // which is an unwritten guarantee the response should not rest on. + Ok(artist_node(&detail.artist, detail.album_count as usize) + .children(detail.albums.iter().map(album_node))) +} + +pub(super) async fn artist_info( + state: &AppState, + principal: &Principal, + params: &Params, + container: &'static str, +) -> Result { + state + .services + .artist(principal.id, params.uuid("id")?) + .await + .map_err(service_protocol)?; + Ok(Node::new(container)) +} + +/// `getAlbumInfo` and `getAlbumInfo2`. +/// +/// WaveFlow queries no remote source, so notes and biography images stay +/// absent. The release identifier is the one part of the answer the catalogue +/// actually holds, and it is emitted when the album has one. `AlbumInfo` +/// predates the OpenSubsonic presence rule, so an album without a release id +/// omits the element rather than sending it empty. +/// +/// The lookup runs first and for its refusal: it is what turns an album the +/// caller cannot reach into the same answer as one that does not exist. +pub(super) async fn album_info( + state: &AppState, + principal: &Principal, + params: &Params, + container: &'static str, +) -> Result { + let album = state + .services + .album(principal.id, params.uuid("id")?) + .await + .map_err(service_protocol)?; + Ok(Node::new(container).children( + album + .album + .musicbrainz_id + .map(|id| Node::new("musicBrainzId").text(id)), + )) +} + +pub(super) async fn get_album( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let detail = state + .services + .album(principal.id, params.uuid("id")?) + .await + .map_err(service_protocol)?; + Ok(album_node(&detail.album).children(detail.songs.iter().map(song_node))) +} + +pub(super) async fn get_song( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let id = params.uuid("id")?; + let song = state + .services + .songs_by_ids(principal.id, &[id]) + .await + .map_err(service_protocol)? + .into_iter() + .next() + .ok_or_else(not_found)?; + Ok(song_node(&song)) +} + +/// Parameter adapter over [`crate::services::DomainServices::list_genres`]. +pub(super) async fn genres( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let genres = state + .services + .list_genres(principal.id, ¶ms.uuids("musicFolderId")?) + .await + .map_err(internal)?; + Ok( + Node::new("genres").children(genres.into_iter().map(|genre| { + Node::new("genre") + .attr("songCount", genre.song_count) + .attr("albumCount", genre.album_count) + .text(genre.name) + })), + ) +} + +/// Renders an artist or album as a browsing entry of `getMusicDirectory`. +/// +/// `musicBrainzId` is dropped on the way. On a `Child` the specification +/// defines it as the *recording* identifier, and a folder standing for an +/// artist or a release has no recording: carrying the release or artist id +/// under that name would be a different identifier wearing the same label. +/// The `album` and `artist` responses keep it, where it means what it says. +pub(super) fn directory_child(node: Node) -> Node { + node.renamed("child") + .attr("isDir", true) + .without("musicBrainzId") +} + +pub(super) async fn music_directory( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let id = params.uuid("id")?; + let overview = overview(state, principal, params).await?; + let mut directory = Node::new("directory").attr("id", id.to_string()); + if let Some(folder) = overview.folders.iter().find(|item| item.id == id) { + // The artists of the library, then the tracks that belong to no album. + // Those name this folder as their `parent` for want of an album id, so + // this is the level that has to answer for them — otherwise a track + // advertises a parent that does not contain it. + let orphans = state + .services + .songs_without_album(principal.id, id, MAX_DIRECTORY_SONGS) + .await + .map_err(service_protocol)?; + if orphans.len() as i64 == MAX_DIRECTORY_SONGS { + tracing::warn!( + library_id = %id, + limit = MAX_DIRECTORY_SONGS, + "album-less tracks reached the folder ceiling; the listing may be short" + ); + } + directory = directory + .attr("name", folder.name.clone()) + .children( + overview + .artists + .iter() + .filter(|artist| artist.artist.library_id == id) + .map(|artist| { + directory_child(artist_node(&artist.artist, artist.album_count as usize)) + }), + ) + .children(orphans.iter().map(|song| song_node(song).renamed("child"))); + } else if let Some(credited) = match state.services.artist(principal.id, id).await { + Ok(credited) => Some(credited), + // Only an absence justifies trying the next branch. A database + // failure has to say so rather than turn into a not-found, which is + // the answer this method gives an identifier that does not exist. + Err(ServiceError::NotFound) => None, + Err(error) => return Err(service_protocol(error)), + } { + // The albums this artist is credited to, by the same rule `getArtist` + // uses — and resolved the same way, rather than from the overview. + // The overview lists only artists an album is credited to, so looking + // the identifier up there would answer 404 for a composer that + // `getArtist` answers for. Tenancy is unchanged: the service blurs a + // foreign identifier into the same not-found this arm falls through to. + directory = directory + .attr("name", credited.artist.name.clone()) + .children( + credited + .albums + .iter() + .map(|album| directory_child(album_node(album))), + ); + } else if overview.albums.iter().any(|item| item.id == id) { + // Only this level needs tracks, and only this album's. + let detail = state + .services + .album(principal.id, id) + .await + .map_err(service_protocol)?; + directory = directory.attr("name", detail.album.title.clone()).children( + detail + .songs + .iter() + .map(|song| song_node(song).renamed("child")), + ); + } else { + return Err(not_found()); + } + Ok(directory) +} + +/// Parameter adapter over [`crate::services::DomainServices::list_albums`]. +/// +/// The ten ordering modes used to live here, sorted in Rust over a full +/// `catalog_snapshot`. They now resolve in SQL, so this maps Subsonic spelling +/// onto the shared query and does nothing else — which is what M4 asks of a +/// facade, and it stops one album page from reading the tenant's whole +/// catalogue. +pub(super) async fn album_list( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let order = params + .first("type") + .unwrap_or("alphabeticalByName") + .parse::() + .map_err(|_| invalid("Invalid album list type"))?; + let offset = params.usize_or("offset", 0, 100_000)?; + let size = params.usize_or("size", 10, 500)?; + // A page of nothing is a valid Subsonic request and used to answer with an + // empty container. `BrowsePage` rejects a zero limit, so the short-circuit + // keeps that shape rather than turning it into error code 10. + if size == 0 { + return Ok(Node::new("albumList2")); + } + let query = AlbumListQuery { + library_ids: params.uuids("musicFolderId")?, + order, + genre: params.first("genre").map(str::to_owned), + from_year: params.i64_optional("fromYear")?, + to_year: params.i64_optional("toYear")?, + page: BrowsePage::new(Some(offset as i64), Some(size as i64)).map_err(service_protocol)?, + }; + let albums = state + .services + .list_albums(principal.id, &query) + .await + .map_err(service_protocol)?; + Ok(Node::new("albumList2").children(albums.iter().map(album_node))) +} + +pub(super) async fn random_songs( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let size = params.usize_or("size", 10, 500)?; + // A page of nothing is a valid request, as it is for getAlbumList. + if size == 0 { + return Ok(Node::new("randomSongs")); + } + let songs = state + .services + .random_songs( + principal.id, + ¶ms.uuids("musicFolderId")?, + params.first("genre"), + params.i64_optional("fromYear")?, + params.i64_optional("toYear")?, + size as i64, + ) + .await + .map_err(service_protocol)?; + Ok(Node::new("randomSongs").children(songs.iter().map(song_node))) +} + +pub(super) async fn songs_by_genre( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let genre = params.first("genre").ok_or_else(missing)?; + let offset = params.usize_or("offset", 0, 100_000)?; + let count = params.usize_or("count", 10, 500)?; + if count == 0 { + return Ok(Node::new("songsByGenre")); + } + let songs = state + .services + .songs_by_genre( + principal.id, + ¶ms.uuids("musicFolderId")?, + genre, + BrowsePage::new(Some(offset as i64), Some(count as i64)).map_err(service_protocol)?, + ) + .await + .map_err(service_protocol)?; + Ok(Node::new("songsByGenre").children(songs.iter().map(song_node))) +} + +pub(super) async fn search( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let raw_query = params.first("query").ok_or_else(missing)?; + let artist_count = params.usize_or("artistCount", 20, 500)?; + let artist_offset = params.usize_or("artistOffset", 0, 100_000)?; + let album_count = params.usize_or("albumCount", 20, 500)?; + let album_offset = params.usize_or("albumOffset", 0, 100_000)?; + let song_count = params.usize_or("songCount", 20, 500)?; + let song_offset = params.usize_or("songOffset", 0, 100_000)?; + + let folders = params.uuids("musicFolderId")?; + + // Subsonic clients send the literal pair of quotes as the documented + // match-all query while paging through a complete catalogue. There is + // nothing to match and FTS5 has no expression meaning "everything", so + // this is three ordinary listings wearing the search response — paged in + // SQL rather than sliced out of a full catalogue read, which is what made + // a client's initial synchronization quadratic in the library. + if raw_query == "\"\"" { + let page = |offset: usize, count: usize| { + BrowsePage::new(Some(offset as i64), Some(count as i64)).map_err(service_protocol) + }; + let found = state + .services + .browse_all( + principal.id, + &folders, + page(artist_offset, artist_count.max(1))?, + page(album_offset, album_count.max(1))?, + page(song_offset, song_count.max(1))?, + ) + .await + .map_err(service_protocol)?; + // The service already applied the offsets, so the renderer must not. + return Ok(search_result( + found.artists.iter().take(artist_count), + found.albums.iter().take(album_count), + found.songs.iter().take(song_count), + (0, artist_count), + (0, album_count), + (0, song_count), + )); + } + + let found = state + .services + .catalog_search(principal.id, &folders, raw_query) + .await + .map_err(internal)?; + Ok(search_result( + found.artists.iter(), + found.albums.iter(), + found.songs.iter(), + (artist_offset, artist_count), + (album_offset, album_count), + (song_offset, song_count), + )) +} + +/// Renders a `searchResult3` from already-selected entities. +#[allow(clippy::too_many_arguments)] +pub(super) fn search_result<'a>( + artists: impl Iterator, + albums: impl Iterator, + songs: impl Iterator, + (artist_offset, artist_count): (usize, usize), + (album_offset, album_count): (usize, usize), + (song_offset, song_count): (usize, usize), +) -> Node { + Node::new("searchResult3") + .children( + artists + .skip(artist_offset) + .take(artist_count) + .map(|artist| artist_node(artist, 0)), + ) + .children(albums.skip(album_offset).take(album_count).map(album_node)) + .children(songs.skip(song_offset).take(song_count).map(song_node)) +} diff --git a/src/subsonic/errors.rs b/src/subsonic/errors.rs new file mode 100644 index 0000000..2d17c5b --- /dev/null +++ b/src/subsonic/errors.rs @@ -0,0 +1,54 @@ +//! The protocol error constructors. +//! +//! Split out of `subsonic.rs`; the wire contract is frozen, so this moved nothing. + +use super::*; + +pub(super) fn auth_error() -> ProtocolError { + ProtocolError { + code: 40, + message: "Wrong username or password", + } +} + +pub(super) fn missing() -> ProtocolError { + ProtocolError { + code: 10, + message: "Required parameter is missing", + } +} + +pub(super) fn invalid(message: &'static str) -> ProtocolError { + ProtocolError { code: 10, message } +} + +pub(super) fn not_found() -> ProtocolError { + ProtocolError { + code: 70, + message: "The requested data was not found", + } +} + +pub(super) fn internal(error: impl std::fmt::Display) -> ProtocolError { + tracing::error!(error = %error, "Subsonic service failure"); + ProtocolError { + code: 0, + message: "Internal server error", + } +} + +pub(super) fn service_protocol(error: ServiceError) -> ProtocolError { + match error { + ServiceError::NotFound => not_found(), + ServiceError::Forbidden => ProtocolError { + code: 50, + message: "User is not authorized for the given operation", + }, + ServiceError::Invalid => invalid("Invalid parameters"), + ServiceError::Conflict => ProtocolError { + code: 0, + message: "Conflict", + }, + other => internal(other), + } +} diff --git a/src/subsonic/lyrics.rs b/src/subsonic/lyrics.rs new file mode 100644 index 0000000..98c5cf8 --- /dev/null +++ b/src/subsonic/lyrics.rs @@ -0,0 +1,71 @@ +//! The two lyrics methods. +//! +//! Split out of `subsonic.rs`; the wire contract is frozen, so this moved nothing. + +use super::*; + +pub(super) async fn get_lyrics( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let artist = params.first("artist"); + let title = params.first("title"); + // Both criteria absent leaves the lookup unfiltered, and it answers with + // whichever track sorts first among those that happen to carry lyrics. + // Nothing was asked for, so nothing is returned. + if artist.is_none() && title.is_none() { + return Ok(Node::new("lyrics")); + } + let Some(lyrics) = state + .services + .lyrics_by_metadata(principal.id, artist, title) + .await + .map_err(service_protocol)? + else { + return Ok(Node::new("lyrics") + .maybe_attr("artist", artist.map(str::to_owned)) + .maybe_attr("title", title.map(str::to_owned))); + }; + let Some(first) = lyrics.structured_lyrics.first() else { + return Ok(Node::new("lyrics")); + }; + Ok(Node::new("lyrics") + .maybe_attr("artist", first.display_artist.clone()) + .attr("title", first.display_title.clone()) + .text( + first + .lines + .iter() + .map(|line| line.value.as_str()) + .collect::>() + .join("\n"), + )) +} + +pub(super) async fn get_lyrics_by_song_id( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let lyrics = state + .services + .lyrics(principal.id, params.uuid("id")?) + .await + .map_err(service_protocol)?; + Ok(Node::new("lyricsList") + .children(lyrics.structured_lyrics.iter().map(structured_lyrics_node))) +} + +pub(super) fn structured_lyrics_node(lyrics: &crate::lyrics::StructuredLyrics) -> Node { + Node::new("structuredLyrics") + .maybe_attr("displayArtist", lyrics.display_artist.clone()) + .attr("displayTitle", lyrics.display_title.clone()) + .attr("lang", lyrics.lang.clone()) + .attr("synced", lyrics.synced) + .children(lyrics.lines.iter().map(|line| { + Node::new("line") + .maybe_attr("start", line.start) + .text(line.value.clone()) + })) +} diff --git a/src/subsonic/media.rs b/src/subsonic/media.rs new file mode 100644 index 0000000..1941e39 --- /dev/null +++ b/src/subsonic/media.rs @@ -0,0 +1,110 @@ +//! Streaming, downloading and cover art. +//! +//! Split out of `subsonic.rs`; the wire contract is frozen, so this moved nothing. + +use super::*; + +pub(super) async fn media_response( + state: &AppState, + principal: &Principal, + params: &Params, + download: bool, + range: Option<&str>, +) -> Result { + let id = params.uuid("id")?; + let track = state + .db + .stream_track_for_user(principal.id, id) + .await + .map_err(internal)? + .ok_or_else(not_found)?; + let requested_bitrate = params + .first("maxBitRate") + .map(|value| value.parse::()) + .transpose() + .map_err(|_| invalid("Invalid bitrate"))? + .filter(|bitrate| *bitrate > 0); + let format = if download { + OutputFormat::Raw + } else if let Some(format) = params.first("format") { + match format { + "raw" => OutputFormat::Raw, + "mp3" => OutputFormat::Mp3, + "opus" | "ogg" => OutputFormat::Opus, + _ => return Err(invalid("Unsupported format")), + } + } else if requested_bitrate.is_some_and(|limit| { + track + .bitrate + .and_then(|bitrate| u32::try_from(bitrate).ok()) + .is_none_or(|source| source > limit) + }) { + // Legacy Subsonic clients such as DSub always send maxBitRate, even + // when it matches the source. Downsample only when the cap is lower; + // otherwise preserve direct playback just like Navidrome. + OutputFormat::Mp3 + } else { + OutputFormat::Raw + }; + let query = StreamQuery { + format, + bitrate: (format != OutputFormat::Raw) + .then_some(requested_bitrate) + .flatten(), + offset_ms: params + .first("timeOffset") + .map(|value| { + value + .parse::() + .ok() + .and_then(|seconds| seconds.checked_mul(1000)) + .ok_or(()) + }) + .transpose() + .map_err(|_| invalid("Invalid time offset"))? + .unwrap_or(0), + }; + match state.media.serve(principal.id, track, query, range).await { + Ok(mut response) => { + if download { + response.headers_mut().insert( + header::CONTENT_DISPOSITION, + "attachment".parse().expect("static header value"), + ); + } + Ok(response) + } + Err(MediaError::NotFound | MediaError::Unauthorized) => Err(not_found()), + Err(MediaError::InvalidRequest) => Err(invalid("Invalid media parameters")), + Err(error @ (MediaError::RangeNotSatisfiable(_) | MediaError::Busy)) => { + Ok(error.into_response()) + } + Err(MediaError::Internal) => Err(internal("media service failed")), + } +} + +pub(super) async fn cover_art_response( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let id = params.first("id").ok_or_else(missing)?; + let (hash, format) = state + .services + .artwork_for_user(principal.id, id) + .await + .map_err(internal)? + .ok_or_else(not_found)?; + let (mime, bytes) = crate::media::read_artwork(&state.artwork_dir, &hash, &format) + .await + .ok_or_else(not_found)?; + Ok(( + StatusCode::OK, + [ + (header::CONTENT_TYPE, mime), + (header::CACHE_CONTROL, "private, max-age=86400"), + ], + bytes, + ) + .into_response()) +} diff --git a/src/subsonic/mod.rs b/src/subsonic/mod.rs new file mode 100644 index 0000000..0882abc --- /dev/null +++ b/src/subsonic/mod.rs @@ -0,0 +1,571 @@ +//! Subsonic/OpenSubsonic compatibility façade. + +use std::{ + collections::{BTreeMap, HashMap, VecDeque}, + sync::{Mutex as StdMutex, OnceLock}, + time::{Duration, Instant}, +}; + +use axum::{ + body::to_bytes, + extract::{Path, Query, Request, State}, + http::{header, HeaderMap, Method, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use md5::{Digest, Md5}; +use serde_json::{Map, Value}; +use uuid::Uuid; + +use crate::{ + database::AccountRole, + media::{MediaError, OutputFormat, StreamQuery}, + security, + services::{ + AlbumItem, AlbumListQuery, AlbumOrder, ArtistItem, ArtistSummary, BrowsePage, PlaylistItem, + ServiceError, SongItem, + }, + AppState, +}; + +mod admin; +mod auth; +mod browse; +mod errors; +mod lyrics; +mod media; +mod nodes; +mod playlists; +mod protocol; +mod shares; +mod userdata; + +use admin::*; +use auth::*; +use browse::*; +use errors::*; +use lyrics::*; +use media::*; +use nodes::*; +use playlists::*; +use protocol::*; +use shares::*; +use userdata::*; + +const SUBSONIC_VERSION: &str = "1.16.1"; + +const XMLNS: &str = "http://subsonic.org/restapi"; + +const MAX_FORM_BYTES: usize = 64 * 1024; + +const AUTH_ATTEMPTS_PER_MINUTE: usize = 20; + +const MAX_AUTH_RATE_KEYS: usize = 10_000; + +/// How many album-less tracks a folder listing will carry. +/// +/// `getMusicDirectory` takes no offset, so a folder cannot be paged and the +/// only bound available is a ceiling. It sits far above `MAX_BROWSE_LIMIT` +/// because reaching it costs a client the tracks beyond it — the browse limit +/// governs a listing the client can ask more of, this one governs a listing it +/// cannot. A folder that reaches it is logged. +const MAX_DIRECTORY_SONGS: i64 = 2_000; + +static AUTH_WINDOWS: OnceLock>>> = OnceLock::new(); + +#[derive(Debug, Clone)] +struct Principal { + id: Uuid, + username: String, + role: AccountRole, +} + +#[derive(Debug, Default)] +struct Params(Vec<(String, String)>); + +#[derive(Debug, Clone)] +struct Node { + name: String, + attrs: BTreeMap, + children: Vec, + text: Option, +} + +impl Node { + fn new(name: impl Into) -> Self { + Self { + name: name.into(), + attrs: BTreeMap::new(), + children: Vec::new(), + text: None, + } + } + + fn attr(mut self, key: impl Into, value: impl Into) -> Self { + self.attrs.insert(key.into(), value.into()); + self + } + + fn maybe_attr(mut self, key: &str, value: Option>) -> Self { + if let Some(value) = value { + self.attrs.insert(key.to_owned(), value.into()); + } + self + } + + fn child(mut self, child: Node) -> Self { + self.children.push(child); + self + } + + fn text(mut self, text: impl Into) -> Self { + self.text = Some(text.into()); + self + } + + fn renamed(mut self, name: &'static str) -> Self { + self.name = name.to_owned(); + self + } + + fn without(mut self, key: &str) -> Self { + self.attrs.remove(key); + self + } + + fn children(mut self, children: impl IntoIterator) -> Self { + self.children.extend(children); + self + } +} + +/// A Subsonic protocol failure. +/// +/// The transport status is deliberately not carried here. OpenSubsonic answers +/// every request it could parse with HTTP 200 and reports the failure in the +/// body, so a client reading `error/code` sees the same outcome whatever the +/// transport did. Answering 401 or 404 instead let proxies and HTTP-level +/// client error handling discard the body before the Subsonic layer read it. +#[derive(Debug)] +struct ProtocolError { + code: i64, + message: &'static str, +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/rest/{method}", get(handle).post(handle)) + .route( + "/share/{token}/tracks/{track_id}/stream", + get(public_share_stream), + ) + .route("/share/{token}", get(public_share)) + .with_state(state) +} + +async fn public_share(State(state): State, Path(token): Path) -> Response { + match state.services.public_share(&token).await { + Ok(share) => { + let tracks = share + .songs + .iter() + .map(|song| { + let mut value = serde_json::to_value(song).expect("song serialization"); + if let Value::Object(object) = &mut value { + object.insert( + "streamUrl".into(), + Value::String(external_url( + state.public_url.as_deref(), + &format!("/share/{token}/tracks/{}/stream", song.id), + )), + ); + } + value + }) + .collect::>(); + ( + [(header::CACHE_CONTROL, "no-store")], + axum::Json(serde_json::json!({ + "id": share.id, + "description": share.description, + "expiresAt": share.expires_at, + "visitCount": share.visit_count, + "tracks": tracks, + })), + ) + .into_response() + } + Err(ServiceError::NotFound) => StatusCode::NOT_FOUND.into_response(), + Err(error) => { + tracing::error!(error = %error, "public share lookup failed"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +async fn public_share_stream( + State(state): State, + Path((token, track_id)): Path<(String, Uuid)>, + Query(query): Query, + headers: HeaderMap, +) -> Response { + let share = match state.services.public_share(&token).await { + Ok(share) => share, + Err(ServiceError::NotFound) => return StatusCode::NOT_FOUND.into_response(), + Err(error) => { + tracing::error!(error = %error, "public share stream lookup failed"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + if !share.songs.iter().any(|song| song.id == track_id) { + return StatusCode::NOT_FOUND.into_response(); + } + let track = match state + .db + .stream_track_for_user(share.owner_id, track_id) + .await + { + Ok(Some(track)) => track, + Ok(None) => return StatusCode::NOT_FOUND.into_response(), + Err(error) => { + tracing::error!(error = %error, "public share media lookup failed"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + let range = headers + .get(header::RANGE) + .and_then(|value| value.to_str().ok()); + match state.media.serve(share.owner_id, track, query, range).await { + Ok(response) => response, + Err(error) => error.into_response(), + } +} + +pub async fn handle( + State(state): State, + Path(raw_method): Path, + request: Request, +) -> Response { + let request_method = request.method().clone(); + let query = request.uri().query().unwrap_or_default().to_owned(); + let range = request + .headers() + .get(header::RANGE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + + // Format negotiation has to survive a failure, and under the `formPost` + // extension `f=json` arrives in the body rather than the query string. + // Collecting the parameters here, outside the fallible path, is what lets a + // POST that fails to authenticate still answer in the format it asked for + // instead of falling back to XML. + let mut wants_json = false; + let params = match parse_pairs(&query) { + Ok(mut params) => { + wants_json = json_requested(¶ms); + if request_method == Method::POST { + match form_params(request).await { + Ok(body) => { + params.0.extend(body.0); + wants_json = json_requested(¶ms); + Ok(params) + } + Err(error) => Err(error), + } + } else { + Ok(params) + } + } + Err(error) => Err(error), + }; + + let outcome = match params { + Ok(params) => { + handle_inner( + &state, + &raw_method, + &request_method, + ¶ms, + range.as_deref(), + ) + .await + } + Err(error) => Err(error), + }; + match outcome { + Ok(response) => response, + // A protocol failure is still an HTTP success: the Subsonic contract + // puts the outcome in the body, never in the status line. + Err(error) => render_protocol(error_node(error.code, error.message), wants_json), + } +} + +/// Parameters carried in a POST body, as the `formPost` extension allows in +/// place of a query string too long for a URL. +async fn form_params(request: Request) -> Result { + // A media type is case-insensitive and may carry parameters, so the type is + // compared on its own rather than as a prefix of the raw header value: + // `Application/X-WWW-Form-Urlencoded; charset=UTF-8` is a conformant way to + // say the same thing, and `application/x-www-form-urlencodedish` is not. + let media_type = request + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .split(';') + .next() + .unwrap_or_default() + .trim(); + if !media_type.eq_ignore_ascii_case("application/x-www-form-urlencoded") { + return Err(invalid("POST requires application/x-www-form-urlencoded")); + } + let body = to_bytes(request.into_body(), MAX_FORM_BYTES) + .await + .map_err(|_| invalid("Invalid form body"))?; + parse_pairs(std::str::from_utf8(&body).map_err(|_| invalid("Invalid form body"))?) +} + +fn json_requested(params: &Params) -> bool { + params.first("f").is_some_and(|value| value == "json") +} + +async fn handle_inner( + state: &AppState, + raw_method: &str, + request_method: &Method, + params: &Params, + range: Option<&str>, +) -> Result { + let method = raw_method.strip_suffix(".view").unwrap_or(raw_method); + let wants_json = json_requested(params); + if is_symfonium_discovery_probe(method, request_method, params) { + return Ok(render_protocol(ok_node(), wants_json)); + } + let principal = authenticate(state, params).await?; + + if matches!(method, "stream" | "download") { + return media_response(state, &principal, params, method == "download", range).await; + } + if method == "getCoverArt" { + return cover_art_response(state, &principal, params).await; + } + + let payload = dispatch(state, &principal, method, params).await?; + let root = if method == "ping" || empty_success_method(method) { + ok_node() + } else { + ok_node().child(payload) + }; + Ok(render_protocol(root, wants_json)) +} + +fn is_symfonium_discovery_probe(method: &str, request_method: &Method, params: &Params) -> bool { + method == "ping" + && request_method == Method::GET + && params.all("c") == ["Symfonium"] + && params.all("u") == ["test"] + && params.all("p") == ["test"] + && params.all("apiKey").is_empty() + && params.all("t").is_empty() + && params.all("s").is_empty() +} + +async fn dispatch( + state: &AppState, + principal: &Principal, + method: &str, + params: &Params, +) -> Result { + match method { + "ping" => Ok(Node::new("ping")), + "getLicense" => Ok(Node::new("license") + .attr("valid", true) + .attr("email", "") + .attr("licenseExpires", "2099-12-31T23:59:59Z")), + "getOpenSubsonicExtensions" => Ok(open_subsonic_extensions()), + // The other half of the apiKeyAuthentication extension: a client holding + // a key has no other way to learn which account it speaks for. + // Advertising the extension without serving this told clients a lie. + "tokenInfo" => Ok(Node::new("tokenInfo").attr("username", principal.username.clone())), + // Playback positions, one per account and track. Symfonium asks for + // them during its initial sync, and they are now read from and written + // to the catalogue rather than answered with an empty container. + "getBookmarks" => bookmarks(state, principal).await, + "createBookmark" => create_bookmark(state, principal, params).await, + "deleteBookmark" => delete_bookmark(state, principal, params).await, + // Recommendation and radio surfaces WaveFlow does not compute. The + // standard empty container is the honest answer and, unlike the + // not-implemented error, does not read to a client as a broken + // server on a page it opens by default. + "getTopSongs" => Ok(Node::new("topSongs")), + "getSimilarSongs" => Ok(Node::new("similarSongs")), + "getSimilarSongs2" => Ok(Node::new("similarSongs2")), + "getInternetRadioStations" => Ok(Node::new("internetRadioStations")), + // No avatars are stored, so the account genuinely has none. Code 70 + // says that; code 0 would blame the method instead of the data. + "getAvatar" => Err(not_found()), + "startScan" => start_scan(state, principal).await, + "getScanStatus" => scan_status(state, principal).await, + "getMusicFolders" => { + let folders = state + .services + .music_folders(principal.id, &[]) + .await + .map_err(internal)?; + Ok( + Node::new("musicFolders").children(folders.into_iter().map(|folder| { + Node::new("musicFolder") + .attr("id", folder.id.to_string()) + .attr("name", folder.name) + })), + ) + } + "getIndexes" => indexes(state, principal, params, false).await, + "getArtists" => indexes(state, principal, params, true).await, + "getArtist" => get_artist(state, principal, params).await, + // DSub requests artist information as soon as an artist page opens. + // WaveFlow does not enrich biographies yet, but a successful empty + // standard container avoids turning an optional capability into a + // blocking client error. The artist is still resolved tenant-side. + "getArtistInfo" => artist_info(state, principal, params, "artistInfo").await, + "getArtistInfo2" => artist_info(state, principal, params, "artistInfo2").await, + // Feishin and Symfonium call these as soon as an album page opens. As + // with getArtistInfo, WaveFlow enriches nothing yet, so the standard + // empty container is the honest answer — and it still resolves the + // album tenant-side, so a foreign id is indistinguishable from a + // missing one. + "getAlbumInfo" => album_info(state, principal, params, "albumInfo").await, + "getAlbumInfo2" => album_info(state, principal, params, "albumInfo2").await, + "getAlbum" => get_album(state, principal, params).await, + "getSong" => get_song(state, principal, params).await, + "getLyrics" => get_lyrics(state, principal, params).await, + "getLyricsBySongId" => get_lyrics_by_song_id(state, principal, params).await, + "getGenres" => genres(state, principal, params).await, + "getMusicDirectory" => music_directory(state, principal, params).await, + "getAlbumList2" => album_list(state, principal, params).await, + // Older clients such as DSub still use the pre-ID3 endpoint. The + // payload is identical for our UUID catalogue; only the container + // name differs from getAlbumList2. + "getAlbumList" => album_list(state, principal, params) + .await + .map(|node| node.renamed("albumList")), + "getRandomSongs" => random_songs(state, principal, params).await, + "getSongsByGenre" => songs_by_genre(state, principal, params).await, + "search3" => search(state, principal, params).await, + "search2" => search(state, principal, params) + .await + .map(|node| node.renamed("searchResult2")), + "getPlaylists" => playlists(state, principal).await, + "getPlaylist" => get_playlist(state, principal, params).await, + "createPlaylist" => create_playlist(state, principal, params).await, + "updatePlaylist" => update_playlist(state, principal, params).await, + "deletePlaylist" => delete_playlist(state, principal, params).await, + "star" => set_star(state, principal, params, true).await, + "unstar" => set_star(state, principal, params, false).await, + "getStarred2" => starred(state, principal, params).await, + // Browse-by-folder clients such as DSub and Ultrasonic still call the + // pre-ID3 method. Same payload for a UUID catalogue; only the + // container differs, exactly as for getAlbumList. + "getStarred" => starred(state, principal, params) + .await + .map(|node| node.renamed("starred")), + "setRating" => set_rating(state, principal, params).await, + "scrobble" => scrobble(state, principal, params).await, + "getNowPlaying" => now_playing(state, principal).await, + "getPlayQueue" => get_queue(state, principal).await, + "savePlayQueue" => save_queue(state, principal, params).await, + "getShares" | "createShare" | "updateShare" | "deleteShare" => { + shares(state, principal, method, params).await + } + "getUser" | "getUsers" | "createUser" | "updateUser" | "deleteUser" | "changePassword" => { + admin(state, principal, method, params).await + } + _ => Err(ProtocolError { + code: 0, + message: "Requested method is not implemented", + }), + } +} + +impl Params { + fn first(&self, key: &str) -> Option<&str> { + self.0 + .iter() + .find(|(name, _)| name == key) + .map(|(_, value)| value.as_str()) + } + fn all(&self, key: &str) -> Vec<&str> { + self.0 + .iter() + .filter(|(name, _)| name == key) + .map(|(_, value)| value.as_str()) + .collect() + } + fn uuid(&self, key: &str) -> Result { + self.first(key) + .ok_or_else(missing)? + .parse() + .map_err(|_| invalid("Invalid UUID")) + } + fn uuid_optional(&self, key: &str) -> Result, ProtocolError> { + self.first(key) + .map(|value| value.parse().map_err(|_| invalid("Invalid UUID"))) + .transpose() + } + fn uuids(&self, key: &str) -> Result, ProtocolError> { + self.all(key) + .into_iter() + .map(|value| value.parse().map_err(|_| invalid("Invalid UUID"))) + .collect() + } + fn usizes(&self, key: &str) -> Result, ProtocolError> { + self.all(key) + .into_iter() + .map(|value| value.parse().map_err(|_| invalid("Invalid number"))) + .collect() + } + fn i64(&self, key: &str) -> Result { + self.first(key) + .ok_or_else(missing)? + .parse() + .map_err(|_| invalid("Invalid number")) + } + fn i64_optional(&self, key: &str) -> Result, ProtocolError> { + self.first(key) + .map(|value| value.parse().map_err(|_| invalid("Invalid number"))) + .transpose() + } + fn i64_or(&self, key: &str, default: i64) -> Result { + self.first(key) + .map(|value| value.parse().map_err(|_| invalid("Invalid number"))) + .transpose() + .map(|value| value.unwrap_or(default)) + } + fn usize_or(&self, key: &str, default: usize, max: usize) -> Result { + let value = self + .first(key) + .map(|value| value.parse().map_err(|_| invalid("Invalid number"))) + .transpose()? + .unwrap_or(default); + Ok(value.min(max)) + } + fn bool_optional(&self, key: &str) -> Result, ProtocolError> { + self.first(key) + .map(|value| match value { + "true" | "1" => Ok(true), + "false" | "0" => Ok(false), + _ => Err(invalid("Invalid boolean")), + }) + .transpose() + } +} + +/// What a rendered node is, told from the fields it carries rather than from +/// its element name — which `getMusicDirectory` collapses to `child` for all +/// three. +enum EntryKind { + Artist, + Album, + Song, +} diff --git a/src/subsonic/nodes.rs b/src/subsonic/nodes.rs new file mode 100644 index 0000000..845eac8 --- /dev/null +++ b/src/subsonic/nodes.rs @@ -0,0 +1,319 @@ +//! Projection of a domain item onto a response node. +//! +//! Split out of `subsonic.rs`; the wire contract is frozen, so this moved nothing. + +use super::*; + +pub(super) fn artist_node(artist: &ArtistItem, album_count: usize) -> Node { + Node::new("artist") + .attr("id", artist.id.to_string()) + .attr("name", artist.name.clone()) + .attr("albumCount", album_count as i64) + .maybe_attr("coverArt", artist.artwork_hash.clone()) + .maybe_attr("starred", artist.starred_at.map(iso_time)) + .maybe_attr("userRating", artist.user_rating) + // An OpenSubsonic addition, under the presence rule the media items + // already follow: on an artist the identifier means the artist, and it + // is emitted empty rather than omitted so a client can tell an untagged + // artist from a server that does not read the tag at all. + .attr( + "musicBrainzId", + artist.musicbrainz_id.clone().unwrap_or_default(), + ) + // Now that the column exists the field is supported, so it is emitted + // with its default rather than omitted: absent would go on saying the + // server cannot answer, which stopped being true. + .attr("sortName", artist.sort_name.clone().unwrap_or_default()) + // The capacities this artist is credited in. Ordered by name inside + // the projection, where the reference emits them in map-iteration + // order and answers differently on every request. Its two synthetic + // roles — `total` and `maincredit` — are not OpenSubsonic role names + // and are not stored, so they cannot leak here. + .children( + artist + .roles + .iter() + .map(|role| Node::new("roles").text(role.clone())), + ) +} + +/// `songCount` and `duration` come from the album projection rather than from +/// a slice of loaded tracks: counting them caller-side is what forced every +/// album listing to materialise the tenant's whole track list first. +pub(super) fn album_node(album: &AlbumItem) -> Node { + Node::new("album") + .attr("id", album.id.to_string()) + .attr("name", album.title.clone()) + .attr("title", album.title.clone()) + .maybe_attr("artist", album.artist.clone()) + .maybe_attr("artistId", album.artist_id.map(|id| id.to_string())) + .maybe_attr("coverArt", album.artwork_hash.clone()) + .maybe_attr("year", album.year) + .maybe_attr("starred", album.starred_at.map(iso_time)) + .maybe_attr("userRating", album.user_rating) + .attr("songCount", album.song_count) + .attr("duration", album.duration_ms / 1000) + .attr("created", iso_time(album.created_at)) + // OpenSubsonic additions, under the same presence rule as `song`. + .attr("isCompilation", album.is_compilation) + .attr("playCount", album.play_count) + .attr("displayArtist", album.artist.clone().unwrap_or_default()) + .attr("sortName", album.sort_name.clone().unwrap_or_default()) + .maybe_attr("played", album.last_played_at.map(iso_time)) + .children(album.artists.iter().map(|artist| { + Node::new("artists") + .attr("id", artist.id.to_string()) + .attr("name", artist.name.clone()) + })) + .children( + album + .genres + .iter() + .map(|genre| Node::new("genres").attr("name", genre.clone())), + ) + // On an album the identifier means the release, not the recording the + // song carries. It is derived from the album's own tracks at scan time, + // so it is a plain column read here. + .attr( + "musicBrainzId", + album.musicbrainz_id.clone().unwrap_or_default(), + ) +} + +pub(super) fn song_node(song: &SongItem) -> Node { + Node::new("song") + .attr("id", song.id.to_string()) + .attr( + "parent", + song.album_id.unwrap_or(song.library_id).to_string(), + ) + .attr("isDir", false) + .attr("title", song.title.clone()) + .maybe_attr("album", song.album.clone()) + .maybe_attr("artist", song.artist.clone()) + .maybe_attr("genre", song.genre.clone()) + .maybe_attr("year", song.year) + .maybe_attr("track", song.track) + .maybe_attr("discNumber", song.disc) + .attr("duration", song.duration_ms / 1000) + .maybe_attr("bitRate", song.bitrate) + .attr("size", song.size) + .attr("suffix", song.suffix.clone()) + .attr("contentType", content_type(&song.suffix)) + .attr("type", "music") + .maybe_attr("coverArt", song.artwork_hash.clone()) + .maybe_attr("albumId", song.album_id.map(|id| id.to_string())) + .maybe_attr("artistId", song.artist_id.map(|id| id.to_string())) + .maybe_attr("starred", song.starred_at.map(iso_time)) + .maybe_attr("userRating", song.user_rating) + .attr("created", iso_time(song.created_at)) + // From here down the fields are OpenSubsonic additions, and they follow + // its presence rule rather than the omission rule the frozen 1.16 + // fields above use: a field the server supports is emitted even when + // the value is unknown, because presence is the only way a client can + // tell "this server does not implement it" from "this track has none". + .attr("mediaType", "song") + .attr("isVideo", false) + .attr("samplingRate", song.sample_rate.unwrap_or_default()) + .attr("channelCount", song.channels.unwrap_or_default()) + .attr("bitDepth", song.bit_depth.unwrap_or_default()) + .attr("playCount", song.play_count) + .attr("displayArtist", song.artist.clone().unwrap_or_default()) + // `played` is the one exception. Its default would be the empty + // string, which is not a timestamp: a client parsing it strictly would + // fail on every track nobody has played. `playCount` is always present + // and already tells the client play statistics are supported. + .maybe_attr("played", song.last_played_at.map(iso_time)) + .children(song.artists.iter().map(|artist| { + Node::new("artists") + .attr("id", artist.id.to_string()) + .attr("name", artist.name.clone()) + })) + .children( + song.genres + .iter() + .map(|genre| Node::new("genres").attr("name", genre.clone())), + ) + // Every artist the album is credited to, not just the one the frozen + // `artistId` field can name. + .children(song.album_artists.iter().map(|artist| { + Node::new("albumArtists") + .attr("id", artist.id.to_string()) + .attr("name", artist.name.clone()) + })) + // Everyone else the file credits: composer, producer, performer and + // the rest, each naming what it did. `subRole` is the instrument a + // performer is credited on, and only a performer has one. + .children(song.contributors.iter().map(|credit| { + Node::new("contributors") + .attr("role", credit.role.clone()) + .maybe_attr("subRole", credit.sub_role.clone()) + .child( + Node::new("artist") + .attr("id", credit.artist.id.to_string()) + .attr("name", credit.artist.name.clone()), + ) + })) + .attr( + "displayComposer", + song.contributors + .iter() + .filter(|credit| credit.role == "composer") + .map(|credit| credit.artist.name.as_str()) + .collect::>() + .join(" \u{2022} "), + ) + .attr( + "displayAlbumArtist", + song.album_artist.clone().unwrap_or_default(), + ) + .attr( + "musicBrainzId", + song.musicbrainz_id.clone().unwrap_or_default(), + ) + .attr("bpm", song.bpm.unwrap_or_default()) + .attr("sortName", song.sort_name.clone().unwrap_or_default()) + .attr("comment", song.comment.clone().unwrap_or_default()) + .children( + song.isrc + .iter() + .map(|isrc| Node::new("isrc").text(isrc.clone())), + ) + .children( + song.moods + .iter() + .map(|mood| Node::new("moods").text(mood.clone())), + ) + .attr( + "explicitStatus", + song.explicit_status.clone().unwrap_or_default(), + ) + // ReplayGain is the one addition whose *members* are omitted when + // unknown, on the specification's own instruction. The container is + // still always present, because that is what says the server reads + // gain tags at all; an untagged track carries an empty one. + .child( + Node::new("replayGain") + .maybe_attr("trackGain", song.replay_gain_track_gain) + .maybe_attr("trackPeak", song.replay_gain_track_peak) + .maybe_attr("albumGain", song.replay_gain_album_gain) + .maybe_attr("albumPeak", song.replay_gain_album_peak), + ) +} + +/// `owner` is the caller: playlist reads are already scoped to their owner, so +/// there is no other name this could carry. Leaving it empty made Feishin +/// treat every playlist as someone else's and refuse to edit it. +/// `bookmarkPosition` is set on the entry rather than on every song node: it +/// is a legacy optional field, and a track only has a position inside the +/// bookmark that holds it. +pub(super) fn bookmark_node(bookmark: &crate::services::BookmarkItem, owner: &str) -> Node { + Node::new("bookmark") + .attr("position", bookmark.position_ms) + .attr("username", owner) + .maybe_attr("comment", bookmark.comment.clone()) + .attr("created", iso_time(bookmark.created_at)) + .attr("changed", iso_time(bookmark.updated_at)) + .child( + song_node(&bookmark.song) + .renamed("entry") + .attr("bookmarkPosition", bookmark.position_ms), + ) +} + +pub(super) fn playlist_node(playlist: &PlaylistItem, owner: &str) -> Node { + Node::new("playlist") + .attr("id", playlist.id.to_string()) + .attr("name", playlist.name.clone()) + .maybe_attr("comment", playlist.comment.clone()) + .attr("owner", owner) + .attr("public", playlist.public) + .attr("songCount", playlist.songs.len() as i64) + .attr( + "duration", + playlist + .songs + .iter() + .map(|song| song.duration_ms / 1000) + .sum::(), + ) + .attr("created", iso_time(playlist.created_at)) + .attr("changed", iso_time(playlist.updated_at)) +} + +pub(super) fn user_node(user: &crate::services::UserItem) -> Node { + Node::new("user") + .attr("username", user.username.clone()) + .attr("scrobblingEnabled", true) + .attr("adminRole", user.role == AccountRole::Admin) + .attr("settingsRole", user.role == AccountRole::Admin) + .attr("downloadRole", true) + .attr("uploadRole", false) + .attr("playlistRole", true) + .attr("coverArtRole", true) + .attr("commentRole", false) + .attr("podcastRole", false) + .attr("streamRole", true) + .attr("jukeboxRole", false) + .attr("shareRole", true) + .attr("videoConversionRole", false) + .children( + user.folder_ids + .iter() + .map(|id| Node::new("folder").text(id.to_string())), + ) +} + +pub(super) fn share_node( + share: &crate::services::ShareItem, + owner: &str, + public_url: Option<&str>, +) -> Node { + let url = share.url_token.as_ref().map(|token| { + let path = format!("/share/{token}"); + external_url(public_url, &path) + }); + Node::new("share") + .attr("id", share.id.to_string()) + .maybe_attr("url", url) + .maybe_attr("description", share.description.clone()) + .maybe_attr("expires", share.expires_at.map(iso_time)) + .attr("username", owner) + .attr("created", iso_time(share.created_at)) + .attr("visitCount", share.visit_count) + .children( + share + .songs + .iter() + .map(|song| song_node(song).renamed("entry")), + ) +} + +pub(super) fn external_url(base: Option<&str>, path: &str) -> String { + base.map_or_else(|| path.to_owned(), |base| format!("{base}{path}")) +} + +pub(super) fn ok_node() -> Node { + Node::new("subsonic-response") + .attr("xmlns", XMLNS) + .attr("status", "ok") + .attr("version", SUBSONIC_VERSION) + .attr("type", "waveflow") + .attr("serverVersion", env!("CARGO_PKG_VERSION")) + .attr("openSubsonic", true) +} + +pub(super) fn error_node(code: i64, message: &'static str) -> Node { + Node::new("subsonic-response") + .attr("xmlns", XMLNS) + .attr("status", "failed") + .attr("version", SUBSONIC_VERSION) + .attr("type", "waveflow") + .attr("serverVersion", env!("CARGO_PKG_VERSION")) + .attr("openSubsonic", true) + .child( + Node::new("error") + .attr("code", code) + .attr("message", message), + ) +} diff --git a/src/subsonic/playlists.rs b/src/subsonic/playlists.rs new file mode 100644 index 0000000..a819a55 --- /dev/null +++ b/src/subsonic/playlists.rs @@ -0,0 +1,124 @@ +//! Playlist methods. +//! +//! Split out of `subsonic.rs`; the wire contract is frozen, so this moved nothing. + +use super::*; + +pub(super) async fn playlists( + state: &AppState, + principal: &Principal, +) -> Result { + let playlists = state + .services + .playlists(principal.id) + .await + .map_err(internal)?; + Ok(Node::new("playlists").children( + playlists + .iter() + .map(|playlist| playlist_node(playlist, &principal.username)), + )) +} + +pub(super) async fn get_playlist( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let playlist = state + .services + .playlist(principal.id, params.uuid("id")?) + .await + .map_err(service_protocol)?; + Ok(playlist_node(&playlist, &principal.username).children( + playlist + .songs + .iter() + .map(|song| song_node(song).renamed("entry")), + )) +} + +pub(super) async fn create_playlist( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let ids = params.uuids("songId")?; + let playlist = if let Some(id) = params.uuid_optional("playlistId")? { + state + .services + // Given a playlistId, songId names every song of the playlist, so + // the call replaces the track list rather than adding to it. A + // client that removes a song sends back what remains, and would + // otherwise see nothing change. + // + // The Subsonic contract is frozen: it has no way to ask for a + // text field to be blanked, so clearing the comment stays off. + .update_playlist( + principal.id, + id, + None, + None, + None, + &ids, + &[], + crate::services::PlaylistClear { + comment: false, + tracks: true, + }, + ) + .await + .map_err(service_protocol)? + } else { + state + .services + .create_playlist( + principal.id, + params.first("name").ok_or_else(missing)?, + &ids, + ) + .await + .map_err(service_protocol)? + }; + Ok(playlist_node(&playlist, &principal.username).children( + playlist + .songs + .iter() + .map(|song| song_node(song).renamed("entry")), + )) +} + +pub(super) async fn update_playlist( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let playlist = state + .services + .update_playlist( + principal.id, + params.uuid("playlistId")?, + params.first("name"), + params.first("comment"), + params.bool_optional("public")?, + ¶ms.uuids("songIdToAdd")?, + ¶ms.usizes("songIndexToRemove")?, + Default::default(), + ) + .await + .map_err(service_protocol)?; + Ok(playlist_node(&playlist, &principal.username)) +} + +pub(super) async fn delete_playlist( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + state + .services + .delete_playlist(principal.id, params.uuid("id")?) + .await + .map_err(service_protocol)?; + Ok(Node::new("deletePlaylist")) +} diff --git a/src/subsonic/protocol.rs b/src/subsonic/protocol.rs new file mode 100644 index 0000000..e53d1a5 --- /dev/null +++ b/src/subsonic/protocol.rs @@ -0,0 +1,329 @@ +//! Rendering a node as XML or JSON, and the wire helpers. +//! +//! Split out of `subsonic.rs`; the wire contract is frozen, so this moved nothing. + +use super::*; + +pub(super) fn render_protocol(node: Node, json: bool) -> Response { + if json { + let mut root = Map::new(); + root.insert(node.name.clone(), node_json(&node, "")); + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/json; charset=utf-8")], + Value::Object(root).to_string(), + ) + .into_response() + } else { + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/xml; charset=utf-8")], + node_xml(&node), + ) + .into_response() + } +} + +pub(super) fn node_json(node: &Node, parent: &str) -> Value { + // An array-typed element is its children, not an object wrapping them — + // whether it holds none or several. Applying this only when empty would + // hand a strictly typed client `[]` on an empty catalogue and an object on + // a populated one, which is worse than being wrong consistently. + if json_array_node(&node.name) { + return Value::Array( + node.children + .iter() + .map(|child| node_json(child, &node.name)) + .collect(), + ); + } + if node.attrs.is_empty() && node.children.is_empty() { + if let Some(text) = &node.text { + return Value::String(text.clone()); + } + } + let mut map = Map::new(); + for (key, value) in &node.attrs { + if key != "xmlns" { + map.insert(key.clone(), value.clone()); + } + } + let mut grouped: BTreeMap<&str, Vec<&Node>> = BTreeMap::new(); + for child in &node.children { + grouped.entry(&child.name).or_default().push(child); + } + for (name, children) in grouped { + let value = if children.len() == 1 && !json_array_field(&node.name, name) { + node_json(children[0], &node.name) + } else { + Value::Array( + children + .into_iter() + .map(|child| node_json(child, &node.name)) + .collect(), + ) + }; + map.insert(name.to_owned(), value); + } + // A browsing child is a song, an album or an artist under one element + // name, and its own fields are what tell them apart: an artist carries + // `albumCount`, an album `songCount`, a song neither. Injecting a song's + // relations into a folder entry would have an artist answer `isrc: []`, + // and injecting an album's would have it answer `artists: []` — a list of + // the artists of an artist. + let entry_kind = match ( + node.attrs.contains_key("albumCount"), + node.attrs.contains_key("songCount"), + ) { + (true, _) => EntryKind::Artist, + (_, true) => EntryKind::Album, + _ => EntryKind::Song, + }; + for name in json_required_array_fields(parent, &node.name) { + let injected = match entry_kind { + // An artist keeps its own array and takes nobody else's: a folder + // entry answering `isrc: []` would say the server read a recording + // identifier off a directory, and `artists: []` would be the list + // of the artists of an artist. + EntryKind::Artist => *name == "roles", + EntryKind::Album => matches!(*name, "artists" | "genres"), + EntryKind::Song => true, + }; + if !injected { + continue; + } + map.entry((*name).to_owned()) + .or_insert_with(|| Value::Array(Vec::new())); + } + if let Some(text) = &node.text { + map.insert("value".into(), Value::String(text.clone())); + } + Value::Object(map) +} + +/// Elements the OpenSubsonic specification types as a JSON array rather than an +/// object. They must serialise as `[]` when empty; an empty object breaks +/// strictly typed clients that decode the field into a list. +pub(super) fn json_array_node(name: &str) -> bool { + matches!(name, "openSubsonicExtensions") +} + +pub(super) fn json_required_array_fields(parent: &str, name: &str) -> &'static [&'static str] { + // A contributor's artist is a reference — an identifier and a display + // name — and shares its element name with the record. Without the parent + // to tell them apart, every array the record carries would be injected + // into the reference, which is exactly what + // `an_artist_reference_is_not_an_artist_record` forbids. + if parent == "contributors" && name == "artist" { + return &[]; + } + match name { + "lyricsList" => &["structuredLyrics"], + "structuredLyrics" => &["line"], + // Emitted as `[]` rather than omitted when a track has no credited + // artist or no genre: under the OpenSubsonic presence rule an absent + // key means the server does not support the field at all. + "song" | "entry" | "child" => &[ + "artists", + "genres", + "isrc", + "moods", + "albumArtists", + "contributors", + ], + "album" => &["artists", "genres"], + // The roles an artist is credited in, empty rather than absent for + // the same reason: absent would say the server does not read them. + "artist" => &["roles"], + _ => &[], + } +} + +/// Extensions this server actually implements, with their supported versions. +/// +/// The list was empty, which told every third-party client that WaveFlow +/// supports nothing optional — so a client that could have posted a long +/// request, authenticated with an API key or seeked a transcode fell back to +/// the lowest common denominator instead. +/// +/// **Only advertise what is implemented and covered by tests.** Announcing an +/// extension the server does not honour is worse than announcing none: the +/// client stops probing and starts relying on it. +/// +/// The specification defines no XML shape for this method, so `versions` +/// renders as a JSON array here and stringifies as `"[1]"` in the XML branch. +/// Clients that use the method request JSON. +pub(super) fn open_subsonic_extensions() -> Node { + let extension = |name: &str, versions: Vec| { + Node::new("openSubsonicExtension").attr("name", name).attr( + "versions", + Value::Array(versions.into_iter().map(Value::from).collect()), + ) + }; + Node::new("openSubsonicExtensions") + // POST with application/x-www-form-urlencoded, for requests too long + // for a query string. + .child(extension("formPost", vec![1])) + // `apiKey` in place of the u/p and u/t/s pairs. + .child(extension("apiKeyAuthentication", vec![1])) + // `timeOffset` on stream, honoured for transcoded output. + .child(extension("transcodeOffset", vec![1])) + // Structured plain or line-synchronised lyrics by stable song UUID. + .child(extension("songLyrics", vec![1])) +} + +pub(super) fn json_array_field(parent: &str, name: &str) -> bool { + matches!( + (parent, name), + ("musicFolders", "musicFolder") + | ("indexes", "index") + | ("artists", "index") + | ("index", "artist") + | ("artist", "album") + | ("album", "song") + | ("genres", "genre") + | ("directory", "child") + | ("albumList", "album") + | ("albumList2", "album") + | ("randomSongs", "song") + | ("songsByGenre", "song") + | ("searchResult3" | "searchResult2", "artist" | "album" | "song") + | ("playlists", "playlist") + | ("playlist", "entry") + | ("bookmarks", "bookmark") + | ("starred2" | "starred", "artist" | "album" | "song") + | ("nowPlaying", "song") + | ("playQueue", "song") + | ("shares", "share") + | ("share", "entry") + | ("users", "user") + | ("user", "folder") + | ("openSubsonicExtensions", "openSubsonicExtension") + | ("lyricsList", "structuredLyrics") + | ("structuredLyrics", "line") + // A media item is rendered as `song`, and renamed to `entry` inside + // a playlist or share and to `child` inside a directory. Its + // OpenSubsonic relations are arrays under all three names. + | ("song" | "entry" | "child" | "album", "artists" | "genres") + | ("song" | "entry" | "child", "isrc" | "moods" | "albumArtists") + | ("song" | "entry" | "child", "contributors") + // An artist rendered as a browsing child keeps the record's shape, + // so its roles stay an array there too — otherwise the field + // collapses into a bare object the moment a directory carries it. + | ("artist" | "child", "roles") + ) +} + +pub(super) fn empty_success_method(method: &str) -> bool { + matches!( + method, + "updatePlaylist" + | "deletePlaylist" + | "star" + | "unstar" + | "setRating" + | "scrobble" + | "savePlayQueue" + | "createBookmark" + | "deleteBookmark" + | "deleteShare" + | "createUser" + | "updateUser" + | "deleteUser" + | "changePassword" + ) +} + +pub(super) fn node_xml(node: &Node) -> String { + let mut output = String::new(); + write_xml(node, &mut output); + output +} + +pub(super) fn write_xml(node: &Node, output: &mut String) { + output.push('<'); + output.push_str(&node.name); + for (key, value) in &node.attrs { + output.push(' '); + output.push_str(key); + output.push_str("=\""); + output.push_str(&xml_escape(&value_string(value))); + output.push('"'); + } + if node.children.is_empty() && node.text.is_none() { + output.push_str("/>"); + return; + } + output.push('>'); + if let Some(text) = &node.text { + output.push_str(&xml_escape(text)); + } + for child in &node.children { + write_xml(child, output); + } + output.push_str("'); +} + +pub(super) fn parse_pairs(raw: &str) -> Result { + serde_urlencoded::from_str::>(raw) + .map(Params) + .map_err(|_| invalid("Invalid parameters")) +} + +pub(super) fn content_type(suffix: &str) -> &'static str { + match suffix { + "mp3" => "audio/mpeg", + "flac" => "audio/flac", + "wav" => "audio/wav", + "ogg" | "opus" => "audio/ogg", + "m4a" | "mp4" => "audio/mp4", + "aac" => "audio/aac", + "dsf" | "dff" => "audio/dsd", + _ => "application/octet-stream", + } +} + +pub(super) fn iso_time(millis: i64) -> String { + chrono::DateTime::from_timestamp_millis(millis) + .unwrap_or_default() + .to_rfc3339() +} + +pub(super) fn parse_time(value: &str) -> Result { + if let Ok(millis) = value.parse::() { + return Ok(millis); + } + chrono::DateTime::parse_from_rfc3339(value) + .map(|value| value.timestamp_millis()) + .map_err(|_| invalid("Invalid date")) +} + +pub(super) fn value_string(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + _ => value.to_string(), + } +} + +/// Escapes a value for an XML attribute or text node. +/// +/// The control characters dropped first are forbidden outright by XML 1.0 +/// §2.2 — no entity spells them — so a tag carrying one would have produced a +/// document no client can parse. Tab, newline and carriage return are legal +/// and stay. +pub(super) fn xml_escape(value: &str) -> String { + value + .replace( + |c| matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}'), + "", + ) + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/src/subsonic/shares.rs b/src/subsonic/shares.rs new file mode 100644 index 0000000..2d59d77 --- /dev/null +++ b/src/subsonic/shares.rs @@ -0,0 +1,68 @@ +//! Share methods. +//! +//! Split out of `subsonic.rs`; the wire contract is frozen, so this moved nothing. + +use super::*; + +pub(super) async fn shares( + state: &AppState, + principal: &Principal, + method: &str, + params: &Params, +) -> Result { + match method { + "getShares" => Ok(Node::new("shares").children( + state + .services + .shares(principal.id) + .await + .map_err(internal)? + .iter() + .map(|share| share_node(share, &principal.username, state.public_url.as_deref())), + )), + "createShare" => { + let share = state + .services + .create_share( + principal.id, + ¶ms.uuids("id")?, + params.first("description"), + params.first("expires").map(parse_time).transpose()?, + ) + .await + .map_err(service_protocol)?; + Ok(Node::new("shares").child(share_node( + &share, + &principal.username, + state.public_url.as_deref(), + ))) + } + "updateShare" => { + let share = state + .services + .update_share( + principal.id, + params.uuid("id")?, + params.first("description"), + params.first("expires").map(parse_time).transpose()?, + Default::default(), + ) + .await + .map_err(service_protocol)?; + Ok(Node::new("shares").child(share_node( + &share, + &principal.username, + state.public_url.as_deref(), + ))) + } + "deleteShare" => { + state + .services + .delete_share(principal.id, params.uuid("id")?) + .await + .map_err(service_protocol)?; + Ok(Node::new("deleteShare")) + } + _ => unreachable!("share method dispatch is exhaustive"), + } +} diff --git a/src/subsonic/userdata.rs b/src/subsonic/userdata.rs new file mode 100644 index 0000000..e52dffa --- /dev/null +++ b/src/subsonic/userdata.rs @@ -0,0 +1,207 @@ +//! Bookmarks, favourites, ratings, scrobbles and the queue. +//! +//! Split out of `subsonic.rs`; the wire contract is frozen, so this moved nothing. + +use super::*; + +pub(super) async fn bookmarks( + state: &AppState, + principal: &Principal, +) -> Result { + let bookmarks = state + .services + .bookmarks(principal.id) + .await + .map_err(internal)?; + Ok(Node::new("bookmarks").children( + bookmarks + .iter() + .map(|bookmark| bookmark_node(bookmark, &principal.username)), + )) +} + +pub(super) async fn create_bookmark( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + state + .services + .set_bookmark( + principal.id, + params.uuid("id")?, + params.i64("position")?, + params.first("comment"), + ) + .await + .map_err(service_protocol)?; + Ok(Node::new("createBookmark")) +} + +pub(super) async fn delete_bookmark( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + state + .services + .delete_bookmark(principal.id, params.uuid("id")?) + .await + .map_err(service_protocol)?; + Ok(Node::new("deleteBookmark")) +} + +pub(super) async fn set_star( + state: &AppState, + principal: &Principal, + params: &Params, + starred: bool, +) -> Result { + for id in params.uuids("id")? { + let kind = state + .services + .entity_kind(principal.id, id) + .await + .map_err(service_protocol)? + .ok_or_else(not_found)?; + state + .services + .set_star(principal.id, kind, id, starred) + .await + .map_err(service_protocol)?; + } + for (key, kind) in [("albumId", "album"), ("artistId", "artist")] { + for id in params.uuids(key)? { + state + .services + .set_star(principal.id, kind, id, starred) + .await + .map_err(service_protocol)?; + } + } + Ok(Node::new(if starred { "star" } else { "unstar" })) +} + +pub(super) async fn starred( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + // The three projections already carry `starred_at`, so the nodes emit + // `starred` themselves. This used to read the whole catalogue and look + // each starred id up inside it. + let starred = state + .services + .starred(principal.id, ¶ms.uuids("musicFolderId")?) + .await + .map_err(service_protocol)?; + let mut node = Node::new("starred2"); + node.children.extend( + starred + .artists + .iter() + .map(|summary| artist_node(&summary.artist, summary.album_count as usize)), + ); + node.children.extend(starred.albums.iter().map(album_node)); + node.children.extend(starred.songs.iter().map(song_node)); + Ok(node) +} + +pub(super) async fn set_rating( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let id = params.uuid("id")?; + let rating = params.i64("rating")?; + let kind = state + .services + .entity_kind(principal.id, id) + .await + .map_err(service_protocol)? + .ok_or_else(not_found)?; + state + .services + .set_rating(principal.id, kind, id, rating) + .await + .map_err(service_protocol)?; + Ok(Node::new("setRating")) +} + +pub(super) async fn scrobble( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + let ids = params.uuids("id")?; + if ids.is_empty() { + return Err(missing()); + } + let times = params + .all("time") + .iter() + .map(|value| value.parse::().map_err(|_| invalid("Invalid time"))) + .collect::, _>>()?; + let submission = params.bool_optional("submission")?.unwrap_or(true); + for (index, id) in ids.into_iter().enumerate() { + state + .services + .scrobble(principal.id, id, submission, times.get(index).copied()) + .await + .map_err(service_protocol)?; + } + Ok(Node::new("scrobble")) +} + +pub(super) async fn now_playing( + state: &AppState, + principal: &Principal, +) -> Result { + let entries = state + .services + .now_playing(principal.id) + .await + .map_err(internal)?; + Ok( + Node::new("nowPlaying").children(entries.iter().map(|(username, song, started)| { + song_node(song).attr("username", username.clone()).attr( + "minutesAgo", + ((chrono::Utc::now().timestamp_millis() - started) / 60_000).max(0), + ) + })), + ) +} + +pub(super) async fn get_queue( + state: &AppState, + principal: &Principal, +) -> Result { + let Some(queue) = state.services.queue(principal.id).await.map_err(internal)? else { + return Ok(Node::new("playQueue")); + }; + Ok(Node::new("playQueue") + .maybe_attr("current", queue.current.map(|id| id.to_string())) + .attr("position", queue.position_ms) + .maybe_attr("changedBy", queue.changed_by) + .attr("changed", iso_time(queue.updated_at)) + .children(queue.songs.iter().map(song_node))) +} + +pub(super) async fn save_queue( + state: &AppState, + principal: &Principal, + params: &Params, +) -> Result { + state + .services + .save_queue( + principal.id, + ¶ms.uuids("id")?, + params.uuid_optional("current")?, + params.i64_or("position", 0)?, + params.first("c"), + ) + .await + .map_err(service_protocol)?; + Ok(Node::new("savePlayQueue")) +} diff --git a/src/webui.rs b/src/webui.rs index 2e4db11..acfc8a3 100644 --- a/src/webui.rs +++ b/src/webui.rs @@ -13,7 +13,7 @@ use axum::{ }; use rust_embed::Embed; -use crate::http::ErrorResponse; +use crate::api::ErrorResponse; #[derive(Embed)] // Staged by build.rs: the real client build when present, a placeholder diff --git a/tests/v2_foundations.rs b/tests/v2_foundations.rs index 8497ffe..2ebc177 100644 --- a/tests/v2_foundations.rs +++ b/tests/v2_foundations.rs @@ -7997,14 +7997,11 @@ async fn sync_claim_precedes_state_validation_and_invalid_claims_roll_back() { .await .unwrap(); assert_eq!(persisted_playlist_tracks, 1); - assert!(state - .services - .queue(owner) - .await - .unwrap() - .unwrap() - .songs - .is_empty()); + let unavailable_queue = state.services.queue(owner).await.unwrap().unwrap(); + assert!(unavailable_queue.songs.is_empty()); + // The saved row still names a current track. The projection must not, once + // that track is no longer among the songs it hands back. + assert!(unavailable_queue.current.is_none()); assert!(state .services .shares(owner) @@ -8015,6 +8012,20 @@ async fn sync_claim_precedes_state_validation_and_invalid_claims_roll_back() { .unwrap() .songs .is_empty()); + // A visitor sees the same thing as the owner: the share survives its last + // track going unavailable, rather than answering not-found after the visit + // has already been counted. + let visited = state + .services + .public_share( + aggregate_share + .url_token + .as_deref() + .expect("a freshly created share carries its token"), + ) + .await + .expect("a share outlives a track that went unavailable"); + assert!(visited.songs.is_empty()); state.services.sync_snapshot(owner, 100).await.unwrap(); }