From 4733040bba9b68649ee960eccdda2df040876401 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:52:40 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`codex/m?= =?UTF-8?q?4-server-completion`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @InstaZDLL. * https://github.com/InstaZDLL/waveflow-server/pull/94#issuecomment-5231132567 The following files were modified: * `src/authentication.rs` * `src/catalog.rs` * `src/database.rs` * `src/http.rs` * `src/lib.rs` * `src/main.rs` * `src/media.rs` * `src/security.rs` * `src/services.rs` * `src/subsonic.rs` * `src/sync.rs` * `webapp/src/api.ts` * `webapp/src/main.tsx` * `webapp/src/pages.tsx` * `webapp/src/player.tsx` --- src/authentication.rs | 41 ++ src/catalog.rs | 31 ++ src/database.rs | 165 +++++- src/http.rs | 1027 +++++++++++++++++++++++++++++++---- src/lib.rs | 30 ++ src/main.rs | 18 + src/media.rs | 63 ++- src/security.rs | 65 ++- src/services.rs | 1184 ++++++++++++++++++++++++++++++++++++++++- src/subsonic.rs | 34 ++ src/sync.rs | 201 +++++++ webapp/src/api.ts | 66 +++ webapp/src/main.tsx | 3 + webapp/src/pages.tsx | 56 +- webapp/src/player.tsx | 23 + 15 files changed, 2895 insertions(+), 112 deletions(-) diff --git a/src/authentication.rs b/src/authentication.rs index e925ef8..48a2b72 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -199,6 +199,20 @@ impl AuthService { }) } + /// Revokes the session associated with an access token. + /// + /// # Examples + /// + /// ``` + /// # async fn example(service: &AuthService) -> Result<(), AuthError> { + /// service.logout("access-token").await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Returns + /// + /// `Ok(())` after revoking the session, or `AuthError::Unavailable` if the database operation fails. pub async fn logout(&self, access_token: &str) -> Result<(), AuthError> { let hash = security::token_hash(access_token); self.db @@ -208,6 +222,16 @@ impl AuthService { Ok(()) } + /// Revokes the session associated with a refresh token. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(service: &AuthService) -> Result<(), AuthError> { + /// service.revoke_refresh("refresh-token").await?; + /// # Ok(()) + /// # } + /// ``` pub async fn revoke_refresh(&self, refresh_token: &str) -> Result<(), AuthError> { let hash = security::token_hash(refresh_token); self.db @@ -217,6 +241,23 @@ impl AuthService { Ok(()) } + /// Authenticates a user with an access token or API token. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidCredentials`] when the token is missing, invalid, + /// or expired. Returns [`AuthError::Unavailable`] when the database cannot be + /// reached. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(service: &AuthService) -> Result<(), AuthError> { + /// let user = service.authenticate("access-token").await?; + /// assert!(!user.username.is_empty()); + /// # Ok(()) + /// # } + /// ``` pub async fn authenticate(&self, access_token: &str) -> Result { let hash = security::token_hash(access_token); let now = now_ms(); diff --git a/src/catalog.rs b/src/catalog.rs index 1c57491..c46b0f4 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -132,6 +132,27 @@ pub struct TrackRecord { } impl Database { + /// Lists the libraries accessible to a user, ordered case-insensitively by name and then by ID. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(database: &Database, user_id: uuid::Uuid) -> Result<(), sqlx::Error> { + /// let libraries = database.libraries_for_user(user_id).await?; + /// for library in libraries { + /// println!("{}", library.name); + /// } + /// # Ok(()) + /// # } + /// ``` + /// + /// # Arguments + /// + /// * `user_id` - The user whose library memberships are queried. + /// + /// # Returns + /// + /// The user's accessible libraries and their membership details. pub async fn libraries_for_user( &self, user_id: Uuid, @@ -163,6 +184,16 @@ impl Database { .collect() } + /// Lists all libraries in creation order. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(database: &Database) -> Result<(), sqlx::Error> { + /// let libraries = database.all_libraries().await?; + /// # Ok(()) + /// # } + /// ``` pub async fn all_libraries(&self) -> Result, sqlx::Error> { let rows = sqlx::query("SELECT id, name, root_path FROM library ORDER BY created_at") .fetch_all(self.pool()) diff --git a/src/database.rs b/src/database.rs index 5863330..79ed212 100644 --- a/src/database.rs +++ b/src/database.rs @@ -185,6 +185,20 @@ pub(crate) enum SyncOperationReservation { } impl Database { + /// Determines whether the database requires initial account setup. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(database: &Database) -> Result<(), sqlx::Error> { + /// if database.setup_required().await? { + /// // Create the initial administrator. + /// } + /// # Ok(()) + /// # } + /// ``` + /// + /// Returns `true` when the database contains no accounts, and `false` otherwise. pub async fn setup_required(&self) -> Result { let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM account") .fetch_one(&self.pool) @@ -192,6 +206,20 @@ impl Database { Ok(count == 0) } + /// Creates the first administrator account when the database contains no accounts. + /// + /// The operation is atomic and records an audit event when the account is created. + /// Returns `None` if an account already exists. + /// + /// # Examples + /// + /// ``` + /// # async fn example(db: &Database) -> Result<(), sqlx::Error> { + /// let admin_id = db.bootstrap_admin("admin", "password-hash", 1_700_000_000).await?; + /// assert!(admin_id.is_some()); + /// # Ok(()) + /// # } + /// ``` pub async fn bootstrap_admin( &self, username: &str, @@ -221,6 +249,24 @@ impl Database { Ok(inserted.then_some(id)) } + /// Opens the configured SQLite database and prepares its connection pool. + /// + /// The database directory is created when needed. SQLite foreign keys and WAL journaling + /// are enabled for the connection pool. + /// + /// # Errors + /// + /// Returns an error if the data directory cannot be created or the database connection + /// cannot be established. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(config: &Config) -> anyhow::Result<()> { + /// let database = Database::open(config).await?; + /// # Ok(()) + /// # } + /// ``` pub async fn open(config: &Config) -> anyhow::Result { tokio::fs::create_dir_all(&config.data_dir).await?; let options = SqliteConnectOptions::new() @@ -346,11 +392,48 @@ impl Database { &self.pool } + /// Acquires exclusive access to the database writer lock. + /// + /// # Examples + /// + /// ```rust,ignore + /// let _guard = database.writer_guard().await; + /// // Perform serialized write operations while `_guard` is held. + /// ``` + /// + /// # Returns + /// + /// An owned guard that releases the writer lock when dropped. pub(crate) async fn writer_guard(&self) -> OwnedMutexGuard<()> { Arc::clone(&self.writer).lock_owned().await } - #[allow(clippy::too_many_arguments)] + /// Reserves a synchronization operation and classifies its current state. + /// + /// An operation is classified as new when it is successfully inserted, incomplete + /// when it was previously reserved but not applied, and replayed when it was + /// already applied. An origin device must belong to the user and remain active. + /// + /// # Examples + /// + /// ```no_run + /// let reservation = database + /// .reserve_sync_operation( + /// &writer_guard, + /// &mut connection, + /// user_id, + /// operation_id, + /// Some(device_id), + /// &intent_hash, + /// created_at, + /// ) + /// .await?; + /// # Ok::<(), sqlx::Error>(()) + /// ``` + /// + /// # Errors + /// + /// Returns a database error if validation, reservation, or replay lookup fails. pub(crate) async fn reserve_sync_operation( &self, _writer_guard: &OwnedMutexGuard<()>, @@ -414,6 +497,26 @@ impl Database { )) } + /// Creates an account with the specified credentials and role, and records its creation. + /// + /// # Arguments + /// + /// * `username` - The account username; surrounding whitespace is removed before storage. + /// * `password_hash` - The precomputed password hash. + /// * `now_ms` - The creation and update timestamp in milliseconds since the Unix epoch. + /// + /// # Examples + /// + /// ```no_run + /// let account_id = database + /// .create_account("alice", password_hash, AccountRole::User, now_ms) + /// .await?; + /// # Ok::<(), sqlx::Error>(()) + /// ``` + /// + /// # Returns + /// + /// The UUID assigned to the new account. pub async fn create_account( &self, username: &str, @@ -732,6 +835,21 @@ impl Database { Ok(result.rows_affected() == 1) } + /// Revokes the active session identified by its access-token hash. + /// + /// # Examples + /// + /// ``` + /// # async fn example(database: &Database) -> Result<(), sqlx::Error> { + /// let access_hash = [0u8; 32]; + /// let was_revoked = database + /// .revoke_session_by_access_hash(&access_hash, 1_700_000_000_000) + /// .await?; + /// println!("Session revoked: {was_revoked}"); + /// # Ok(()) + /// # } + /// ``` + async function doc? Need Rustdoc uses no async wording concern summary. Fine. But Database may not in scope in doctest? impl method docs, Database in scope likely. Could be crate private. Good. pub async fn revoke_session_by_access_hash( &self, access_hash: &[u8], @@ -749,6 +867,21 @@ impl Database { Ok(result.rows_affected() == 1) } + /// Revokes the active session identified by its refresh-token hash. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(database: &Database, refresh_hash: &[u8], now_ms: i64) -> Result<(), sqlx::Error> { + /// let revoked = database + /// .revoke_session_by_refresh_hash(refresh_hash, now_ms) + /// .await?; + /// # let _ = revoked; + /// # Ok(()) + /// # } + /// ``` + /// + /// Returns `true` when an active session was revoked, or `false` when no matching active session exists. pub async fn revoke_session_by_refresh_hash( &self, refresh_hash: &[u8], @@ -766,6 +899,36 @@ impl Database { Ok(result.rows_affected() == 1) } + /// Creates an API token record with its name, hashed value, scopes, and creation timestamp. + /// + /// # Parameters + /// + /// * `name` - Display name for the token. + /// * `token_hash` - Hash of the token value. + /// * `scopes` - Permissions granted to the token. + /// * `now_ms` - Creation timestamp in milliseconds since the Unix epoch. + /// + /// # Returns + /// + /// The newly created token's identifier. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(db: &Database) -> Result<(), sqlx::Error> { + /// let token_id = db + /// .create_api_token( + /// user_id, + /// "Music client", + /// &token_hash, + /// &["library:read".to_owned()], + /// now_ms, + /// ) + /// .await?; + /// # let _ = token_id; + /// # Ok(()) + /// # } + /// ``` pub async fn create_api_token( &self, user_id: Uuid, diff --git a/src/http.rs b/src/http.rs index 067d27f..b36c2ed 100644 --- a/src/http.rs +++ b/src/http.rs @@ -303,6 +303,15 @@ pub struct SyncSnapshot { pub shares: Vec, } +/// Builds the application router with health, authentication, catalog, user-data, synchronization, and administration endpoints. +/// +/// # Examples +/// +/// ```no_run +/// # use crate::{router, AppState}; +/// # let state: AppState = todo!(); +/// let app = router(state); +/// ``` pub fn router(state: AppState) -> Router { Router::new() .route("/health", get(health)) @@ -392,14 +401,25 @@ pub async fn health() -> Json { }) } +/// Checks whether the database is available for serving requests. +/// +/// Responds with `200 OK` when the database is reachable, or `503 Service Unavailable` +/// when the database probe fails. +/// +/// # Examples +/// +/// ```no_run +/// let response = ready(State(state)).await; +/// assert!(response.status().is_success()); +/// ``` #[utoipa::path( - get, - path = "/ready", - tag = "probes", - responses( - (status = 200, body = ReadyResponse), - (status = 503, body = ReadyResponse) - ) +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 { @@ -425,6 +445,15 @@ pub async fn ready(State(state): State) -> Response { } } +/// Reports whether initial application setup is required. +/// +/// # Examples +/// +/// ```no_run +/// let response = setup_status(state).await?; +/// assert!(response.0.required || !response.0.required); +/// # Ok::<(), ApiError>(()) +/// ``` #[utoipa::path(get, path = "/api/v2/setup", tag = "authentication", responses((status = 200, body = SetupStatusResponse)))] pub async fn setup_status( State(state): State, @@ -433,7 +462,27 @@ pub async fn setup_status( 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)))] +/// Creates the initial administrator account during application setup. +/// +/// The request must include a valid browser origin and administrator credentials. +/// +/// # Returns +/// +/// Returns HTTP 201 with the newly created administrator's user ID. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example(state: AppState, headers: axum::http::HeaderMap) { +/// let request = axum::Json(SetupRequest { +/// username: "admin".to_owned(), +/// password: "change-me".to_owned(), +/// }); +/// let result = setup(axum::extract::State(state), headers, request).await; +/// # } +/// ``` +/// +/// #[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, @@ -495,15 +544,31 @@ pub async fn refresh( .map_err(ApiError::from) } +/// Revokes the authenticated bearer session. +/// +/// The request must include a non-empty `Bearer` authorization value. On +/// success, the handler returns HTTP 204. +/// +/// # Examples +/// +/// ```text +/// POST /api/v2/auth/logout +/// Authorization: Bearer +/// +/// HTTP/1.1 204 No Content +/// ``` +/// +/// An absent or invalid bearer token produces HTTP 401. Authentication +/// service failures produce HTTP 503. #[utoipa::path( - post, - path = "/api/v2/auth/logout", - tag = "authentication", - responses( - (status = 204), - (status = 401, body = ErrorResponse), - (status = 503, body = ErrorResponse) - ) +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, @@ -518,19 +583,30 @@ pub async fn logout( 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. +/// Authenticates a browser session and establishes refresh and CSRF cookies. +/// +/// The response contains a short-lived access token, while the rotating refresh +/// token is stored in an HttpOnly, same-site cookie. +/// +/// # Examples +/// +/// ```no_run +/// // POST /api/v2/web/auth/login with username, password, and device_name. +/// ``` +/// +/// # Returns +/// +/// The access-token response with authentication cookies attached. #[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) - ) +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, @@ -546,15 +622,27 @@ pub async fn web_login( web_auth_response(&state, &headers, tokens) } +/// Refreshes a browser session using the refresh-token cookie after validating the web request. +/// +/// # Examples +/// +/// ```no_run +/// // Send a POST request to `/api/v2/web/auth/refresh` with the refresh-token +/// // cookie and matching CSRF headers. +/// # let _endpoint = "/api/v2/web/auth/refresh"; +/// ``` +/// +/// Returns an authentication response and refreshed cookies on success. Requests +/// with invalid origin, CSRF credentials, or refresh tokens are rejected. #[utoipa::path( - post, - path = "/api/v2/web/auth/refresh", - tag = "authentication", - responses( - (status = 200, body = WebAuthResponse), - (status = 401, body = ErrorResponse), - (status = 403, body = ErrorResponse) - ) +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, @@ -570,16 +658,14 @@ pub async fn web_refresh( 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) - ) -)] +/// Logs out the browser session associated with the refresh cookie and expires the session cookies. +/// +/// # Examples +/// +/// ``` +/// let endpoint = "/api/v2/web/auth/logout"; +/// assert_eq!(endpoint, "/api/v2/web/auth/logout"); +/// ``` pub async fn web_logout( State(state): State, headers: HeaderMap, @@ -605,7 +691,18 @@ pub async fn web_logout( 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)))] +/// Queues a manual scan for a library accessible to the authenticated user. +/// +/// # Errors +/// +/// Returns an authentication error when the request lacks valid credentials, `ApiError::NotFound` when the library is unavailable to the user, or `ApiError::Unavailable` when the scan cannot be queued. +/// +/// # Examples +/// +/// ```text +/// POST /api/v2/libraries/{library_id}/scans +/// Authorization: Bearer +/// ``` pub async fn start_scan( State(state): State, Path(library_id): Path, @@ -629,7 +726,22 @@ pub async fn start_scan( 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)))] +/// Lists the libraries accessible to the authenticated user. +/// +/// # Returns +/// +/// The libraries available to the authenticated user. +/// +/// # Examples +/// +/// ```ignore +/// let response = client +/// .get("/api/v2/libraries") +/// .bearer_auth(access_token) +/// .send() +/// .await?; +/// assert!(response.status().is_success()); +/// ``` pub async fn list_libraries( State(state): State, headers: HeaderMap, @@ -643,6 +755,21 @@ pub async fn list_libraries( .map_err(db_error) } +/// Creates a library for the authenticated administrator and queues its initial scan. +/// +/// The library path must identify an existing, non-symlink directory, and the library name +/// must not be empty or consist only of whitespace. +/// +/// # Examples +/// +/// ```text +/// POST /api/v2/libraries +/// {"name":"Music","path":"/srv/music","visibility":"private"} +/// ``` +/// +/// # Returns +/// +/// The created library ID and the ID of its queued initial scan. #[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, @@ -697,7 +824,21 @@ pub async fn create_library( )) } -#[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)))] +/// Assigns a library role to a user. +/// +/// The caller must be an administrator. The library and user must exist, and the +/// owner role cannot be assigned. +/// +/// # Examples +/// +/// ``` +/// let path = "/api/v2/libraries/{library_id}/members/{user_id}"; +/// assert!(path.contains("/libraries/")); +/// assert!(path.contains("/members/")); +/// ``` +/// +/// Returns [`StatusCode::NO_CONTENT`] when the membership is updated. +/// Returns [`ApiError::Validation`] for an owner role or unknown library or user. pub async fn set_library_member( State(state): State, Path((library_id, user_id)): Path<(Uuid, Uuid)>, @@ -737,6 +878,16 @@ pub async fn set_library_member( Ok(StatusCode::NO_CONTENT) } +/// Removes a user’s membership from a library. +/// +/// Returns `404 Not Found` when the user is not a member of the library. +/// +/// # Examples +/// +/// ```ignore +/// let status = remove_library_member(state, (library_id, user_id), headers).await?; +/// assert_eq!(status, 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, @@ -762,6 +913,22 @@ pub async fn remove_library_member( } } +/// Retrieves a scan job visible to the authenticated user. +/// +/// # Examples +/// +/// ```ignore +/// let response = client +/// .get(format!("/api/v2/scans/{scan_id}")) +/// .send() +/// .await?; +/// ``` +/// +/// # Errors +/// +/// Returns an unauthorized error when authentication fails, a not-found error +/// when the scan is unavailable to the user, or a database error when lookup +/// fails. #[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, @@ -807,6 +974,27 @@ pub async fn scan_events( Ok(Sse::new(output).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))) } +/// Lists the tracks in a library that the authenticated user can access. +/// +/// The optional search query is trimmed before filtering. Results use an offset +/// and a limit between 1 and 500; the default offset is 0 and the default +/// limit is 500. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example(client: reqwest::Client, base_url: &str, library_id: &str) { +/// let response = client +/// .get(format!("{base_url}/api/v2/libraries/{library_id}/tracks?limit=100")) +/// .send() +/// .await +/// .unwrap(); +/// assert!(response.status().is_success()); +/// # } +/// ``` +/// +/// Returns the matching track records, or an API error when authentication, +/// library access, pagination, or database lookup fails. #[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, @@ -838,6 +1026,22 @@ pub async fn list_tracks( Ok(Json(tracks)) } +/// Retrieves a catalog track visible to the authenticated user. +/// +/// # Examples +/// +/// ```no_run +/// # use uuid::Uuid; +/// # let track_id = Uuid::nil(); +/// // Request: GET /api/v2/tracks/{track_id} +/// let _track_id = track_id; +/// ``` +/// +/// Returns the requested track, or `ApiError::NotFound` when it is unavailable to the user. +/// +/// # Errors +/// +/// Returns an authentication error when the request lacks valid credentials. #[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, @@ -856,6 +1060,24 @@ pub async fn get_track( .ok_or(ApiError::NotFound) } +/// Lists albums accessible to the authenticated user, optionally filtered by library and paginated. +/// +/// # Arguments +/// +/// * `library_id` — Restricts results to a specific library. +/// * `offset` — Number of albums to skip. +/// * `limit` — Maximum number of albums to return. +/// +/// # Returns +/// +/// The accessible albums for the requested page. +/// +/// # Examples +/// +/// ``` +/// let path = "/api/v2/albums?offset=0&limit=20"; +/// assert!(path.starts_with("/api/v2/albums")); +/// ``` #[utoipa::path(get, path = "/api/v2/albums", tag = "catalog", params(("library_id" = Option, Query), ("offset" = Option, Query), ("limit" = 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, @@ -1010,6 +1232,20 @@ pub async fn list_playlists( .map_err(service_error) } +/// Creates a playlist for the authenticated user. +/// +/// # Examples +/// +/// ``` +/// let request = CreatePlaylistRequest { +/// name: "Favorites".to_owned(), +/// track_ids: Vec::new(), +/// }; +/// assert_eq!(request.name, "Favorites"); +/// ``` +/// +/// Returns `201 Created` with the new playlist, or an error when authentication, +/// authorization, validation, or playlist creation fails. #[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, @@ -1041,7 +1277,18 @@ pub async fn get_playlist( .map_err(service_error) } -#[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 = 422, body = ErrorResponse)))] +/// Updates a playlist's metadata and track membership for the authenticated user. +/// +/// # Examples +/// +/// ```ignore +/// let playlist = update_playlist(state, playlist_id, headers, request) +/// .await +/// .expect("playlist update should succeed"); +/// assert_eq!(playlist.0.id, playlist_id); +/// ``` +/// +/// Returns the updated playlist. pub async fn update_playlist( State(state): State, Path(playlist_id): Path, @@ -1067,6 +1314,15 @@ pub async fn update_playlist( .map_err(service_error) } +/// Deletes a playlist owned by the authenticated user. +/// +/// # Examples +/// +/// ``` +/// use axum::http::StatusCode; +/// +/// assert_eq!(StatusCode::NO_CONTENT, StatusCode::from_u16(204).unwrap()); +/// ``` #[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, @@ -1122,6 +1378,17 @@ pub async fn remove_favorite( set_favorite(state, headers, &entity_type, entity_id, false).await } +/// Sets or clears a user's favorite status for an entity. +/// +/// # Examples +/// +/// ```no_run +/// let status = set_favorite(state, headers, "track", entity_id, true).await?; +/// assert_eq!(status, StatusCode::NO_CONTENT); +/// # Ok::<(), ApiError>(()) +/// ``` +/// +/// `entity_type` identifies the kind of entity being updated. async fn set_favorite( state: AppState, headers: HeaderMap, @@ -1139,6 +1406,21 @@ async fn set_favorite( Ok(StatusCode::NO_CONTENT) } +/// Sets a user's rating for a track, album, or artist. +/// +/// `entity_type` must identify a supported entity kind, and `rating` is supplied +/// in the request body. +/// +/// # Returns +/// +/// `StatusCode::NO_CONTENT` when the rating is saved. +/// +/// # Examples +/// +/// ```no_run +/// // PUT /api/v2/ratings/track/{entity_id} +/// // {"rating": 5} +/// ``` #[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, @@ -1156,7 +1438,18 @@ pub async fn set_rating( 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)))] +/// Lists ratings for the authenticated user. +/// +/// # Returns +/// +/// The user's ratings, or an API error if authentication or retrieval fails. +/// +/// # Examples +/// +/// ``` +/// let endpoint = "/api/v2/ratings"; +/// assert_eq!(endpoint, "/api/v2/ratings"); +/// ``` pub async fn list_ratings( State(state): State, headers: HeaderMap, @@ -1170,7 +1463,19 @@ pub async fn list_ratings( .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)))] +/// Records a track playback or submission event for the authenticated user. +/// +/// # Examples +/// +/// ```ignore +/// let status = create_scrobble(state, headers, Json(request)).await?; +/// assert_eq!(status, StatusCode::NO_CONTENT); +/// # Ok::<(), ApiError>(()) +/// ``` +/// +/// # Returns +/// +/// `StatusCode::NO_CONTENT` when the scrobble is recorded successfully. pub async fn create_scrobble( State(state): State, headers: HeaderMap, @@ -1192,6 +1497,23 @@ pub async fn create_scrobble( Ok(StatusCode::NO_CONTENT) } +/// Lists the authenticated user's listening history with an optional result limit. +/// +/// The limit defaults to 200 and must be between 1 and the maximum synchronization +/// limit. +/// +/// # Examples +/// +/// ```text +/// GET /api/v2/history?limit=50 +/// ``` +/// +/// The response contains the user's history entries. +/// +/// # Errors +/// +/// Returns an authentication error for unauthenticated requests or a validation +/// error when the limit is outside the allowed range. #[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, @@ -1211,6 +1533,23 @@ pub async fn list_history( .map_err(service_error) } +/// Reports whether media transcoding is available and how many transcodes are currently active. +/// +/// Authentication is required. +/// +/// # Examples +/// +/// ```text +/// GET /api/v2/transcode/status +/// Authorization: Bearer +/// ``` +/// +/// The response contains `available` and `active` fields. +/// +/// # Errors +/// +/// Returns an authentication error when the request does not include valid credentials. +/// #[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, @@ -1223,6 +1562,19 @@ pub async fn transcode_status( })) } +/// Lists all users for an authenticated administrator. +/// +/// # Returns +/// +/// The users configured in the application. +/// +/// # Examples +/// +/// ```no_run +/// let users = list_users(state, headers).await?; +/// assert!(!users.0.is_empty()); +/// # Ok::<(), ApiError>(()) +/// ``` #[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, @@ -1238,6 +1590,17 @@ pub async fn list_users( .map_err(service_error) } +/// Creates a web user after verifying that the authenticated actor is an administrator. +/// +/// # Examples +/// +/// ```no_run +/// // POST /api/v2/admin/users with a `CreateUserRequest` JSON body. +/// ``` +/// +/// # Returns +/// +/// The created user and HTTP status `201 Created`. #[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, @@ -1259,6 +1622,41 @@ pub async fn create_user( Ok((StatusCode::CREATED, Json(user))) } +/// Updates an existing user's role, account status, library access, and credentials. +/// +/// The caller must be authenticated as an administrator. +/// +/// # Parameters +/// +/// * `username` - Username of the account to update. +/// * `request` - Account fields to change; omitted fields retain their current values. +/// +/// # Returns +/// +/// The updated user account. +/// +/// # Examples +/// +/// ```no_run +/// # use axum::{extract::{Path, State}, Json}; +/// # use axum::http::HeaderMap; +/// # async fn example(state: AppState, headers: HeaderMap) { +/// let request = UpdateUserRequest { +/// role: None, +/// disabled: Some(false), +/// library_ids: None, +/// subsonic_password: None, +/// web_password: None, +/// }; +/// +/// let result = update_user( +/// State(state), +/// Path(String::from("alice")), +/// headers, +/// Json(request), +/// ).await; +/// # } +/// ``` #[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, @@ -1288,7 +1686,19 @@ pub async fn update_user( .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)))] +/// Deletes the specified user account when requested by an administrator. +/// +/// # Errors +/// +/// Returns an error if authentication fails, the authenticated user is not an +/// administrator, or the user does not exist. +/// +/// # Examples +/// +/// ```no_run +/// // Send an authenticated DELETE request to: +/// // DELETE /api/v2/admin/users/alice +/// ``` pub async fn delete_user( State(state): State, Path(username): Path, @@ -1304,6 +1714,23 @@ pub async fn delete_user( Ok(StatusCode::NO_CONTENT) } +/// Creates or replaces a user's Subsonic credential. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example() -> Result<(), ApiError> { +/// let response = set_subsonic_credential(state, username, headers, request).await?; +/// assert!(!response.0.api_key.is_empty()); +/// # Ok(()) +/// # } +/// ``` +/// +/// The generated API key is returned in the response. +/// +/// # Errors +/// +/// Returns an error if authentication, authorization, credential creation, or request validation fails. #[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, @@ -1321,6 +1748,21 @@ pub async fn set_subsonic_credential( Ok(Json(SubsonicCredentialResponse { api_key })) } +/// Revokes the specified user's Subsonic credential. +/// +/// # Arguments +/// +/// * `username` - Username whose Subsonic credential should be revoked. +/// +/// # Returns +/// +/// The HTTP `204 No Content` status on success. +/// +/// # Examples +/// +/// ```text +/// DELETE /api/v2/admin/users/alice/subsonic-credential +/// ``` #[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, @@ -1372,6 +1814,26 @@ pub async fn get_queue( .map_err(service_error) } +/// Saves the authenticated user's playback queue. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example(state: AppState, headers: HeaderMap, request: SaveQueueRequest) { +/// let status = save_queue( +/// axum::extract::State(state), +/// headers, +/// axum::Json(request), +/// ) +/// .await +/// .unwrap(); +/// assert_eq!(status, axum::http::StatusCode::NO_CONTENT); +/// # } +/// ``` +/// +/// # Returns +/// +/// `StatusCode::NO_CONTENT` when the queue is saved successfully. #[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, @@ -1395,6 +1857,19 @@ pub async fn save_queue( Ok(StatusCode::NO_CONTENT) } +/// Lists the authenticated user's shares. +/// +/// # Returns +/// +/// The user's shares as API response objects. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example(state: AppState, headers: HeaderMap) { +/// let Json(shares) = list_shares(State(state), headers).await.unwrap(); +/// # } +/// ``` #[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, @@ -1412,7 +1887,17 @@ pub async fn list_shares( 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)))] +/// Creates a share for the requested tracks. +/// +/// # Examples +/// +/// ```no_run +/// // Submit a POST request to `/api/v2/shares` with the selected track IDs. +/// ``` +/// +/// # Returns +/// +/// A `201 Created` response containing the created share. pub async fn create_share( State(state): State, headers: HeaderMap, @@ -1434,6 +1919,14 @@ pub async fn create_share( Ok((StatusCode::CREATED, Json(share_response(&state, share)))) } +/// Updates a share owned by the authenticated user. +/// +/// # Examples +/// +/// ```no_run +/// // PATCH /api/v2/shares/{share_id} +/// // JSON body: { "description": "Shared playlist", "expires_at": null } +/// ``` #[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, @@ -1457,6 +1950,18 @@ pub async fn update_share( Ok(Json(share_response(&state, share))) } +/// Deletes a share owned by the authenticated user. +/// +/// Returns `204 No Content` when the share is deleted, or an API error when +/// authentication fails or the share is unavailable. +/// +/// # Examples +/// +/// ```no_run +/// let share_id = uuid::Uuid::new_v4(); +/// let endpoint = format!("/api/v2/shares/{share_id}"); +/// assert!(endpoint.contains(&share_id.to_string())); +/// ``` #[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, @@ -1473,6 +1978,14 @@ pub async fn delete_share( Ok(StatusCode::NO_CONTENT) } +/// Converts a service share into its API representation, including its public URL when available. +/// +/// # Examples +/// +/// ```ignore +/// let response = share_response(&state, share); +/// assert_eq!(response.id, share_id); +/// ``` fn share_response(state: &AppState, share: crate::services::ShareItem) -> ShareResponse { let url = share.url_token.map(|token| { let path = format!("/share/{token}"); @@ -1492,16 +2005,31 @@ fn share_response(state: &AppState, share: crate::services::ShareItem) -> ShareR } } +/// Retrieves durable synchronization changes after a cursor. +/// +/// `after` defaults to the beginning of the change log, and `limit` defaults to +/// the standard synchronization page size. The limit must be greater than zero +/// and no greater than the maximum synchronization page size. +/// +/// # Examples +/// +/// ```text +/// GET /api/v2/sync/changes?after=42&limit=100 +/// ``` +/// +/// # Returns +/// +/// A page of synchronization changes after the requested cursor. #[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 = 422, body = ErrorResponse) - ) +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 = 422, body = ErrorResponse) +) )] pub async fn sync_changes( State(state): State, @@ -1522,12 +2050,20 @@ pub async fn sync_changes( .map_err(sync_error) } -#[utoipa::path( - get, - path = "/api/v2/sync/snapshot", - tag = "sync", - responses((status = 200, body = SyncSnapshot), (status = 401, body = ErrorResponse)) -)] +/// Retrieves the authenticated user's synchronization snapshot. +/// +/// # Examples +/// +/// ```no_run +/// let result = sync_snapshot( +/// axum::extract::State(todo!()), +/// axum::http::HeaderMap::new(), +/// ).await; +/// assert!(result.is_ok()); +/// ``` +/// +/// The snapshot includes the synchronization cursor, playlists, favorites, ratings, +/// queue, listening history, and shares. pub async fn sync_snapshot( State(state): State, headers: HeaderMap, @@ -1563,17 +2099,26 @@ pub async fn sync_snapshot( })) } -#[utoipa::path( - put, - path = "/api/v2/sync/ack", - tag = "sync", - request_body = SyncAckRequest, - responses( - (status = 204), - (status = 401, body = ErrorResponse), - (status = 422, body = ErrorResponse) - ) -)] +/// Records the synchronization cursor acknowledged by a device. +/// +/// # Errors +/// +/// Returns a validation error if the synchronization service rejects the +/// acknowledgement. +/// +/// # Examples +/// +/// A client acknowledges a cursor with a request such as: +/// +/// ```text +/// PUT /api/v2/sync/ack +/// Content-Type: application/json +/// +/// {"device_id":"","cursor":42} +/// ``` +/// +/// On success, the endpoint responds with `204 No Content`. +pub async fn sync_ack( pub async fn sync_ack( State(state): State, headers: HeaderMap, @@ -1591,19 +2136,32 @@ pub async fn sync_ack( 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) - ) +/// Upgrades an authenticated request to a WebSocket that delivers synchronization cursor notifications. +/// +/// Clients should retrieve durable changes after receiving a notification; WebSocket delivery is only a wake-up signal. +/// +/// # Examples +/// +/// ```no_run +/// # use axum::extract::Query; +/// # let query = Query(SyncQuery { after: Some(0) }); +/// # let _ = query; +/// ``` +/// +/// # Errors +/// +/// Returns a validation error when `after` is negative and an authentication error when the request lacks valid credentials. +/// +/// #[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, @@ -1621,6 +2179,19 @@ pub async fn sync_socket( .into_response()) } +/// Serves a synchronization WebSocket for an authenticated user. +/// +/// Sends updates newer than the supplied cursor, forwards subsequent synchronization +/// notifications, responds to WebSocket control frames, and closes when the connection +/// or synchronization subscription ends. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example(socket: WebSocket, state: AppState, user_id: Uuid) { +/// serve_sync_socket(socket, state, user_id, 0).await; +/// # } +/// ``` 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(); @@ -1671,6 +2242,19 @@ enum SyncNoticeAction { Close, } +/// Classifies a synchronization notice for a user connection. +/// +/// User-specific notices produce a cursor notification, unrelated notices are +/// skipped, lagged subscriptions recover using the user's latest durable +/// cursor, and closed subscriptions terminate the connection. +/// +/// # Examples +/// +/// ```ignore +/// let action = sync_notice_action(&sync, user_id, notice).await?; +/// assert!(matches!(action, SyncNoticeAction::Send(_) | SyncNoticeAction::Continue)); +/// # Ok::<(), sqlx::Error>(()) +/// ``` async fn sync_notice_action( sync: &crate::sync::SyncService, user_id: Uuid, @@ -1689,6 +2273,29 @@ async fn sync_notice_action( } } +/// Sends a synchronization cursor notification through a WebSocket connection. +/// +/// # Parameters +/// +/// * `sender` - WebSocket sink used to deliver the notification. +/// * `cursor` - Durable synchronization cursor to include in the notification. +/// +/// # Returns +/// +/// `Ok(())` when the notification is sent successfully; otherwise, the WebSocket send error. +/// +/// # Examples +/// +/// ```no_run +/// # use axum::extract::ws::{Message, WebSocket}; +/// # use futures_util::stream::SplitSink; +/// # async fn example( +/// # sender: &mut SplitSink, +/// # ) -> Result<(), axum::Error> { +/// send_sync_notice(sender, 42).await?; +/// # Ok(()) +/// # } +/// ``` async fn send_sync_notice( sender: &mut futures_util::stream::SplitSink, cursor: i64, @@ -1718,6 +2325,15 @@ impl From for ApiError { } impl IntoResponse for ApiError { + /// Converts the API error into an HTTP response with its status code and error payload. + /// + /// # Examples + /// + /// ``` + /// let response = ApiError::NotFound.into_response(); + /// + /// assert_eq!(response.status(), StatusCode::NOT_FOUND); + /// ``` fn into_response(self) -> Response { let (status, code, message) = match self { Self::Unauthorized => ( @@ -1742,6 +2358,23 @@ impl IntoResponse for ApiError { } } +/// Builds the web authentication response and sets the refresh-token and CSRF cookies. +/// +/// # Examples +/// +/// ```no_run +/// let response = web_auth_response(&state, &headers, tokens)?; +/// # Ok::<(), ApiError>(()) +/// ``` +/// +/// The refresh token is stored in an `HttpOnly` cookie, while the CSRF token is +/// returned in a cookie available to browser scripts. Both cookies use the +/// configured token lifetime and HTTPS security settings. +/// +/// # Returns +/// +/// The authentication response containing the access-token payload and +/// authentication cookies. fn web_auth_response( state: &AppState, _headers: &HeaderMap, @@ -1773,12 +2406,40 @@ fn web_auth_response( Ok(response) } +/// Appends a `Set-Cookie` header to an HTTP response. +/// +/// Returns an error when the cookie value cannot be represented as an HTTP header value. +/// +/// # Examples +/// +/// ``` +/// let mut response = Response::new(Body::empty()); +/// append_cookie(&mut response, "session=abc".to_owned()).unwrap(); +/// +/// assert_eq!( +/// response.headers().get("set-cookie").unwrap(), +/// "session=abc" +/// ); +/// ``` 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(()) } +/// Builds a `Set-Cookie` value that immediately expires the named cookie. +/// +/// HttpOnly cookies use the web-auth path; other cookies use the root path. +/// The resulting cookie may also include `HttpOnly` and `Secure` attributes. +/// +/// # Examples +/// +/// ``` +/// assert_eq!( +/// expired_cookie("session", true, true), +/// "session=; Path=/api/v2/web/auth; SameSite=Strict; Max-Age=0; HttpOnly; Secure" +/// ); +/// ``` fn expired_cookie(name: &str, http_only: bool, secure: bool) -> String { format!( "{name}=; Path={}; SameSite=Strict; Max-Age=0{}{}", @@ -1788,16 +2449,49 @@ fn expired_cookie(name: &str, http_only: bool, secure: bool) -> String { ) } +/// Determines whether cookies should be marked as secure based on the configured public URL. +/// +/// # Examples +/// +/// ``` +/// let state = AppState { +/// public_url: Some("https://example.com".to_owned()), +/// ..Default::default() +/// }; +/// +/// assert!(secure_cookies(&state)); +/// ``` fn secure_cookies(state: &AppState) -> bool { public_url_is_https(state.public_url.as_deref()) } +/// Determines whether a configured public URL uses HTTPS. +/// +/// # Examples +/// +/// ``` +/// assert!(public_url_is_https(Some("https://example.com"))); +/// assert!(!public_url_is_https(Some("http://example.com"))); +/// assert!(!public_url_is_https(None)); +/// ``` 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") } +/// Extracts a non-empty cookie value by name from the request headers. +/// +/// # Examples +/// +/// ``` +/// use axum::http::{header, HeaderMap, HeaderValue}; +/// +/// let mut headers = HeaderMap::new(); +/// headers.insert(header::COOKIE, HeaderValue::from_static("session=abc123")); +/// +/// assert_eq!(cookie_value(&headers, "session"), Some("abc123")); +/// ``` fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { headers .get_all(header::COOKIE) @@ -1808,6 +2502,19 @@ fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { .find_map(|(key, value)| (key == name && !value.is_empty()).then_some(value)) } +/// Validates the request origin and CSRF credentials for a browser-authenticated request. +/// +/// # Errors +/// +/// Returns [`ApiError::Forbidden`] when the origin, CSRF cookie, or CSRF header is +/// missing or invalid. +/// +/// # Examples +/// +/// ```rust,ignore +/// let result = validate_web_request(&state, &headers); +/// assert!(result.is_ok()); +/// ``` 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)?; @@ -1821,6 +2528,25 @@ fn validate_web_request(state: &AppState, headers: &HeaderMap) -> Result<(), Api Ok(()) } +/// Validates that a web request has an acceptable origin. +/// +/// The origin must use HTTP or HTTPS, contain no path, query, or fragment, and +/// match the configured public origin or the request's `Host` header. +/// +/// # Errors +/// +/// Returns [`ApiError::Forbidden`] when the origin is missing, malformed, or +/// does not match the expected host. Returns [`ApiError::Unavailable`] when +/// the configured public URL is invalid. +/// +/// # Examples +/// +/// ```no_run +/// # let state: AppState = todo!(); +/// let headers = axum::http::HeaderMap::new(); +/// validate_web_origin(&state, &headers)?; +/// # Ok::<(), ApiError>(()) +/// ``` fn validate_web_origin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> { let origin = headers .get(header::ORIGIN) @@ -1854,6 +2580,25 @@ fn validate_web_origin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiE } } +/// Extracts a non-empty bearer token from the `Authorization` header. +/// +/// # Examples +/// +/// ``` +/// use http::{header, HeaderMap, HeaderValue}; +/// +/// let mut headers = HeaderMap::new(); +/// headers.insert( +/// header::AUTHORIZATION, +/// HeaderValue::from_static("Bearer example-token"), +/// ); +/// +/// assert_eq!(bearer_token(&headers), Some("example-token")); +/// ``` +/// +/// # Returns +/// +/// The bearer token when the header contains a non-empty `Bearer ` value, or `None` otherwise. fn bearer_token(headers: &HeaderMap) -> Option<&str> { headers .get(header::AUTHORIZATION)? @@ -1863,6 +2608,15 @@ fn bearer_token(headers: &HeaderMap) -> Option<&str> { .filter(|token| !token.is_empty()) } +/// Authenticates a request using its bearer token. +/// +/// Returns an unauthorized error when the request has no bearer token or when authentication fails. +/// +/// # Examples +/// +/// ```ignore +/// let user = authenticated(&state, &headers).await?; +/// ``` async fn authenticated( state: &AppState, headers: &HeaderMap, @@ -1871,6 +2625,17 @@ async fn authenticated( state.auth.authenticate(token).await.map_err(ApiError::from) } +/// Ensures that the authenticated user has administrator privileges. +/// +/// # Examples +/// +/// ```ignore +/// require_admin(&user)?; +/// ``` +/// +/// # Errors +/// +/// Returns [`ApiError::Forbidden`] when the user does not have the administrator role. fn require_admin(user: &crate::authentication::AuthUser) -> Result<(), ApiError> { if user.role == crate::database::AccountRole::Admin { Ok(()) @@ -1879,6 +2644,20 @@ fn require_admin(user: &crate::authentication::AuthUser) -> Result<(), ApiError> } } +/// Builds the mutation context for an authenticated user's request, validating any supplied device identifier. +/// +/// # Errors +/// +/// Returns [`ApiError::Validation`] when the specified device does not belong to the user. +/// +/// # Examples +/// +/// ```no_run +/// let context = mutation_context(&state, &headers, user_id).await?; +/// assert!(context.origin_device_id.is_none()); +/// # Ok::<(), ApiError>(()) +/// ``` +async fn mutation_context( async fn mutation_context( state: &AppState, headers: &HeaderMap, @@ -1903,6 +2682,38 @@ async fn mutation_context( }) } +/// Parses an optional UUID from an HTTP header. +/// +/// An absent header produces `None`. A present header must contain a valid UUID; +/// otherwise, the function returns a validation error. +/// +/// # Examples +/// +/// ``` +/// use axum::http::{HeaderMap, HeaderValue}; +/// use uuid::Uuid; +/// +/// let mut headers = HeaderMap::new(); +/// headers.insert("x-device-id", HeaderValue::from_static( +/// "550e8400-e29b-41d4-a716-446655440000", +/// )); +/// +/// let device_id = optional_uuid_header(&headers, "x-device-id").unwrap(); +/// assert_eq!( +/// device_id, +/// Some(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()) +/// ); +/// ``` +/// +/// # Errors +/// +/// Returns `ApiError::Validation` when the header value is not valid UTF-8 or +/// does not contain a valid UUID. +/// +/// # Returns +/// +/// The parsed UUID when the header is present and valid, or `None` when the +/// header is absent. fn optional_uuid_header(headers: &HeaderMap, name: &'static str) -> Result, ApiError> { headers .get(name) @@ -1916,11 +2727,27 @@ fn optional_uuid_header(headers: &HeaderMap, name: &'static str) -> Result ApiError { tracing::error!(error = %error, "catalog database operation failed"); ApiError::Unavailable } +/// Converts a synchronization error into the corresponding API error. +/// +/// # Examples +/// +/// ``` +/// let error = sync_error(crate::sync::SyncError::Invalid); +/// assert!(matches!(error, ApiError::Validation)); +/// ``` fn sync_error(error: crate::sync::SyncError) -> ApiError { match error { crate::sync::SyncError::Invalid => ApiError::Validation, @@ -1929,10 +2756,16 @@ fn sync_error(error: crate::sync::SyncError) -> ApiError { } } -/// 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. +/// Converts service-layer failures into API errors while hiding whether a forbidden resource exists. +/// +/// Forbidden resources are reported as not found to prevent resource-existence leaks. +/// +/// # Examples +/// +/// ``` +/// let error = service_error(crate::services::ServiceError::Forbidden); +/// assert!(matches!(error, ApiError::NotFound)); +/// ``` fn service_error(error: crate::services::ServiceError) -> ApiError { use crate::services::ServiceError; match error { diff --git a/src/lib.rs b/src/lib.rs index 9edf094..c88cc16 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -192,6 +192,22 @@ pub struct AppState { )] pub struct ApiDoc; +/// Initializes the application state from the supplied configuration. +/// +/// Opens and migrates the database, verifies the instance key, and initializes +/// the authentication, scanning, media, synchronization, and domain services. +/// Returns an error if initialization fails or if the instance key does not +/// match the database. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example(config: &Config) -> anyhow::Result<()> { +/// let state = initialize(config).await?; +/// # let _ = state; +/// # Ok(()) +/// # } +/// ``` pub async fn initialize(config: &Config) -> anyhow::Result { let db = database::Database::open(config).await?; db.migrate().await?; @@ -233,6 +249,20 @@ pub async fn initialize(config: &Config) -> anyhow::Result { }) } +/// Builds the application router with API, media, Subsonic, OpenAPI, and web client routes. +/// +/// The router applies request tracing, request-ID propagation, body-size limits, timeouts, +/// and optional CORS configuration from `config`. +/// +/// # Examples +/// +/// ```no_run +/// # use crate::{app, AppState, Config}; +/// # let config: Config = unimplemented!(); +/// # let state: AppState = unimplemented!(); +/// let router = app(&config, state); +/// # let _ = router; +/// ``` pub fn app(config: &Config, state: AppState) -> Router { let openapi = ApiDoc::openapi(); let openapi_for_route = openapi.clone(); diff --git a/src/main.rs b/src/main.rs index 4234baf..088ffd9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,24 @@ async fn main() -> anyhow::Result<()> { } } +/// Starts the WaveFlow HTTP server and its background maintenance tasks. +/// +/// # Errors +/// +/// Returns an error if the listener cannot bind, its local address cannot be +/// determined, or the HTTP server fails. +/// +/// # Examples +/// +/// ```no_run +/// # async fn example( +/// # config: Config, +/// # state: waveflow_server::AppState, +/// # ) -> anyhow::Result<()> { +/// serve(config, state).await?; +/// # Ok(()) +/// # } +/// ``` async fn serve(config: Config, state: waveflow_server::AppState) -> anyhow::Result<()> { let bind_addr = config.bind_addr; if config.public_url.is_none() { diff --git a/src/media.rs b/src/media.rs index 19c0953..85c443d 100644 --- a/src/media.rs +++ b/src/media.rs @@ -91,6 +91,17 @@ pub enum MediaError { } impl MediaService { + /// Initializes the media service and prepares its transcoding environment. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(config: Config) -> anyhow::Result<()> { + /// let service = MediaService::initialize(&config).await?; + /// # let _ = service; + /// # Ok(()) + /// # } + /// ``` pub async fn initialize(config: &Config) -> anyhow::Result { check_tool(&config.ffmpeg_path, "ffmpeg").await?; check_tool(&config.ffprobe_path, "ffprobe").await?; @@ -123,16 +134,64 @@ impl MediaService { &self.inner.ffprobe } + /// Reports the number of transcodings currently in progress. + /// + /// # Returns + /// + /// The number of active transcoding operations. + /// + /// # Examples + /// + /// ```no_run + /// # let service: MediaService = todo!(); + /// let active = service.active_transcodes(); + /// assert_eq!(active, 0); + /// ``` pub fn active_transcodes(&self) -> usize { self.inner.active_transcodes.load(Ordering::Relaxed) } - /// The service is only constructed after both FFmpeg tools pass startup - /// detection, so an initialized instance is the capability signal. + /// Indicates whether FFmpeg-based transcoding is available. + /// + /// # Examples + /// + /// ``` + /// # fn example(service: &MediaService) { + /// let available = service.transcoding_available(); + /// assert_eq!(available, service.transcoding_available()); + /// # } + /// ``` + /// + /// # Returns + /// + /// `true` if transcoding is available, `false` otherwise. pub fn transcoding_available(&self) -> bool { self.inner.transcoding_available } + /// Serves an available track as its original file or a transcoded audio stream. + /// + /// Transcoded streams may begin at the requested offset. Byte ranges are supported + /// for original files and completed transcoded files. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(service: &MediaService, user_id: Uuid, track: StreamTrack, query: StreamQuery) { + /// let response = service.serve(user_id, track, query, None).await?; + /// # let _: Response = response; + /// # Ok::<(), MediaError>(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns an error when the track is unavailable, its path is invalid, the + /// requested output parameters are invalid, or the requested range cannot be served. + /// + /// # Returns + /// + /// The HTTP response containing the requested media stream. pub async fn serve( &self, user_id: Uuid, diff --git a/src/security.rs b/src/security.rs index 3a40433..35fbe7d 100644 --- a/src/security.rs +++ b/src/security.rs @@ -46,6 +46,23 @@ pub struct EncryptedSecret { } impl SecretBox { + /// Loads an instance key from `path`, creating and securely storing a random key when the file does not exist. + /// + /// If multiple processes create the file concurrently, all callers use the key ultimately stored at `path`. + /// + /// # Errors + /// + /// Returns an error if the key cannot be read or created, or if the stored key is invalid. + /// + /// # Examples + /// + /// ``` + /// # use std::fs; + /// # let path = std::env::temp_dir().join(format!("instance-key-{}", std::process::id())); + /// let secret_box = SecretBox::load_or_create(&path)?; + /// # fs::remove_file(path)?; + /// # Ok::<(), SecurityError>(()) + /// ``` pub fn load_or_create(path: &Path) -> Result { let key = match std::fs::read(path) { Ok(bytes) => bytes, @@ -63,6 +80,20 @@ impl SecretBox { Self::from_key_bytes(&key) } + /// Creates a secret box from a 32-byte instance key. + /// + /// # Errors + /// + /// Returns [`SecurityError::InvalidInstanceKey`] when `key` is not exactly 32 bytes. + /// + /// # Examples + /// + /// ``` + /// let secret_box = SecretBox::from_key_bytes(&[0u8; 32])?; + /// # let _ = secret_box; + /// # Ok::<(), SecurityError>(()) + /// ``` + pub fn from_key_bytes(key: &[u8]) -> Result { pub fn from_key_bytes(key: &[u8]) -> Result { let key: [u8; INSTANCE_KEY_BYTES] = key .try_into() @@ -74,9 +105,22 @@ impl SecretBox { }) } - /// Derives a stable, unforgeable bearer token without persisting any - /// reversible token material. The domain label prevents reuse for another - /// keyed purpose from producing the same output. + /// Derives a stable, URL-safe bearer token for a share. + /// + /// The token is deterministically bound to both the instance key and the share + /// identifier, without requiring token material to be persisted. + /// + /// # Examples + /// + /// ``` + /// let secret_box = SecretBox::from_key_bytes(&[7u8; 32]).unwrap(); + /// let share_id = uuid::Uuid::nil(); + /// + /// let token = secret_box.derive_share_token(share_id); + /// + /// assert!(token.starts_with("wfs_")); + /// assert_eq!(token, secret_box.derive_share_token(share_id)); + /// ``` pub fn derive_share_token(&self, share_id: uuid::Uuid) -> String { let mut hasher = blake3::Hasher::new_keyed(&self.key); hasher.update(b"waveflow/share-token/v1\0"); @@ -87,6 +131,21 @@ impl SecretBox { ) } + /// Encrypts plaintext into an authenticated secret containing a random nonce. + /// + /// # Returns + /// + /// The encrypted secret, or a [`SecurityError`] if encryption fails. + /// + /// # Examples + /// + /// ``` + /// let secret_box = SecretBox::from_key_bytes(&[0u8; 32]).unwrap(); + /// let encrypted = secret_box.encrypt(b"secret data").unwrap(); + /// + /// assert!(!encrypted.ciphertext.is_empty()); + /// ``` + pub fn encrypt pub fn encrypt(&self, plaintext: &[u8]) -> Result { let mut nonce = [0u8; NONCE_BYTES]; OsRng.fill_bytes(&mut nonce); diff --git a/src/services.rs b/src/services.rs index 62b74d0..bb18483 100644 --- a/src/services.rs +++ b/src/services.rs @@ -308,6 +308,14 @@ pub enum ServiceError { } impl From for ServiceError { + /// Converts a synchronization error into the corresponding service error. + /// + /// # Examples + /// + /// ``` + /// let error: ServiceError = crate::sync::SyncError::Invalid.into(); + /// assert!(matches!(error, ServiceError::Invalid)); + /// ``` fn from(error: crate::sync::SyncError) -> Self { match error { crate::sync::SyncError::Invalid => Self::Invalid, @@ -318,6 +326,17 @@ impl From for ServiceError { } impl DomainServices { + /// Creates domain services backed by the database, secret-management service, and synchronization service. + /// + /// # Examples + /// + /// ```no_run + /// let services = DomainServices::new( + /// todo!(), // Database + /// std::sync::Arc::new(todo!()), // SecretBox + /// todo!(), // SyncService + /// ); + /// ``` pub fn new(db: Database, secret_box: Arc, sync: SyncService) -> Self { Self { db, @@ -326,6 +345,29 @@ impl DomainServices { } } + /// Creates the initial administrator account. + /// + /// The username must be valid and the password must contain at least 12 + /// characters. This operation fails if an administrator has already been + /// created. + /// + /// # Errors + /// + /// Returns `ServiceError::Invalid` for invalid credentials, + /// `ServiceError::Unavailable` if password hashing cannot complete, or + /// `ServiceError::Conflict` if initialization has already occurred. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(services: &DomainServices) -> Result<(), ServiceError> { + /// let admin_id = services + /// .bootstrap_admin("admin", "a-secure-password") + /// .await?; + /// # let _ = admin_id; + /// # Ok(()) + /// # } + /// ``` pub async fn bootstrap_admin( &self, username: &str, @@ -345,6 +387,27 @@ impl DomainServices { .ok_or(ServiceError::Conflict) } + /// Finds the enabled Subsonic credential associated with a username. + /// + /// Username matching is case-insensitive. + /// + /// # Arguments + /// + /// * `username` - The username to search for. + /// + /// # Returns + /// + /// The matching credential record, or `None` when no enabled account has that username. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(services: &DomainServices) -> Result<(), ServiceError> { + /// let credential = services.credential_by_username("alice").await?; + /// assert!(credential.is_some()); + /// # Ok(()) + /// # } + /// ``` pub async fn credential_by_username( &self, username: &str, @@ -692,6 +755,21 @@ impl DomainServices { )) } + /// Loads songs by ID for a user, requiring every requested song to be visible and available. + /// + /// # Examples + /// + /// ```rust,ignore + /// let songs = services.songs_by_ids(user_id, &song_ids).await?; + /// ``` + /// + /// # Parameters + /// + /// * `ids` — The song IDs to load. + /// + /// # Returns + /// + /// The requested songs in the order returned by the service. pub async fn songs_by_ids( &self, user_id: Uuid, @@ -701,6 +779,28 @@ impl DomainServices { self.songs_by_ids_on(&mut connection, user_id, ids).await } + /// Creates a consistent synchronization snapshot for a user. + /// + /// The snapshot includes the current synchronization cursor, playlists, favorites, + /// ratings, queue, playback history, and shares visible to the user. + /// + /// # Arguments + /// + /// * `user_id` - The user whose synchronization data is collected. + /// * `history_limit` - The maximum number of history entries to include. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: uuid::Uuid, + /// # ) -> Result<(), ServiceError> { + /// let snapshot = services.sync_snapshot(user_id, 100).await?; + /// println!("Synchronization cursor: {}", snapshot.cursor); + /// # Ok(()) + /// # } + /// ``` pub async fn sync_snapshot( &self, user_id: Uuid, @@ -730,6 +830,29 @@ impl DomainServices { }) } + /// Retrieves all requested songs visible and available to the user. + /// + /// # Errors + /// + /// Returns [`ServiceError::NotFound`] if any requested song is unavailable or + /// inaccessible. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # connection: &mut SqliteConnection, + /// # user_id: Uuid, + /// # song_id: Uuid, + /// # ) { + /// let songs = services + /// .songs_by_ids_on(connection, user_id, &[song_id]) + /// .await + /// .unwrap(); + /// assert_eq!(songs.len(), 1); + /// # } + /// ``` async fn songs_by_ids_on( &self, connection: &mut SqliteConnection, @@ -746,6 +869,16 @@ impl DomainServices { } } + /// Loads the requested songs that are visible and available to the user, skipping + /// missing or inaccessible songs while preserving the requested order. + /// + /// # Examples + /// + /// ```ignore + /// let songs = services + /// .songs_by_ids_lenient_on(&mut connection, user_id, &track_ids) + /// .await?; + /// ``` async fn songs_by_ids_lenient_on( &self, connection: &mut SqliteConnection, @@ -777,6 +910,26 @@ impl DomainServices { .collect()) } + /// Retrieves artwork metadata for an entity or artwork hash visible to a user. + /// + /// # Examples + /// + /// ```no_run + /// # use uuid::Uuid; + /// # use crate::services::DomainServices; + /// # async fn example(services: &DomainServices, user_id: Uuid) { + /// let artwork = services + /// .artwork_for_user(user_id, "artwork-hash") + /// .await + /// .unwrap(); + /// + /// assert!(artwork.is_some()); + /// # } + /// ``` + /// + /// The returned tuple contains the artwork hash and format. + /// + /// Returns `None` when the identifier does not resolve to artwork accessible to the user. pub async fn artwork_for_user( &self, user_id: Uuid, @@ -799,11 +952,37 @@ impl DomainServices { .map_err(Into::into) } + /// Lists the playlists owned by a user. + /// + /// # Arguments + /// + /// * `user_id` - The user whose playlists are returned. + /// + /// # Returns + /// + /// The user's playlists, including their ordered tracks. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(services: &DomainServices, user_id: Uuid) -> Result<(), ServiceError> { + /// let playlists = services.playlists(user_id).await?; + /// # Ok(()) + /// # } + /// ``` 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 } + /// Loads the playlists owned by a user, including their ordered songs. + /// + /// # Examples + /// + /// ```no_run + /// let playlists = services.playlists_on(&mut connection, user_id).await?; + /// # Ok::<(), ServiceError>(()) + /// ``` async fn playlists_on( &self, connection: &mut SqliteConnection, @@ -832,11 +1011,62 @@ impl DomainServices { Ok(result) } + /// Retrieves a playlist owned by the specified user. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: uuid::Uuid, + /// # playlist_id: uuid::Uuid, + /// # ) -> Result<(), ServiceError> { + /// let playlist = services.playlist(user_id, playlist_id).await?; + /// assert_eq!(playlist.id, playlist_id); + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns `ServiceError::NotFound` when the playlist does not exist or is not + /// owned by the specified user. 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 } + /// Retrieves a playlist owned by the specified user, including its songs. + /// + /// # Errors + /// + /// Returns [`ServiceError::NotFound`] when the playlist does not exist or is + /// owned by another user. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # connection: &mut SqliteConnection, + /// # user_id: Uuid, + /// # playlist_id: Uuid, + /// # ) -> Result<(), ServiceError> { + /// let playlist = services + /// .playlist_on(connection, user_id, playlist_id) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Parameters + /// + /// * `user_id` identifies the playlist owner. + /// * `id` identifies the playlist. + /// + /// # Returns + /// + /// The owned playlist and its ordered songs. async fn playlist_on( &self, connection: &mut SqliteConnection, @@ -863,6 +1093,14 @@ impl DomainServices { }) } + /// Retrieves the songs in a playlist that are visible to a user. + /// + /// # Examples + /// + /// ```no_run + /// let songs = services.playlist_songs_on(&mut connection, user_id, playlist_id).await?; + /// # Ok::<(), ServiceError>(()) + /// ``` async fn playlist_songs_on( &self, connection: &mut SqliteConnection, @@ -876,6 +1114,33 @@ impl DomainServices { .await } + /// Lists the tracks in a playlist owned by the specified user, preserving playlist order. + /// + /// # Parameters + /// + /// * `user_id` identifies the playlist owner. + /// * `playlist_id` identifies the playlist to inspect. + /// + /// # Returns + /// + /// The ordered track identifiers belonging to the playlist. + /// + /// # Examples + /// + /// ``` + /// # async fn example( + /// # services: &DomainServices, + /// # connection: &mut SqliteConnection, + /// # user_id: Uuid, + /// # playlist_id: Uuid, + /// # ) -> Result<(), ServiceError> { + /// let track_ids = services + /// .playlist_track_ids_on(connection, user_id, playlist_id) + /// .await?; + /// assert!(track_ids.is_empty() || !track_ids.is_empty()); + /// # Ok(()) + /// # } + /// ``` async fn playlist_track_ids_on( &self, connection: &mut SqliteConnection, @@ -896,6 +1161,32 @@ impl DomainServices { .map_err(Into::into) } + /// Creates a playlist for a user with the specified name and tracks. + /// + /// # Parameters + /// + /// * `user_id` — The user who owns the playlist. + /// * `name` — The playlist name. + /// * `track_ids` — The tracks to add in their requested order. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: uuid::Uuid, + /// # ) -> Result<(), ServiceError> { + /// let playlist = services + /// .create_playlist(user_id, "Favorites", &[]) + /// .await?; + /// assert_eq!(playlist.name, "Favorites"); + /// # Ok(()) + /// # } + /// ``` + /// + /// # Returns + /// + /// The newly created playlist. pub async fn create_playlist( &self, user_id: Uuid, @@ -911,6 +1202,37 @@ impl DomainServices { .await } + /// Creates a playlist for a user with the specified tracks. + /// + /// The playlist and its ordered tracks are stored transactionally. Replayed mutation + /// contexts return the previously created playlist. + /// + /// # Parameters + /// + /// * `user_id` — The user who owns the playlist. + /// * `name` — The playlist name. + /// * `track_ids` — The tracks to add in their desired order. + /// * `context` — Mutation context used for deduplication and synchronization. + /// + /// # Returns + /// + /// The created or previously persisted playlist. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: Uuid, + /// # context: MutationContext, + /// # ) -> Result<(), ServiceError> { + /// let playlist = services + /// .create_playlist_with_context(user_id, "Favorites", &[], context) + /// .await?; + /// assert_eq!(playlist.name, "Favorites"); + /// # Ok(()) + /// # } + /// ``` pub async fn create_playlist_with_context( &self, user_id: Uuid, @@ -967,7 +1289,38 @@ impl DomainServices { self.playlist(user_id, id).await } - #[allow(clippy::too_many_arguments)] + /// Updates an owned playlist's metadata and track ordering. + /// + /// Added tracks are appended in the given order, while tracks at the specified + /// indexes are removed before additions are applied. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: Uuid, + /// # playlist_id: Uuid, + /// # ) -> Result<(), ServiceError> { + /// let playlist = services + /// .update_playlist( + /// user_id, + /// playlist_id, + /// Some("Favorites"), + /// None, + /// Some(false), + /// &[], + /// &[], + /// ) + /// .await?; + /// # let _ = playlist; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Returns + /// + /// The updated playlist. pub async fn update_playlist( &self, user_id: Uuid, @@ -991,7 +1344,34 @@ impl DomainServices { .await } - #[allow(clippy::too_many_arguments)] + /// Updates a playlist's metadata and ordered tracks for its owner. + /// + /// Added tracks must be visible to the user. Removal indexes are applied to the + /// playlist's existing track order, and invalid indexes cause the operation to + /// fail. Replayed mutations return the current playlist without applying changes. + /// + /// # Examples + /// + /// ```no_run + /// #[tokio::test] + /// async fn update_playlist() -> Result<(), ServiceError> { + /// let playlist = services + /// .update_playlist_with_context( + /// user_id, + /// playlist_id, + /// Some("Favorites"), + /// None, + /// Some(false), + /// &[], + /// &[], + /// context, + /// ) + /// .await?; + /// + /// assert_eq!(playlist.name, "Favorites"); + /// Ok(()) + /// } + /// ``` pub async fn update_playlist_with_context( &self, user_id: Uuid, @@ -1085,11 +1465,50 @@ impl DomainServices { self.playlist(user_id, id).await } + /// Deletes a playlist owned by the specified user. + /// + /// # Parameters + /// + /// * `user_id` - The playlist owner's user ID. + /// * `id` - The playlist ID to delete. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: Uuid, + /// # playlist_id: Uuid, + /// # ) -> Result<(), ServiceError> { + /// services.delete_playlist(user_id, playlist_id).await?; + /// # Ok(()) + /// # } + /// ``` 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 } + /// Deletes a playlist owned by the specified user. + /// + /// Replayed deletion requests are treated as successful without performing the deletion. Returns + /// `ServiceError::NotFound` when the playlist does not exist or is owned by another user. + /// + /// # Examples + /// + /// ``` + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: uuid::Uuid, + /// # playlist_id: uuid::Uuid, + /// # context: MutationContext, + /// # ) -> Result<(), ServiceError> { + /// services + /// .delete_playlist_with_context(user_id, playlist_id, context) + /// .await?; + /// # Ok(()) + /// # } + /// ``` pub async fn delete_playlist_with_context( &self, user_id: Uuid, @@ -1138,6 +1557,29 @@ impl DomainServices { } } + /// Sets or removes a user's favorite marker for a visible track, album, or artist. + /// + /// # Arguments + /// + /// * `entity_type` identifies the entity as a track, album, or artist. + /// * `starred` determines whether the favorite marker is added or removed. + /// + /// # Returns + /// + /// `Ok(())` when the favorite state is updated; otherwise, a [`ServiceError`]. + /// + /// # Examples + /// + /// ``` + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: Uuid, + /// # track_id: Uuid, + /// # ) -> Result<(), ServiceError> { + /// services.set_star(user_id, "track", track_id, true).await?; + /// # Ok(()) + /// # } + /// ``` pub async fn set_star( &self, user_id: Uuid, @@ -1155,6 +1597,26 @@ impl DomainServices { .await } + /// Adds or removes a user's star for an authorized catalog entity. + /// + /// # Arguments + /// + /// * `entity_type` — The entity category, such as a song, album, or artist. + /// + /// # Examples + /// + /// ```ignore + /// services + /// .set_star_with_context( + /// user_id, + /// "song", + /// song_id, + /// true, + /// context, + /// ) + /// .await?; + /// ``` + pub async fn set_star_with_context( pub async fn set_star_with_context( &self, user_id: Uuid, @@ -1254,6 +1716,21 @@ impl DomainServices { }) } + /// Lists the entities starred by a user that remain visible to that user. + /// + /// # Returns + /// + /// A list of tuples containing each entity's kind, ID, and star timestamp. + /// + /// # Examples + /// + /// ``` + /// # async fn example(services: &DomainServices, user_id: Uuid) -> Result<(), ServiceError> { + /// let starred = services.starred_ids(user_id).await?; + /// let _ = starred; + /// # Ok(()) + /// # } + /// ``` pub async fn starred_ids( &self, user_id: Uuid, @@ -1262,6 +1739,23 @@ impl DomainServices { self.starred_ids_on(&mut connection, user_id).await } + /// Lists the entities starred by a user that remain visible in the user's libraries. + /// + /// # Returns + /// + /// Each tuple contains the entity type, entity identifier, and timestamp when it was starred, + /// ordered from newest to oldest. + /// + /// # Examples + /// + /// ```ignore + /// let starred = services.starred_ids_on(&mut connection, user_id).await?; + /// ``` + async fn starred_ids_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ) -> Result, ServiceError> { async fn starred_ids_on( &self, connection: &mut SqliteConnection, @@ -1280,11 +1774,50 @@ impl DomainServices { .collect::, sqlx::Error>>().map_err(Into::into) } + /// Lists a user's ratings for entities that remain visible to that user. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: Uuid, + /// # ) -> Result<(), ServiceError> { + /// let ratings = services.ratings(user_id).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Returns + /// + /// The user's visible ratings, or a service error if the ratings cannot be loaded. 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 } + /// Lists the user's ratings for entities they can currently access, ordered by most recent update. + /// + /// # Examples + /// + /// ```no_run + /// let ratings = services.ratings_on(&mut connection, user_id).await?; + /// assert!(ratings.iter().all(|rating| rating.rating <= 5)); + /// # Ok::<(), ServiceError>(()) + /// ``` + /// + /// # Arguments + /// + /// * `connection` - Database connection used to load the ratings. + /// * `user_id` - User whose visible ratings are requested. + /// + /// # Returns + /// + /// The user's visible ratings, ordered by update time descending. + /// + /// # Errors + /// + /// Returns an error if the database query fails or a stored identifier cannot be parsed as a UUID. async fn ratings_on( &self, connection: &mut SqliteConnection, @@ -1314,6 +1847,24 @@ impl DomainServices { .map_err(Into::into) } + /// Sets or removes a user's rating for a visible catalog entity. + /// + /// A rating from 1 through 5 is stored, while a rating of 0 removes the + /// existing rating. The operation fails if the rating is outside this range + /// or the entity is unavailable to the user. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: Uuid, + /// # album_id: Uuid, + /// # ) -> Result<(), ServiceError> { + /// services.set_rating(user_id, "album", album_id, 5).await?; + /// # Ok(()) + /// # } + /// ``` pub async fn set_rating( &self, user_id: Uuid, @@ -1331,6 +1882,31 @@ impl DomainServices { .await } + /// Sets or removes a user's rating for a visible entity and records the mutation. + /// + /// A rating of `0` removes the existing rating; ratings from `1` through `5` are + /// stored. The operation is idempotent when replayed with the same mutation + /// context. + /// + /// # Errors + /// + /// Returns [`ServiceError::Invalid`] when `rating` is outside the range `0..=5`, + /// or when the entity is unavailable to the user. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: uuid::Uuid, + /// # context: MutationContext, + /// # ) -> Result<(), ServiceError> { + /// services + /// .set_rating_with_context(user_id, "track", uuid::Uuid::new_v4(), 5, context) + /// .await?; + /// # Ok(()) + /// # } + /// ``` pub async fn set_rating_with_context( &self, user_id: Uuid, @@ -1395,6 +1971,23 @@ impl DomainServices { Ok(()) } + /// Records a track playback event or updates the user's now-playing state. + /// + /// A submission records completed playback, while a non-submission updates now-playing + /// information. An optional playback timestamp may be provided. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: Uuid, + /// # track_id: Uuid, + /// # ) -> Result<(), ServiceError> { + /// services.scrobble(user_id, track_id, true, None).await?; + /// # Ok(()) + /// # } + /// ``` pub async fn scrobble( &self, user_id: Uuid, @@ -1412,6 +2005,32 @@ impl DomainServices { .await } + /// Records a track playback event or updates the user's now-playing state. + /// + /// A submission removes the user's existing now-playing state; otherwise, the + /// track becomes the user's current now-playing item. The optional timestamp + /// must be nonnegative and no more than five minutes in the future. + /// + /// # Examples + /// + /// ```rust,no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: uuid::Uuid, + /// # track_id: uuid::Uuid, + /// # context: MutationContext, + /// # ) -> Result<(), ServiceError> { + /// services + /// .scrobble_with_context(user_id, track_id, true, None, context) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns an error if the track is inaccessible, the timestamp is invalid, + /// or the operation cannot be persisted. pub async fn scrobble_with_context( &self, user_id: Uuid, @@ -1488,6 +2107,51 @@ impl DomainServices { Ok(()) } + /// Lists currently playing tracks from enabled accounts that are visible to a user. + + /// + + /// # Examples + + /// + + /// ```no_run + + /// # async fn example( + + /// # services: &DomainServices, + + /// # user_id: uuid::Uuid, + + /// # ) -> Result<(), ServiceError> { + + /// let playing = services.now_playing(user_id).await?; + + /// # let _ = playing; + + /// # Ok(()) + + /// # } + + /// ``` + + /// + + /// # Arguments + + /// + + /// * `user_id` - User whose library visibility determines which tracks are included. + + /// + + /// # Returns + + /// + + /// A list of `(username, song, started_at)` tuples ordered by playback start time, + + /// with the newest activity first. pub async fn now_playing( &self, user_id: Uuid, @@ -1514,6 +2178,28 @@ impl DomainServices { Ok(result) } + /// Lists the user's visible playback history in reverse chronological order. + /// + /// # Arguments + /// + /// * `user_id` - Identifies the user whose history is requested. + /// * `limit` - Maximum number of history entries to return; must be between 1 and 500. + /// + /// # Returns + /// + /// The user's visible history entries, ordered from newest to oldest. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: uuid::Uuid, + /// # ) -> Result<(), ServiceError> { + /// let entries = services.history(user_id, 100).await?; + /// # Ok(()) + /// # } + /// ``` pub async fn history( &self, user_id: Uuid, @@ -1523,6 +2209,34 @@ impl DomainServices { self.history_on(&mut connection, user_id, limit).await } + /// Retrieves a user's play history for tracks in libraries they can access. + /// + /// Results are ordered from newest to oldest. The limit must be between 0 and + /// [`MAX_HISTORY_LIMIT`], inclusive. + /// + /// # Arguments + /// + /// * `user_id` — The user whose play history is requested. + /// * `limit` — The maximum number of history entries to return. + /// + /// # Errors + /// + /// Returns [`ServiceError::Invalid`] when `limit` is outside the permitted + /// range, or a database error when the history cannot be loaded. + /// + /// # Examples + /// + /// ```rust,no_run + /// # async fn example( + /// # services: &DomainServices, + /// # connection: &mut SqliteConnection, + /// # user_id: Uuid, + /// # ) -> Result<(), ServiceError> { + /// let history = services.history_on(connection, user_id, 20).await?; + /// assert!(history.len() <= 20); + /// # Ok(()) + /// # } + /// ``` async fn history_on( &self, connection: &mut SqliteConnection, @@ -1554,6 +2268,24 @@ impl DomainServices { .map_err(Into::into) } + /// Replaces the user's playback queue with the specified tracks. + /// + /// The queue position must be nonnegative, and every track must be visible to the user. + /// + /// # Parameters + /// + /// * `current` — The track currently being played, if any. + /// * `position_ms` — The playback position of the current track in milliseconds. + /// * `client` — An optional client identifier associated with the queue. + /// + /// # Examples + /// + /// ```rust,ignore + /// services + /// .save_queue(user_id, &track_ids, Some(current_track), 30_000, Some("web")) + /// .await?; + /// # Ok::<(), ServiceError>(()) + /// ``` pub async fn save_queue( &self, user_id: Uuid, @@ -1573,7 +2305,26 @@ impl DomainServices { .await } - #[allow(clippy::too_many_arguments)] + /// Saves a user's playback queue and its current track position. + /// + /// The queue may contain up to 400 tracks, all of which must be visible to the + /// user. The position must be greater than or equal to zero. + /// + /// # Errors + /// + /// Returns [`ServiceError::Invalid`] when the queue is too large, the position + /// is negative, or a track is unavailable or inaccessible. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(services: &DomainServices, user_id: Uuid) -> Result<(), ServiceError> { + /// services + /// .save_queue_with_context(user_id, &[], None, 0, None, todo!()) + /// .await?; + /// # Ok(()) + /// # } + /// ``` pub async fn save_queue_with_context( &self, user_id: Uuid, @@ -1654,11 +2405,44 @@ impl DomainServices { Ok(()) } + /// Loads the current playback queue visible to a user. + /// + /// Inaccessible tracks are omitted from the queue. + /// + /// # Returns + /// + /// The user's queue, or `None` if no queue has been saved. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(services: &DomainServices, user_id: uuid::Uuid) -> Result<(), ServiceError> { + /// let queue = services.queue(user_id).await?; + /// if let Some(queue) = queue { + /// println!("Queue loaded: {queue:?}"); + /// } + /// # 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 } + /// Loads a user's queue and its currently visible songs. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # service: &DomainServices, + /// # connection: &mut SqliteConnection, + /// # user_id: Uuid, + /// # ) -> Result<(), ServiceError> { + /// let queue = service.queue_on(connection, user_id).await?; + /// # Ok(()) + /// # } + /// ``` async fn queue_on( &self, connection: &mut SqliteConnection, @@ -1692,11 +2476,42 @@ impl DomainServices { })) } + /// Lists the shares created by a user. + /// + /// Persisted share records do not include bearer tokens. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: uuid::Uuid, + /// # ) -> Result<(), ServiceError> { + /// let shares = services.shares(user_id).await?; + /// # 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 } + /// Loads the shares owned by a user, including only songs still visible to that user. + /// + /// Persistent reads omit share bearer tokens. Shares are ordered by creation time, + /// with their accessible songs preserved in stored order. + /// + /// # Examples + /// + /// ```ignore + /// let shares = services.shares_on(&mut connection, user_id).await?; + /// ``` + async fn shares_on( + &self, + connection: &mut SqliteConnection, + user_id: Uuid, + ) -> Result, ServiceError> { async fn shares_on( &self, connection: &mut SqliteConnection, @@ -1751,6 +2566,28 @@ impl DomainServices { Ok(shares) } + /// Creates a share containing the specified tracks for a user. + /// + /// The tracks must be visible to the user. The share may include an optional + /// description and expiration timestamp; its access token is returned only in + /// the creation result. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: Uuid, + /// # track_id: Uuid, + /// # ) -> Result<(), ServiceError> { + /// let share = services + /// .create_share(user_id, &[track_id], Some("Favourite track"), None) + /// .await?; + /// + /// assert!(share.url_token.is_some()); + /// # Ok(()) + /// # } + /// ``` pub async fn create_share( &self, user_id: Uuid, @@ -1768,6 +2605,27 @@ impl DomainServices { .await } + /// Creates a share containing the specified tracks for a user. + /// + /// The returned share includes its bearer token, which is available only from + /// this creation result. The tracks must be visible to the user, and the list + /// must contain between one and [`MAX_SHARE_TRACKS`] tracks. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example() { + /// # let services: DomainServices = todo!(); + /// # let user_id = Uuid::new_v4(); + /// # let ids = vec![Uuid::new_v4()]; + /// # let context = todo!(); + /// let share = services + /// .create_share_with_context(user_id, &ids, Some("Favorites"), None, context) + /// .await + /// .unwrap(); + /// assert!(share.url_token.is_some()); + /// # } + /// ``` pub async fn create_share_with_context( &self, user_id: Uuid, @@ -1856,6 +2714,20 @@ impl DomainServices { }) } + /// Retrieves a public share using its bearer token and records a visit. + /// + /// Expired or revoked shares return [`ServiceError::NotFound`]. The returned + /// share omits its bearer token and includes only songs still visible to the + /// share owner. + /// + /// # Examples + /// + /// ```ignore + /// let share = services.public_share(token).await?; + /// assert!(share.url_token.is_none()); + /// # Ok::<(), ServiceError>(()) + /// ``` + pub async fn public_share(&self, token: &str) -> Result { 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>?)") @@ -1898,6 +2770,23 @@ impl DomainServices { }) } + /// Updates the description and expiration time of an owned share. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: uuid::Uuid, + /// # share_id: uuid::Uuid, + /// # ) -> Result<(), ServiceError> { + /// let share = services + /// .update_share(user_id, share_id, Some("Shared playlist"), None) + /// .await?; + /// assert_eq!(share.description.as_deref(), Some("Shared playlist")); + /// # Ok(()) + /// # } + /// ``` pub async fn update_share( &self, user_id: Uuid, @@ -1915,6 +2804,31 @@ impl DomainServices { .await } + /// Updates an owner's share description and expiration, preserving fields whose values are omitted. + /// + /// Returns the updated share. Returns [`ServiceError::NotFound`] when the share does not belong to the user. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: Uuid, + /// # share_id: Uuid, + /// # ) -> Result<(), ServiceError> { + /// let updated = services + /// .update_share_with_context( + /// user_id, + /// share_id, + /// Some("Shared music"), + /// None, + /// todo!(), + /// ) + /// .await?; + /// # let _ = updated; + /// # Ok(()) + /// # } + /// ``` pub async fn update_share_with_context( &self, user_id: Uuid, @@ -1983,11 +2897,47 @@ impl DomainServices { .ok_or(ServiceError::NotFound) } + /// Deletes a share owned by the user. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(services: &DomainServices, user_id: Uuid, share_id: Uuid) { + /// services.delete_share(user_id, share_id).await.unwrap(); + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns [`ServiceError::NotFound`] when the share does not exist or is owned by another user. 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 } + /// Deletes a share owned by the specified user and records the deletion for synchronization. + /// + /// Replayed deletion requests are treated as successful without applying the deletion again. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # user_id: Uuid, + /// # share_id: Uuid, + /// # context: MutationContext, + /// # ) -> Result<(), ServiceError> { + /// services + /// .delete_share_with_context(user_id, share_id, context) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns `ServiceError::NotFound` when the share does not exist or is owned by another user. pub async fn delete_share_with_context( &self, user_id: Uuid, @@ -2035,6 +2985,26 @@ impl DomainServices { } } + /// Lists all users, including their roles, status, Subsonic credential state, and library memberships. + /// + /// # Examples + /// + /// ``` + /// # use uuid::Uuid; + /// # async fn example( + /// # services: &DomainServices, + /// # ) -> Result<(), ServiceError> { + /// let users = services.users(Uuid::new_v4()).await?; + /// assert!(users.iter().all(|user| !user.username.is_empty())); + /// # Ok(()) + /// # } + /// ``` + /// + /// Requires the requesting account to be an enabled administrator. + /// + /// # Returns + /// + /// A list of users ordered by username. 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") @@ -2054,6 +3024,24 @@ impl DomainServices { Ok(users) } + /// Creates a web user after validating administrator authorization and account credentials. + /// + /// The password must contain at least 12 characters. Duplicate usernames produce a conflict error. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # actor_id: uuid::Uuid, + /// # ) -> Result<(), ServiceError> { + /// let user = services + /// .create_web_user(actor_id, "reader", "a-password-with-12-chars", AccountRole::User) + /// .await?; + /// # let _ = user; + /// # Ok(()) + /// # } + /// ``` pub async fn create_web_user( &self, actor_id: Uuid, @@ -2088,8 +3076,31 @@ impl DomainServices { .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. + /// Sets a dedicated Subsonic password and rotates the account's API key. + /// + /// The clear API key is returned only from this operation; its hash is persisted. + /// Requires administrator authorization and a password of at least 12 bytes. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # admin_id: Uuid, + /// # ) -> Result<(), ServiceError> { + /// let api_key = services + /// .set_subsonic_credential(admin_id, "user", "a-secure-password") + /// .await?; + /// assert!(!api_key.is_empty()); + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns `ServiceError::Invalid` for passwords shorter than 12 bytes, + /// `ServiceError::NotFound` when the account does not exist, or an error when + /// the caller is not an administrator or credential persistence fails. pub async fn set_subsonic_credential( &self, actor_id: Uuid, @@ -2114,6 +3125,24 @@ impl DomainServices { Ok(api_key) } + /// Revokes the Subsonic credential for a user. + /// + /// The caller must be an enabled administrator. Returns `NotFound` when the + /// user or an existing Subsonic credential cannot be found. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # actor_id: Uuid, + /// # ) -> Result<(), ServiceError> { + /// services + /// .revoke_subsonic_credential(actor_id, "alice") + /// .await?; + /// # Ok(()) + /// # } + /// ``` pub async fn revoke_subsonic_credential( &self, actor_id: Uuid, @@ -2136,6 +3165,30 @@ impl DomainServices { } } + /// Creates a Subsonic user and assigns access to the requested libraries. + /// + /// The caller must be an enabled administrator. The username must be valid and + /// the Subsonic password must be non-empty. The account may be created as an + /// administrator, and library access defaults to all libraries when no IDs are + /// provided. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(services: &DomainServices, actor_id: Uuid) -> Result<(), ServiceError> { + /// let user = services + /// .create_subsonic_user(actor_id, "listener", "secret", false, None) + /// .await?; + /// assert_eq!(user.username, "listener"); + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns `ServiceError::Invalid` for an invalid username or empty password, + /// `ServiceError::Conflict` when the username already exists, or another + /// service error when creation fails. pub async fn create_subsonic_user( &self, actor_id: Uuid, @@ -2225,6 +3278,40 @@ impl DomainServices { .ok_or(ServiceError::NotFound) } + /// Updates an existing user's account settings and returns the resulting user. + /// + /// The update may change the user's role, disabled state, web password, Subsonic + /// password, and listener library memberships. Administrators cannot disable or + /// demote themselves, and changing a web password revokes the user's active + /// sessions. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example(services: &DomainServices, actor_id: uuid::Uuid) -> Result<(), ServiceError> { + /// let user = services + /// .update_user( + /// actor_id, + /// "listener", + /// UserUpdate { + /// admin: Some(false), + /// disabled: Some(false), + /// web_password: None, + /// subsonic_password: None, + /// folder_ids: None, + /// }, + /// ) + /// .await?; + /// # let _ = user; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns an error if the actor is not an administrator, the target user does + /// not exist, a supplied password or library selection is invalid, or the + /// requested update cannot be applied. pub async fn update_user( &self, actor_id: Uuid, @@ -2377,6 +3464,27 @@ impl DomainServices { } } + /// Resolves requested library identifiers against the libraries available to the service. + /// + /// When no identifiers are requested, returns all available libraries. Requested identifiers + /// are validated, deduplicated, and returned in their original order. + /// + /// # Errors + /// + /// Returns [`ServiceError::NotFound`] if a requested library does not exist. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # services: &DomainServices, + /// # requested: Option<&[Uuid]>, + /// # ) -> Result<(), ServiceError> { + /// let library_ids = services.resolve_library_ids(requested).await?; + /// # let _ = library_ids; + /// # Ok(()) + /// # } + /// ``` async fn resolve_library_ids( &self, requested: Option<&[Uuid]>, @@ -2402,6 +3510,21 @@ impl DomainServices { Ok(unique) } + /// Verifies that a user can access the specified catalog entity. + /// + /// # Errors + /// + /// Returns [`ServiceError::Invalid`] for an unsupported entity kind and + /// [`ServiceError::NotFound`] when the entity is missing or inaccessible to + /// the user. + /// + /// # Examples + /// + /// ```ignore + /// services + /// .authorize_entity_on(&mut connection, user_id, "track", track_id) + /// .await?; + /// ``` async fn authorize_entity_on( &self, connection: &mut SqliteConnection, @@ -2559,6 +3682,22 @@ async fn replace_playlist_tracks( Ok(()) } +/// Validates that a name contains between 1 and 200 non-whitespace characters. +/// +/// # Examples +/// +/// ``` +/// assert!(validate_name("My playlist").is_ok()); +/// assert!(validate_name(" ").is_err()); +/// ``` +/// +/// # Errors +/// +/// Returns `ServiceError::Invalid` when the trimmed name is empty or exceeds 200 characters. +/// +/// # Returns +/// +/// `Ok(())` when the name is valid; otherwise, `Err(ServiceError::Invalid)`. fn validate_name(name: &str) -> Result<(), ServiceError> { if (1..=200).contains(&name.trim().chars().count()) { Ok(()) @@ -2567,6 +3706,20 @@ fn validate_name(name: &str) -> Result<(), ServiceError> { } } +/// Validates that a mutation receipt represents the expected entity type. +/// +/// # Examples +/// +/// ```rust,ignore +/// let receipt = MutationReceipt::default(); +/// assert!(validate_replay_type(&receipt, "playlist").is_ok()); +/// ``` +/// +/// # Errors +/// +/// Returns [`ServiceError::Conflict`] when the receipt's entity type differs +/// from the expected type. +fn validate_replay_type... fn validate_replay_type(receipt: &MutationReceipt, expected: &str) -> Result<(), ServiceError> { if receipt.entity_type == expected { Ok(()) @@ -2575,6 +3728,18 @@ fn validate_replay_type(receipt: &MutationReceipt, expected: &str) -> Result<(), } } +/// Validates a username after trimming surrounding whitespace. +/// +/// A valid username contains 3–64 ASCII alphanumeric characters, hyphens, +/// underscores, or periods. +/// +/// # Examples +/// +/// ``` +/// assert!(validate_username("alice_01").is_ok()); +/// assert!(validate_username("ab").is_err()); +/// ``` +fn validate_username(username: &str) -> Result<(), ServiceError> fn validate_username(username: &str) -> Result<(), ServiceError> { let username = username.trim(); if !(3..=64).contains(&username.len()) @@ -2588,6 +3753,15 @@ fn validate_username(username: &str) -> Result<(), ServiceError> { } } +/// Parses a string into a UUID and represents parsing failures as SQLx decode errors. +/// +/// # Examples +/// +/// ``` +/// let id = parse_uuid("550e8400-e29b-41d4-a716-446655440000".to_owned()) +/// .expect("valid UUID"); +/// assert_eq!(id, Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()); +/// ``` fn parse_uuid(value: String) -> Result { Uuid::from_str(&value).map_err(|error| sqlx::Error::Decode(Box::new(error))) } diff --git a/src/subsonic.rs b/src/subsonic.rs index 614d777..1f3c802 100644 --- a/src/subsonic.rs +++ b/src/subsonic.rs @@ -1298,6 +1298,19 @@ async fn shares( } } +/// Handles administrator-only user-management operations such as listing, creating, updating, deleting, and changing user credentials. +/// +/// # Examples +/// +/// ```ignore +/// let user = admin(&state, &principal, "getUser", ¶ms).await?; +/// ``` +async fn admin( +state: &AppState, +principal: &Principal, +method: &str, +params: &Params, +) -> Result { async fn admin( state: &AppState, principal: &Principal, @@ -1485,6 +1498,15 @@ fn playlist_node(playlist: &PlaylistItem) -> Node { .attr("changed", iso_time(playlist.updated_at)) } +/// Builds a Subsonic user response node with account roles and assigned folders. +/// +/// # Examples +/// +/// ``` +/// # let user: &crate::services::UserItem = todo!(); +/// let node = user_node(user); +/// let _ = node; +/// ``` fn user_node(user: &crate::services::UserItem) -> Node { Node::new("user") .attr("username", user.username.clone()) @@ -1508,6 +1530,18 @@ fn user_node(user: &crate::services::UserItem) -> Node { ) } +/// Builds a response node containing share metadata and its shared song entries. +/// +/// The public share URL is included only when the share has a token and a public +/// base URL is available. +/// +/// # Examples +/// +/// ```rust,ignore +/// let node = share_node(&share, Some("https://music.example")); +/// assert_eq!(node.name(), "share"); +/// ``` +fn share_node(share: &crate::services::ShareItem, public_url: Option<&str>) -> Node fn share_node(share: &crate::services::ShareItem, public_url: Option<&str>) -> Node { let url = share.url_token.as_ref().map(|token| { let path = format!("/share/{token}"); diff --git a/src/sync.rs b/src/sync.rs index 8293f9b..270d354 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -32,6 +32,20 @@ pub enum SyncError { pub struct MutationIntent([u8; 32]); impl MutationIntent { + /// Creates a deterministic mutation intent from an action, target, and JSON payload. + /// + /// Object key order in the payload does not affect the resulting intent. + /// + /// # Examples + /// + /// ``` + /// use serde_json::json; + /// + /// let first = MutationIntent::new("update", "profile", &json!({"name": "Ada", "active": true})); + /// let second = MutationIntent::new("update", "profile", &json!({"active": true, "name": "Ada"})); + /// + /// assert_eq!(first, second); + /// ``` pub fn new(action: &str, target: &str, payload: &Value) -> Self { let payload = canonical_json(payload); let payload = serde_json::to_vec(&payload).expect("JSON values always serialize"); @@ -44,6 +58,14 @@ impl MutationIntent { Self(*hasher.finalize().as_bytes()) } + /// Provides the intent hash as a byte slice. + /// + /// # Examples + /// + /// ``` + /// let intent = MutationIntent::new("update", "item", &serde_json::json!({})); + /// assert_eq!(intent.as_bytes().len(), 32); + /// ``` fn as_bytes(&self) -> &[u8] { &self.0 } @@ -56,6 +78,14 @@ pub struct MutationContext { } impl MutationContext { + /// Creates a mutation context with a new operation ID and no originating device. + /// + /// # Examples + /// + /// ``` + /// let context = MutationContext::server_generated(); + /// assert!(context.origin_device_id.is_none()); + /// ``` pub fn server_generated() -> Self { Self { operation_id: Uuid::new_v4(), @@ -111,15 +141,52 @@ pub struct SyncService { } impl SyncService { + /// Creates a synchronization service backed by the specified database. + /// + /// # Examples + /// + /// ``` + /// let service = SyncService::new(db); + /// ``` pub fn new(db: Database) -> Self { let (notices, _) = broadcast::channel(256); Self { db, notices } } + /// Creates a receiver for synchronization notices published by the service. + /// + /// Each received item contains the affected user's ID and the new synchronization cursor. + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut receiver = service.subscribe(); + /// let (user_id, notice) = receiver.recv().await?; + /// assert_eq!(notice.cursor, 1); + /// # Ok::<(), tokio::sync::broadcast::error::RecvError>(()) + /// ``` pub fn subscribe(&self) -> broadcast::Receiver<(Uuid, SyncNotice)> { self.notices.subscribe() } + /// Reserves a mutation operation and identifies whether it is new or a safe replay. + /// + /// An existing operation with a different intent returns [`SyncError::Conflict`]. + /// Operations from invalid origin devices and incomplete reservations are rejected. + /// + /// # Examples + /// + /// ```ignore + /// let claim = service + /// .claim_operation(&writer_guard, &mut connection, user_id, context, intent) + /// .await?; + /// ``` + /// + /// # Errors + /// + /// Returns [`SyncError::Invalid`] for an invalid origin device, [`SyncError::Conflict`] + /// when a reused operation ID has a different intent, or [`SyncError::Database`] when + /// the reservation or replay receipt cannot be read. pub(crate) async fn claim_operation( &self, writer_guard: &OwnedMutexGuard<()>, @@ -177,6 +244,26 @@ impl SyncService { } } + /// Completes a mutation by recording its synchronization event and associated result. + /// + /// # Examples + /// + /// ```ignore + /// let receipt = service + /// .complete_operation( + /// &mut connection, + /// user_id, + /// context, + /// "task", + /// task_id, + /// "updated", + /// &payload, + /// Some(task_id), + /// ) + /// .await?; + /// assert!(!receipt.replayed); + /// # Ok::<(), sqlx::Error>(()) + /// ``` #[allow(clippy::too_many_arguments)] pub(crate) async fn complete_operation( &self, @@ -228,6 +315,15 @@ impl SyncService { }) } + /// Publishes a synchronization notice for a newly completed mutation. + /// + /// Replayed mutation receipts do not produce a notice. + /// + /// # Examples + /// + /// ```no_run + /// service.publish(user_id, receipt); + /// ``` pub(crate) fn publish(&self, user_id: Uuid, receipt: MutationReceipt) { if !receipt.replayed { let _ = self.notices.send(( @@ -239,6 +335,22 @@ impl SyncService { } } + /// Retrieves a page of changes after the specified cursor. + /// + /// The page contains at most `limit` changes, reports whether additional changes + /// are available, and provides the cursor to use for the next request. The + /// cursor must be non-negative, and `limit` must be between 1 and + /// `MAX_SYNC_LIMIT`. + /// + /// # Examples + /// + /// ``` + /// # async fn example(service: &SyncService, user_id: Uuid) -> Result<(), SyncError> { + /// let page = service.changes(user_id, 0, 100).await?; + /// assert!(page.next_cursor >= 0); + /// # Ok(()) + /// # } + /// ``` pub async fn changes( &self, user_id: Uuid, @@ -272,6 +384,23 @@ impl SyncService { }) } + /// Retrieves the highest synchronization cursor recorded for a user, or zero when no changes exist. + /// + /// # Errors + /// + /// Returns a database error if the cursor cannot be queried. + /// + /// # Examples + /// + /// ``` + /// # async fn example() -> Result<(), sqlx::Error> { + /// # let service = todo!(); + /// # let user_id = uuid::Uuid::nil(); + /// let cursor = service.latest_cursor(user_id).await?; + /// assert!(cursor >= 0); + /// # Ok(()) + /// # } + /// ``` pub async fn latest_cursor(&self, user_id: Uuid) -> Result { sqlx::query_scalar("SELECT COALESCE(MAX(cursor), 0) FROM sync_event WHERE user_id=?") .bind(user_id.to_string()) @@ -279,6 +408,27 @@ impl SyncService { .await } + /// Checks whether a device belongs to a user and remains active. + /// + /// # Examples + /// + /// ``` + /// # use uuid::Uuid; + /// # async fn example(service: &crate::sync::SyncService, user_id: Uuid, device_id: Uuid) { + /// let belongs = service + /// .device_belongs_to_user(user_id, device_id) + /// .await + /// .unwrap(); + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns a database error if the device membership cannot be queried. + /// + /// # Returns + /// + /// `true` if the device belongs to the user and has not been revoked, `false` otherwise. pub async fn device_belongs_to_user( &self, user_id: Uuid, @@ -294,6 +444,28 @@ impl SyncService { .await } + /// Records the highest synchronization cursor acknowledged by an active device. + /// + /// Cursors below zero or beyond the user's latest cursor are rejected without modifying + /// the acknowledgment. + /// + /// # Examples + /// + /// ```no_run + /// # async fn example( + /// # service: &SyncService, + /// # user_id: uuid::Uuid, + /// # device_id: uuid::Uuid, + /// # ) -> Result<(), sqlx::Error> { + /// let recorded = service.acknowledge(user_id, device_id, 42).await?; + /// println!("Acknowledgment recorded: {recorded}"); + /// # Ok(()) + /// # } + /// ``` + /// + /// # Returns + /// + /// `true` if the acknowledgment was recorded for an active device, `false` otherwise. pub async fn acknowledge( &self, user_id: Uuid, @@ -323,6 +495,18 @@ impl SyncService { } } +/// Produces a recursively canonicalized JSON value with object keys sorted while preserving array order. +/// +/// # Examples +/// +/// ``` +/// use serde_json::json; +/// +/// let value = json!({"b": 2, "a": {"d": 4, "c": 3}}); +/// let canonical = canonical_json(&value); +/// +/// assert_eq!(canonical, json!({"a": {"c": 3, "d": 4}, "b": 2})); +/// ``` fn canonical_json(value: &Value) -> Value { match value { Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()), @@ -339,6 +523,15 @@ fn canonical_json(value: &Value) -> Value { } } +/// Converts a SQLite journal row into a [`SyncChange`], decoding UUIDs and JSON payloads. +/// +/// # Examples +/// +/// ```ignore +/// let change = change_from_row(row)?; +/// assert_eq!(change.entity_type, "note"); +/// # Ok::<(), sqlx::Error>(()) +/// ``` fn change_from_row(row: sqlx::sqlite::SqliteRow) -> Result { let payload: String = row.try_get("payload_json")?; Ok(SyncChange { @@ -358,6 +551,14 @@ fn change_from_row(row: sqlx::sqlite::SqliteRow) -> Result Result { Uuid::parse_str(&value).map_err(|error| sqlx::Error::Decode(Box::new(error))) } diff --git a/webapp/src/api.ts b/webapp/src/api.ts index beb52b9..c290bdf 100644 --- a/webapp/src/api.ts +++ b/webapp/src/api.ts @@ -131,14 +131,29 @@ export class ApiError extends Error { } } +/** + * Determines whether an authenticated session is available. + * + * @returns `true` if a session exists, `false` otherwise. + */ export function hasSession(): boolean { return session !== null; } +/** + * Retrieves the currently authenticated user. + * + * @returns The current session user, or `null` when no session exists. + */ export function currentUser(): SessionUser | null { return session?.user ?? null; } +/** + * Parses a response body as JSON when content is available. + * + * @returns The parsed JSON value, or `undefined` for empty and `204 No Content` responses. + */ async function parse(response: Response): Promise { if (response.status === 204) return undefined as T; const text = await response.text(); @@ -155,6 +170,11 @@ async function parse(response: Response): Promise { */ let pendingRefresh: Promise | null = null; +/** + * Refreshes the current session. + * + * @returns `true` if the session refresh succeeds, `false` otherwise. + */ function refresh(): Promise { if (!pendingRefresh) { pendingRefresh = performRefresh().finally(() => { @@ -164,6 +184,11 @@ function refresh(): Promise { return pendingRefresh; } +/** + * Attempts to refresh the current web session using the CSRF cookie. + * + * @returns `true` if the session was refreshed successfully, `false` otherwise. + */ async function performRefresh(): Promise { const hadSession = session !== null; const csrf = cookieValue("waveflow-csrf"); @@ -188,6 +213,11 @@ async function performRefresh(): Promise { } } +/** + * Clears the current session after a refresh failure and redirects authenticated users to the login page. + * + * @param hadSession - Whether a session existed before the refresh attempt + */ function handleRefreshFailure(hadSession: boolean): void { session = null; if (hadSession && window.location.pathname !== "/login") { @@ -195,10 +225,22 @@ function handleRefreshFailure(hadSession: boolean): void { } } +/** + * Confirms that an active session exists or attempts to refresh the session. + * + * @returns `true` if a session is available or successfully refreshed, `false` otherwise. + */ export async function ensureSession(): Promise { return hasSession() || refresh(); } +/** + * Performs an authenticated API request and retries once after refreshing the session when the request receives an unauthorized response. + * + * @param path - The API request path + * @param init - Request options for the API call + * @returns The parsed API response + */ async function call( path: string, init: RequestInit = {}, @@ -217,6 +259,13 @@ async function call( return parse(response); } +/** + * Authenticates a user and stores the resulting web session. + * + * @param username - The user's login name + * @param password - The user's password + * @throws ApiError if authentication fails + */ export async function login(username: string, password: string): Promise { const response = await fetch("/api/v2/web/auth/login", { method: "POST", @@ -238,6 +287,12 @@ export const setupRequired = () => (status) => status.required, ); +/** + * Creates the initial administrator account during application setup. + * + * @param username - The administrator's username + * @param password - The administrator's password + */ export async function bootstrapAdmin( username: string, password: string, @@ -252,6 +307,11 @@ export async function bootstrapAdmin( } } +/** + * Logs out the current session. + * + * The in-memory session is cleared regardless of whether the logout request succeeds. + */ export async function logout(): Promise { try { const csrf = cookieValue("waveflow-csrf"); @@ -264,6 +324,12 @@ export async function logout(): Promise { } } +/** + * Retrieves the value of a named browser cookie. + * + * @param name - The cookie name + * @returns The cookie value, or `null` if the cookie is not present + */ function cookieValue(name: string): string | null { const prefix = `${name}=`; for (const part of document.cookie.split(";")) { diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index 299d841..063fd61 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -29,6 +29,9 @@ import { import { PlayerBar, PlayerProvider } from "./player"; import "./styles.css"; +/** + * Renders the authenticated application shell with navigation, routed content, and the player bar. + */ function Shell() { const navigate = useNavigate(); const user = currentUser(); diff --git a/webapp/src/pages.tsx b/webapp/src/pages.tsx index 3ee4425..769633e 100644 --- a/webapp/src/pages.tsx +++ b/webapp/src/pages.tsx @@ -72,6 +72,9 @@ function Loading({ error }: { error: string | null }) { return

{error ? `Failed: ${error}` : "Loading…"}

; } +/** + * Provides administrator setup and user authentication, then redirects to the requested internal path or the home page. + */ export function LoginPage() { const navigate = useNavigate(); const [username, setUsername] = useState(""); @@ -177,6 +180,11 @@ function AlbumGrid({ albums }: { albums: Album[] }) { ); } +/** + * Displays the available albums with loading and error states. + * + * @returns The albums page content. + */ export function AlbumsPage() { const { value, error } = useAsync(listAlbums, []); if (!value) return ; @@ -188,6 +196,11 @@ export function AlbumsPage() { ); } +/** + * Renders a table of songs with playback controls, metadata, and favourite toggles. + * + * @param songs - The songs to display. + */ export function SongTable({ songs }: { songs: Song[] }) { const player = usePlayer(); const [stars, setStars] = useState>({}); @@ -243,6 +256,11 @@ export function SongTable({ songs }: { songs: Song[] }) { ); } +/** + * Displays the signed-in user's favourite tracks. + * + * @returns The favourites page content. + */ export function FavoritesPage() { const { value, error } = useAsync(async () => { const favorites = await listFavorites(); @@ -267,6 +285,11 @@ export function FavoritesPage() { ); } +/** + * Displays playlists and provides controls to create, play, update, and delete them. + * + * @returns The playlists page content. + */ export function PlaylistsPage() { const player = usePlayer(); const [revision, setRevision] = useState(0); @@ -373,6 +396,9 @@ export function PlaylistsPage() { ); } +/** + * Displays the synchronized playback queue and provides controls to play, clear, or remove tracks. + */ export function QueuePage() { const player = usePlayer(); const queueKeys = useMemo(() => { @@ -430,6 +456,9 @@ export function QueuePage() { ); } +/** + * Displays and manages public links for the current music queue. + */ export function SharesPage() { const player = usePlayer(); const [description, setDescription] = useState(""); @@ -534,6 +563,11 @@ export function SharesPage() { ); } +/** + * Provides a form for rotating a user's dedicated Subsonic credential. + * + * @param user - The user whose credential will be rotated + */ function CredentialForm({ user }: { user: User }) { const [password, setPassword] = useState(""); const [apiKey, setApiKey] = useState(null); @@ -571,6 +605,9 @@ function CredentialForm({ user }: { user: User }) { ); } +/** + * Provides administrative controls for managing libraries, scans, and user accounts. + */ export function AdminPage() { const signedInUser = currentUser(); const [revision, setRevision] = useState(0); @@ -753,6 +790,12 @@ export function AdminPage() { ); } +/** + * Displays a page heading with supporting detail text. + * + * @param title - The page heading + * @param detail - The supporting text displayed below the heading + */ function PageHeader({ title, detail }: { title: string; detail: string }) { return (
@@ -762,6 +805,11 @@ function PageHeader({ title, detail }: { title: string; detail: string }) { ); } +/** + * Displays a message for an empty content state. + * + * @param message - The message to display + */ function EmptyState({ message }: { message: string }) { return (
@@ -900,11 +948,11 @@ export function SearchPage() { } /** - * Consent screen for the native Authorization Code + PKCE flow. + * Presents a consent screen for an Authorization Code + PKCE request. * - * The desktop application opens this URL in the system browser with its PKCE - * parameters; approving posts them back with the browser session attached and - * follows the redirect the server computes. + * Validates the request parameters and trusted redirect, then either approves + * the request and follows the server-provided redirect or redirects with an + * `access_denied` error. */ export function AuthorizePage() { const params = new URLSearchParams(window.location.search); diff --git a/webapp/src/player.tsx b/webapp/src/player.tsx index 73d438d..4d2ddb0 100644 --- a/webapp/src/player.tsx +++ b/webapp/src/player.tsx @@ -40,18 +40,36 @@ type PlayerProgress = { const PlayerContext = createContext(null); const PlayerProgressContext = createContext(null); +/** + * Provides access to player state within a `PlayerProvider`. + * + * @returns The current player state + * @throws If called outside a `PlayerProvider` + */ export function usePlayer(): PlayerState { const player = useContext(PlayerContext); if (!player) throw new Error("usePlayer requires PlayerProvider"); return player; } +/** + * Provides playback progress from the nearest player provider. + * + * @returns The current playback position and duration + * @throws If called outside a `PlayerProvider` + */ function usePlayerProgress(): PlayerProgress { const progress = useContext(PlayerProgressContext); if (!progress) throw new Error("usePlayerProgress requires PlayerProvider"); return progress; } +/** + * Provides playback state, progress, and controls to descendant components. + * + * @param children - The components rendered within the player contexts + * @returns The descendant components wrapped with player contexts + */ export function PlayerProvider({ children }: { children: ReactNode }) { const audio = useRef(null); const [queue, setQueue] = useState([]); @@ -363,6 +381,11 @@ export function PlayerProvider({ children }: { children: ReactNode }) { ); } +/** + * Renders playback controls and progress for the currently selected track. + * + * @returns The player bar, or `null` when no track is selected. + */ export function PlayerBar() { const player = usePlayer(); const progress = usePlayerProgress();