From 2563fc1fffb168bfac4f5e1c39a234a35b2d5710 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 24 Aug 2026 10:37:23 +0200 Subject: [PATCH 1/8] refactor(services): split the domain services by domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `services.rs` held 4 273 lines and a single `impl DomainServices` of some 3 600. The block is now fourteen `impl DomainServices` blocks under `src/services/`, which Rust allows within one crate, and `mod.rs` keeps what they share: the SQL projection macros, the read-model types, `ServiceError`, the struct, and the free row-mapping helpers. Pure movement. The macros stay at the top of `mod.rs`, ahead of the `mod` declarations, so the textual scope of `macro_rules!` reaches every child and none of the 36 call sites changed. The free helpers stay private to `mod.rs`, which its descendants can still see. The one visibility change: nine `*_on` helpers that run inside a caller's transaction are used across the new boundaries, so they widen from private to `pub(super)` — visible within `services` and nowhere else. No behaviour change; 42 unit and 46 integration tests pass unchanged. Signed-off-by: InstaZDLL --- src/services.rs | 4273 ----------------------------------- src/services/admin.rs | 430 ++++ src/services/albums.rs | 148 ++ src/services/artists.rs | 67 + src/services/bookmarks.rs | 199 ++ src/services/catalog.rs | 233 ++ src/services/credentials.rs | 116 + src/services/favorites.rs | 264 +++ src/services/mod.rs | 1240 ++++++++++ src/services/playback.rs | 304 +++ src/services/playlists.rs | 360 +++ src/services/scan.rs | 78 + src/services/search.rs | 161 ++ src/services/shares.rs | 377 +++ src/services/songs.rs | 364 +++ src/services/sync.rs | 45 + 16 files changed, 4386 insertions(+), 4273 deletions(-) delete mode 100644 src/services.rs create mode 100644 src/services/admin.rs create mode 100644 src/services/albums.rs create mode 100644 src/services/artists.rs create mode 100644 src/services/bookmarks.rs create mode 100644 src/services/catalog.rs create mode 100644 src/services/credentials.rs create mode 100644 src/services/favorites.rs create mode 100644 src/services/mod.rs create mode 100644 src/services/playback.rs create mode 100644 src/services/playlists.rs create mode 100644 src/services/scan.rs create mode 100644 src/services/search.rs create mode 100644 src/services/shares.rs create mode 100644 src/services/songs.rs create mode 100644 src/services/sync.rs 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..cff645b --- /dev/null +++ b/src/services/catalog.rs @@ -0,0 +1,233 @@ +//! 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 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, + }) + } + + 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..6ed64e9 --- /dev/null +++ b/src/services/favorites.rs @@ -0,0 +1,264 @@ +//! 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> { + 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(()) + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..176b46d --- /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) + } + } +} + +/// 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/playback.rs b/src/services/playback.rs new file mode 100644 index 0000000..756091b --- /dev/null +++ b/src/services/playback.rs @@ -0,0 +1,304 @@ +//! 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::, _>>()?; + 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?, + })) + } +} 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..9f81737 --- /dev/null +++ b/src/services/shares.rs @@ -0,0 +1,377 @@ +//! 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, + 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(()) + } + } +} diff --git a/src/services/songs.rs b/src/services/songs.rs new file mode 100644 index 0000000..586a199 --- /dev/null +++ b/src/services/songs.rs @@ -0,0 +1,364 @@ +//! 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); + 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, + }) + } + + 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, + }) + } +} From 74a6451584a1c1e9ba554e64ed65f9408c45989f Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 24 Aug 2026 10:41:45 +0200 Subject: [PATCH 2/8] refactor(subsonic): split the facade by method family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `subsonic.rs` held 2 434 lines flat. It becomes eleven modules under `src/subsonic/` — authentication, browsing, lyrics, playlists, user data, media, shares, administration, node projection, protocol rendering and the error constructors — while `mod.rs` keeps what the dispatch needs: the constants, `Principal`, `Params`, `Node`, `ProtocolError`, the router and `dispatch` itself. Pure movement. Every moved item was private to the flat module, so each widens to `pub(super)` and `mod.rs` re-globs them; no call site changed. Beyond that prefix the only diff is rustfmt wrapping the signatures it pushed past 100 columns. The wire contract is frozen for v2.0-beta and nothing here touches it: 42 unit and 46 integration tests pass unchanged, the Subsonic XML/JSON compatibility test among them. Signed-off-by: InstaZDLL --- src/subsonic.rs | 2434 ------------------------------------- src/subsonic/admin.rs | 145 +++ src/subsonic/auth.rs | 135 ++ src/subsonic/browse.rs | 435 +++++++ src/subsonic/errors.rs | 54 + src/subsonic/lyrics.rs | 65 + src/subsonic/media.rs | 110 ++ src/subsonic/mod.rs | 571 +++++++++ src/subsonic/nodes.rs | 319 +++++ src/subsonic/playlists.rs | 124 ++ src/subsonic/protocol.rs | 319 +++++ src/subsonic/shares.rs | 68 ++ src/subsonic/userdata.rs | 207 ++++ 13 files changed, 2552 insertions(+), 2434 deletions(-) delete mode 100644 src/subsonic.rs create mode 100644 src/subsonic/admin.rs create mode 100644 src/subsonic/auth.rs create mode 100644 src/subsonic/browse.rs create mode 100644 src/subsonic/errors.rs create mode 100644 src/subsonic/lyrics.rs create mode 100644 src/subsonic/media.rs create mode 100644 src/subsonic/mod.rs create mode 100644 src/subsonic/nodes.rs create mode 100644 src/subsonic/playlists.rs create mode 100644 src/subsonic/protocol.rs create mode 100644 src/subsonic/shares.rs create mode 100644 src/subsonic/userdata.rs 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..fed9d35 --- /dev/null +++ b/src/subsonic/lyrics.rs @@ -0,0 +1,65 @@ +//! 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"); + 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..76ad39b --- /dev/null +++ b/src/subsonic/protocol.rs @@ -0,0 +1,319 @@ +//! 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(), + } +} + +pub(super) fn xml_escape(value: &str) -> String { + value + .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")) +} From f12e80374935fd71b3e045461b5463f86c5e6bb4 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 24 Aug 2026 10:49:24 +0200 Subject: [PATCH 3/8] refactor(api): split the native surface and name it for what it serves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `http.rs` held 2 415 lines and every `/api/v2` route. It becomes eighteen modules under `src/api/`, one per resource, plus the three that cut across them: `error` for `ApiError` and its mappings, `access` for who is calling and what their token allows, and `web_session` for the browser's cookies, CSRF and origin checks. `mod.rs` keeps the header constants and `router`. The module is renamed `http` to `api` because that is the surface it serves; `crate::http` was only ever the file it lived in. `mod.rs` re-exports each child, so the hundred-odd `api::` paths the utoipa declaration in `lib.rs` names still resolve, and no handler moved out of reach. The `access` re-export is `pub(crate)`, its widest item being `pub(crate)` too — a `pub use` there re-exports nothing and warns. Pure movement. Private helpers widen to `pub(super)`; the rest is rustfmt wrapping the signatures that prefix pushed past 100 columns. 42 unit and 46 integration tests pass unchanged. Signed-off-by: InstaZDLL --- src/api/access.rs | 135 +++ src/api/auth.rs | 174 +++ src/api/bookmarks.rs | 78 ++ src/api/catalog.rs | 229 ++++ src/api/error.rs | 139 +++ src/api/favorites.rs | 105 ++ src/api/libraries.rs | 218 ++++ src/api/mod.rs | 163 +++ src/api/oauth.rs | 101 ++ src/api/playback.rs | 153 +++ src/api/playlists.rs | 134 +++ src/api/probes.rs | 65 ++ src/api/setup.rs | 44 + src/api/shares.rs | 148 +++ src/api/sync.rs | 249 +++++ src/api/tokens.rs | 79 ++ src/api/tracks.rs | 76 ++ src/api/users.rs | 140 +++ src/api/web_session.rs | 126 +++ src/http.rs | 2415 ---------------------------------------- src/lib.rs | 218 ++-- src/media.rs | 2 +- src/webui.rs | 2 +- 23 files changed, 2667 insertions(+), 2526 deletions(-) create mode 100644 src/api/access.rs create mode 100644 src/api/auth.rs create mode 100644 src/api/bookmarks.rs create mode 100644 src/api/catalog.rs create mode 100644 src/api/error.rs create mode 100644 src/api/favorites.rs create mode 100644 src/api/libraries.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/oauth.rs create mode 100644 src/api/playback.rs create mode 100644 src/api/playlists.rs create mode 100644 src/api/probes.rs create mode 100644 src/api/setup.rs create mode 100644 src/api/shares.rs create mode 100644 src/api/sync.rs create mode 100644 src/api/tokens.rs create mode 100644 src/api/tracks.rs create mode 100644 src/api/users.rs create mode 100644 src/api/web_session.rs delete mode 100644 src/http.rs diff --git a/src/api/access.rs b/src/api/access.rs new file mode 100644 index 0000000..ce91646 --- /dev/null +++ b/src/api/access.rs @@ -0,0 +1,135 @@ +//! 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) + } +} + +pub(super) fn bearer_token(headers: &HeaderMap) -> Option<&str> { + headers + .get(header::AUTHORIZATION)? + .to_str() + .ok()? + .strip_prefix("Bearer ") + .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..1ac9765 --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,174 @@ +//! 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) + ) +)] +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) +} 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..ffc588c --- /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)), 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..8d84d14 --- /dev/null +++ b/src/api/error.rs @@ -0,0 +1,139 @@ +//! 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 + } + } +} + +#[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/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..bb24ae8 --- /dev/null +++ b/src/api/sync.rs @@ -0,0 +1,249 @@ +//! 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 +} 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..a718426 --- /dev/null +++ b/src/api/web_session.rs @@ -0,0 +1,126 @@ +//! 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) + } +} 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/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 From 8cea5bb3e1d61fbf4445620c987a5b7401b2ee9c Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 24 Aug 2026 10:50:03 +0200 Subject: [PATCH 4/8] docs(claude): record where the three surfaces now live `services.rs`, `subsonic.rs` and `http.rs` are directories, so the two rules that named them by file pointed at nothing. Adds the layout rule alongside them: which directory carries which surface, what each `mod.rs` is allowed to keep, and why the SQL macros sit ahead of the `mod` declarations. Signed-off-by: InstaZDLL --- CLAUDE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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. From 52fd5ef41081705d4b96376065500673bca17574 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 24 Aug 2026 11:24:51 +0200 Subject: [PATCH 5/8] fix(api): file the moved tests under the modules they cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The splitter recognised functions, types and `impl` blocks as top-level items, but not `mod`. The `#[cfg(test)] mod tests` at the end of `http.rs` was therefore swallowed by the range of the item above it and landed in `error.rs`, which covers neither of the two tests it holds. They move to the modules that own what they exercise: `public_url_is_https` to `web_session`, `sync_notice_action` to `sync`. Content unchanged; only the `use super::` line narrows to each module's own items. This is the failure the split cannot verify by compiling — the movement proof compares content, and content was preserved. Filing is what a person has to check. Signed-off-by: InstaZDLL --- src/api/error.rs | 31 ------------------------------- src/api/sync.rs | 23 +++++++++++++++++++++++ src/api/web_session.rs | 13 +++++++++++++ 3 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/api/error.rs b/src/api/error.rs index 8d84d14..8330cde 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -106,34 +106,3 @@ pub(super) fn service_error(error: crate::services::ServiceError) -> ApiError { } } } - -#[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/api/sync.rs b/src/api/sync.rs index bb24ae8..6484418 100644 --- a/src/api/sync.rs +++ b/src/api/sync.rs @@ -247,3 +247,26 @@ pub(super) async fn send_sync_notice( 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}; + + #[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/api/web_session.rs b/src/api/web_session.rs index a718426..fddc063 100644 --- a/src/api/web_session.rs +++ b/src/api/web_session.rs @@ -124,3 +124,16 @@ pub(super) fn validate_web_origin(state: &AppState, headers: &HeaderMap) -> Resu 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)); + } +} From a7facc1200957494775c0bb2b2748fb7e433561d Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 24 Aug 2026 11:24:51 +0200 Subject: [PATCH 6/8] docs(api): declare the responses and parameters that already exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three OpenAPI declarations described less than their handler does. `web_login` reaches 422 through `AuthError::InvalidDeviceName` and 503 through `AuthError::Unavailable`; `web_refresh` and `web_logout` reach 503 the same way, and through `append_cookie`. Their native counterparts declare those statuses already — only the browser variants were short. `/api/v2/search` accepts `artist_offset`, `album_offset` and `song_offset` on `SearchQuery`, which is what lets a client page songs past the end of the artists, but the parameter list named only `q`, `offset` and `limit`, so no generated client could reach them. And in `services/mod.rs`, the doc block describing the batched `track_artist` and `track_genre` reads sat above `folder_filter`, whose own doc followed it without a blank line — so rustdoc merged the two onto the JSON library filter. It goes back onto `attach_song_relations`, which is what it describes. Pre-existing, not from the split. No runtime behaviour changes. Signed-off-by: InstaZDLL --- src/api/auth.rs | 10 +++++++--- src/api/catalog.rs | 2 +- src/services/mod.rs | 22 +++++++++++----------- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/api/auth.rs b/src/api/auth.rs index 1ac9765..eeea764 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -97,7 +97,9 @@ pub async fn logout( responses( (status = 200, body = WebAuthResponse), (status = 401, body = ErrorResponse), - (status = 403, body = ErrorResponse) + (status = 403, body = ErrorResponse), + (status = 422, body = ErrorResponse), + (status = 503, body = ErrorResponse) ) )] pub async fn web_login( @@ -121,7 +123,8 @@ pub async fn web_login( responses( (status = 200, body = WebAuthResponse), (status = 401, body = ErrorResponse), - (status = 403, body = ErrorResponse) + (status = 403, body = ErrorResponse), + (status = 503, body = ErrorResponse) ) )] pub async fn web_refresh( @@ -145,7 +148,8 @@ pub async fn web_refresh( responses( (status = 204), (status = 401, body = ErrorResponse), - (status = 403, body = ErrorResponse) + (status = 403, body = ErrorResponse), + (status = 503, body = ErrorResponse) ) )] pub async fn web_logout( diff --git a/src/api/catalog.rs b/src/api/catalog.rs index ffc588c..0ef9136 100644 --- a/src/api/catalog.rs +++ b/src/api/catalog.rs @@ -153,7 +153,7 @@ pub async fn get_artist( .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)))] +#[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, diff --git a/src/services/mod.rs b/src/services/mod.rs index 176b46d..515fe64 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -750,17 +750,6 @@ impl DomainServices { } } -/// 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 @@ -850,6 +839,17 @@ async fn attach_album_relations( 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, From e74b4948b96d800a587b59f27a68982bd98f717a Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 24 Aug 2026 11:25:03 +0200 Subject: [PATCH 7/8] fix: seven defects the review found in the moved code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All pre-existing, none introduced by the split. Kept in their own commit so the movement commits stay verifiable as movement. **A share died with its last track.** `public_share` resolved songs strictly while the owner's listing resolved them leniently, so a track gone unavailable turned every visit into a 404 — after the visit counter had already been incremented, outside the transaction. It now matches `shares_on`. The test proves it: without the change the assertion fails with `NotFound`. **A queue named a song it did not hand back.** `current` was read from the row whatever the lenient resolution returned, so it could point at a track absent from `songs`. It is now cleared when the track is not there. **`Bearer` was matched case-sensitively.** RFC 7235 §2.1 makes the scheme name case-insensitive; a client spelling it `bearer` was turned away as unauthenticated. **XML 1.0 forbids some control characters outright** — U+0000..U+0008, U+000B, U+000C, U+000E..U+001F — and no entity spells them, so a tag carrying one produced a document no client can parse. They are dropped before escaping; tab, newline and carriage return are legal and stay. **`getLyrics` with neither artist nor title** left the lookup unfiltered and answered with whichever track sorts first among those that carry lyrics. Nothing asked for, nothing returned. **An out-of-range rating** was refused only after taking the process-wide writer gate and claiming the operation. The range check moves ahead of both; the claim was rolled back either way, so only the ordering changes. **`starred` acquired five pool connections** for three projections and two relation batches, all answering one question. One connection now serves the whole call. Two assertions added to the existing unavailable-track test, both confirmed to fail against the previous behaviour. Signed-off-by: InstaZDLL --- src/api/access.rs | 12 ++++++++++-- src/services/catalog.rs | 6 ++---- src/services/favorites.rs | 9 ++++++--- src/services/playback.rs | 19 ++++++++++++------- src/services/shares.rs | 8 +++++++- src/services/songs.rs | 14 +++++++++----- src/subsonic/lyrics.rs | 6 ++++++ src/subsonic/protocol.rs | 10 ++++++++++ tests/v2_foundations.rs | 27 +++++++++++++++++++-------- 9 files changed, 81 insertions(+), 30 deletions(-) diff --git a/src/api/access.rs b/src/api/access.rs index ce91646..306f6f5 100644 --- a/src/api/access.rs +++ b/src/api/access.rs @@ -85,12 +85,20 @@ pub(crate) async fn authenticated( } } +/// 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> { - headers + let (scheme, token) = headers .get(header::AUTHORIZATION)? .to_str() .ok()? - .strip_prefix("Bearer ") + .split_once(' ')?; + scheme + .eq_ignore_ascii_case("Bearer") + .then_some(token) .filter(|token| !token.is_empty()) } diff --git a/src/services/catalog.rs b/src/services/catalog.rs index cff645b..5475f4c 100644 --- a/src/services/catalog.rs +++ b/src/services/catalog.rs @@ -146,10 +146,8 @@ impl DomainServices { 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 folders = folder_filter(folder_ids); + let folder_filter = folders.as_deref(); let mut songs = sqlx::query(concat!( song_select!(), diff --git a/src/services/favorites.rs b/src/services/favorites.rs index 6ed64e9..eb9d774 100644 --- a/src/services/favorites.rs +++ b/src/services/favorites.rs @@ -206,6 +206,12 @@ impl DomainServices { 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}"), @@ -222,9 +228,6 @@ impl DomainServices { 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 { diff --git a/src/services/playback.rs b/src/services/playback.rs index 756091b..170827b 100644 --- a/src/services/playback.rs +++ b/src/services/playback.rs @@ -288,17 +288,22 @@ impl DomainServices { .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 { - current: row - .try_get::, _>("current_track_id")? - .map(parse_uuid) - .transpose()?, + // 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: self - .songs_by_ids_lenient_on(connection, user_id, &ids) - .await?, + songs, })) } } diff --git a/src/services/shares.rs b/src/services/shares.rs index 9f81737..f06d6f1 100644 --- a/src/services/shares.rs +++ b/src/services/shares.rs @@ -212,7 +212,13 @@ impl DomainServices { 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?, + // 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?, }) } diff --git a/src/services/songs.rs b/src/services/songs.rs index 586a199..27a1858 100644 --- a/src/services/songs.rs +++ b/src/services/songs.rs @@ -198,6 +198,10 @@ impl DomainServices { 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 \ @@ -207,7 +211,7 @@ impl DomainServices { .bind(user_id.to_string()) .bind(folders.as_deref()) .bind(folders.as_deref()) - .fetch_all(self.db.pool()) + .fetch_all(&mut *connection) .await? .into_iter() .map(artist_summary_from_row) @@ -221,12 +225,12 @@ impl DomainServices { .bind(user_id.to_string()) .bind(folders.as_deref()) .bind(folders.as_deref()) - .fetch_all(self.db.pool()) + .fetch_all(&mut *connection) .await? .into_iter() .map(album_from_row) .collect::, _>>()?; - attach_album_relations(&mut *self.db.pool().acquire().await?, user_id, &mut albums).await?; + 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", @@ -236,12 +240,12 @@ impl DomainServices { .bind(user_id.to_string()) .bind(folders.as_deref()) .bind(folders.as_deref()) - .fetch_all(self.db.pool()) + .fetch_all(&mut *connection) .await? .into_iter() .map(song_from_row) .collect::, _>>()?; - attach_song_relations(&mut *self.db.pool().acquire().await?, user_id, &mut songs).await?; + attach_song_relations(&mut connection, user_id, &mut songs).await?; Ok(StarredCatalog { artists, albums, diff --git a/src/subsonic/lyrics.rs b/src/subsonic/lyrics.rs index fed9d35..98c5cf8 100644 --- a/src/subsonic/lyrics.rs +++ b/src/subsonic/lyrics.rs @@ -11,6 +11,12 @@ pub(super) async fn get_lyrics( ) -> 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) diff --git a/src/subsonic/protocol.rs b/src/subsonic/protocol.rs index 76ad39b..e53d1a5 100644 --- a/src/subsonic/protocol.rs +++ b/src/subsonic/protocol.rs @@ -309,8 +309,18 @@ pub(super) fn value_string(value: &Value) -> 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('>', ">") 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(); } From 9c8873b72af2dc629bd2699a514202356e7d5a8d Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 24 Aug 2026 13:04:22 +0200 Subject: [PATCH 8/8] test(api): make the lagged-cursor test able to fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test asserted `Send(0)` for a user drawn at random, which has no journal entries — so `latest_user_cursor` answers 0, and a branch returning a constant 0 would have passed just as well. It proved the Lagged arm was wired to something, not that it recovers the durable cursor its name claims. It now creates an account and writes one event through the real journal path, then asserts the recovered value equals the cursor that write returned. The empty case stays as a second assertion, since falling back to the base cursor rather than failing is also part of the contract. Checked by replacing the arm with `Send(0)`: the test now fails with left `Send(0)`, right `Send(1)`. Signed-off-by: InstaZDLL --- src/api/sync.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/src/api/sync.rs b/src/api/sync.rs index 6484418..4a991d0 100644 --- a/src/api/sync.rs +++ b/src/api/sync.rs @@ -252,21 +252,77 @@ pub(super) async fn send_sync_notice( 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 sync = crate::sync::SyncService::new(db); + 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, - uuid::Uuid::new_v4(), + 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)); } }