From a0c0da8a8283bc82c49da417c4f95188ab4baac5 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 09:39:44 +0100 Subject: [PATCH 1/4] refactor(media): move media, scanner and watcher into their own directories Signed-off-by: InstaZDLL --- src/{media.rs => media/mod.rs} | 0 src/{scanner.rs => scanner/mod.rs} | 0 src/{watcher.rs => watcher/mod.rs} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename src/{media.rs => media/mod.rs} (100%) rename src/{scanner.rs => scanner/mod.rs} (100%) rename src/{watcher.rs => watcher/mod.rs} (100%) diff --git a/src/media.rs b/src/media/mod.rs similarity index 100% rename from src/media.rs rename to src/media/mod.rs diff --git a/src/scanner.rs b/src/scanner/mod.rs similarity index 100% rename from src/scanner.rs rename to src/scanner/mod.rs diff --git a/src/watcher.rs b/src/watcher/mod.rs similarity index 100% rename from src/watcher.rs rename to src/watcher/mod.rs From 52ee60d4f8ead027b02936dac8a65bab8cfedb5e Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 09:56:06 +0100 Subject: [PATCH 2/4] refactor(media): split the media service into transcode, stream, artwork and canvas Signed-off-by: InstaZDLL --- src/media/artwork.rs | 153 +++ src/media/canvas.rs | 329 ++++++ src/media/mod.rs | 2169 ++-------------------------------------- src/media/stream.rs | 701 +++++++++++++ src/media/tests.rs | 458 +++++++++ src/media/transcode.rs | 493 +++++++++ 6 files changed, 2204 insertions(+), 2099 deletions(-) create mode 100644 src/media/artwork.rs create mode 100644 src/media/canvas.rs create mode 100644 src/media/stream.rs create mode 100644 src/media/tests.rs create mode 100644 src/media/transcode.rs diff --git a/src/media/artwork.rs b/src/media/artwork.rs new file mode 100644 index 00000000..39093225 --- /dev/null +++ b/src/media/artwork.rs @@ -0,0 +1,153 @@ +//! Covers: where a stored artwork is read from, and the route that serves it. + +use axum::{ + extract::{Path as AxumPath, State}, + http::{header, HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; + +use super::{bearer_principal, MediaError}; +use crate::AppState; + +/// Artwork addressed by its own hash: the bytes behind the URL cannot change, +/// so a client never needs to ask again. +const ARTWORK_CANONICAL_CACHE: &str = "private, max-age=31536000, immutable"; +/// Artwork addressed by a track, album or artist: the URL resolves whichever +/// cover that entity carries *now*, and a rescan can move it. +const ARTWORK_ALIAS_CACHE: &str = "private, no-cache"; + +/// Content type for a stored artwork, or `None` for a format we never wrote. +/// +/// Shared by the native route and the Subsonic facade: two copies would drift +/// on the day a format is added, and the client that got +/// `application/octet-stream` would simply show no cover. +pub fn artwork_mime(format: &str) -> Option<&'static str> { + match format { + "jpg" | "jpeg" => Some("image/jpeg"), + "png" => Some("image/png"), + "webp" => Some("image/webp"), + _ => None, + } +} + +/// Reads an artwork file from the store. +/// +/// `hash` and `format` come from the database, never from the request, so the +/// joined path cannot escape `artwork_dir` — the caller resolves the request's +/// id through `artwork_for_user`, which also enforces tenancy. +pub async fn read_artwork( + artwork_dir: &std::path::Path, + hash: &str, + format: &str, +) -> Option<(&'static str, Vec)> { + let mime = artwork_mime(format)?; + let bytes = tokio::fs::read(artwork_dir.join(format!("{hash}.{format}"))) + .await + .ok()?; + Some((mime, bytes)) +} + +#[utoipa::path( + get, + path = "/api/v2/artwork/{artwork_id}", + tag = "media", + params(( + "artwork_id" = String, + Path, + description = "An `artwork_hash` from any catalogue payload, or the id of a track, \ + album or artist that carries one." + )), + responses( + (status = 200, description = "Cover image", content_type = "image/jpeg"), + (status = 401), + (status = 404) + ) +)] +/// Serves a cover behind the same bearer as the rest of the native API. +/// +/// Catalogue payloads carry `artwork_hash` but nothing resolved it: only the +/// Subsonic facade served images, behind a separate credential. A native client +/// therefore showed a remote catalogue with no covers at all. +/// +/// Access is re-checked per request through `artwork_for_user`, so a hash +/// guessed from another tenant's library answers 404 like anything else. +pub async fn artwork( + State(state): State, + AxumPath(artwork_id): AxumPath, + headers: HeaderMap, +) -> Result { + let user = bearer_principal(&state, &headers).await?; + let (hash, format) = state + .services + .artwork_for_user(user.id, &artwork_id) + .await + .map_err(|error| { + tracing::error!(error = %error, "artwork lookup failed"); + MediaError::Internal + })? + .ok_or(MediaError::NotFound)?; + + // How the URL was addressed decides what a client may do with the answer, + // and the two forms are not the same resource. + // + // Addressed by hash, the hash *is* the content: this URL can never answer + // differently, so a client should stop revalidating it entirely. Addressed + // by a track, album or artist id, the lookup above resolved that entity's + // *current* cover — and a rescan that finds new embedded art moves it. The + // alias must stay revalidatable or a replaced cover would be frozen in + // every client's cache for a year. + // + // `private` on both, and neither becomes `public`: the route is + // authenticated and tenant-checked, and two accounts whose libraries hold + // the same cover share its hash, so a shared cache keyed on the URL would + // hand one tenant's artwork to another with no credential in sight. A + // private cache is the client's own and loses nothing by the restriction. + let cache_control = if artwork_id == hash { + ARTWORK_CANONICAL_CACHE + } else { + ARTWORK_ALIAS_CACHE + }; + // The validator is the hash either way, which is what makes revalidating an + // alias a 304 rather than a second full transfer of the same bytes. + let etag = format!("\"{hash}\""); + if if_none_match_hits(&headers, &etag) { + return Ok(( + StatusCode::NOT_MODIFIED, + [ + (header::CACHE_CONTROL, cache_control.to_owned()), + (header::ETAG, etag), + ], + ) + .into_response()); + } + + let (mime, bytes) = read_artwork(&state.artwork_dir, &hash, &format) + .await + .ok_or(MediaError::NotFound)?; + Ok(( + StatusCode::OK, + [ + (header::CONTENT_TYPE, mime.to_owned()), + (header::CACHE_CONTROL, cache_control.to_owned()), + (header::ETAG, etag), + ], + bytes, + ) + .into_response()) +} + +/// Whether the request already holds this exact entity. +/// +/// `*` matches anything the client has, which for a single-representation +/// resource is this one. +pub(super) fn if_none_match_hits(headers: &HeaderMap, etag: &str) -> bool { + headers + .get(header::IF_NONE_MATCH) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .map(str::trim) + .any(|candidate| candidate == etag || candidate == "*") + }) +} diff --git a/src/media/canvas.rs b/src/media/canvas.rs new file mode 100644 index 00000000..64358ebe --- /dev/null +++ b/src/media/canvas.rs @@ -0,0 +1,329 @@ +//! Canvas loops: the routes that serve, place, remove and ticket them. + +use axum::{ + extract::{Path as AxumPath, State}, + http::{header, HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, +}; +use bytes::Bytes; +use uuid::Uuid; + +use super::artwork::if_none_match_hits; +use super::stream::{serve_file, StreamTicketResponse}; +use super::{bearer_principal, MediaError}; +use crate::AppState; + +/// A canvas addressed by its hash. The bytes behind a hash never change, so +/// this URL can never answer differently and a client should stop asking. +const CANVAS_CANONICAL_CACHE: &str = "private, max-age=31536000, immutable"; +/// A canvas addressed by a track: the link is what it resolves, and a member +/// can replace it at any moment. Stated apart from the artwork constants that +/// happen to hold the same policy — a canvas is not a cover, and one changing +/// must not drag the other. +const CANVAS_ALIAS_CACHE: &str = "private, no-cache"; + +fn mime_for_canvas(format: &str) -> &'static str { + match format { + "webm" => "video/webm", + // The store only ever holds what the probe accepted, and that is one of + // exactly two containers. + _ => "video/mp4", + } +} + +/// Serves the blob behind a canvas, once something has authorised it. +/// +/// Shared by the three ways in — the hash, the track alias, and a ticket — so +/// the caching rule and the 304 live in one place and cannot drift apart per +/// route. Which `cache_control` applies is the caller's decision, because only +/// the caller knows how the URL was addressed. +async fn serve_canvas( + state: &AppState, + blob: &crate::services::CanvasBlob, + headers: &HeaderMap, + cache_control: &'static str, +) -> Result { + // The validator is the hash however the URL was addressed, which is what + // makes revalidating an alias a 304 rather than a second transfer of bytes + // the client already holds. + let etag = format!("\"{}\"", blob.hash); + if if_none_match_hits(headers, &etag) { + return Ok(( + StatusCode::NOT_MODIFIED, + [ + (header::CACHE_CONTROL, cache_control.to_owned()), + (header::ETAG, etag), + ], + ) + .into_response()); + } + let range = headers + .get(header::RANGE) + .and_then(|value| value.to_str().ok()); + let path = state.services.canvas_file(blob); + let mut response = serve_file(&path, range, mime_for_canvas(&blob.format)).await?; + let served = response.headers_mut(); + served.insert( + header::CACHE_CONTROL, + HeaderValue::from_str(cache_control).map_err(|_| MediaError::Internal)?, + ); + served.insert( + header::ETAG, + HeaderValue::from_str(&etag).map_err(|_| MediaError::Internal)?, + ); + Ok(response) +} + +#[utoipa::path( + get, + path = "/api/v2/canvas/{canvas_hash}", + tag = "media", + params(("canvas_hash" = String, Path)), + responses( + (status = 200, description = "The loop, immutable under this URL"), + (status = 206), + (status = 304), + (status = 401), + (status = 404) + ) +)] +pub async fn canvas_by_hash( + State(state): State, + AxumPath(canvas_hash): AxumPath, + headers: HeaderMap, +) -> Result { + let user = bearer_principal(&state, &headers).await?; + // Knowing a hash establishes nothing. Two accounts that are strangers to + // each other can hold the same loop, so this resolves through membership + // the way `artwork_for_user` does, and a hash nobody reachable references + // answers exactly like one that does not exist. + let blob = state + .services + .canvas_for_user(user.id, &canvas_hash) + .await + .map_err(|error| { + tracing::error!(error = %error, "canvas lookup failed"); + MediaError::Internal + })? + .ok_or(MediaError::NotFound)?; + serve_canvas(&state, &blob, &headers, CANVAS_CANONICAL_CACHE).await +} + +#[utoipa::path( + get, + path = "/api/v2/tracks/{track_id}/canvas", + tag = "media", + params(("track_id" = Uuid, Path)), + responses( + (status = 200, description = "The loop this track carries now"), + (status = 206), + (status = 304), + (status = 401), + (status = 404) + ) +)] +pub async fn canvas_for_track( + State(state): State, + AxumPath(track_id): AxumPath, + headers: HeaderMap, +) -> Result { + let user = bearer_principal(&state, &headers).await?; + // A track id is guessed no worse than a hash, so the alias is no more an + // authorisation than the fingerprint is: membership in the track's library, + // re-read on this request. A track that does not exist, one belonging to + // somebody else, and one with no canvas all answer 404. + let blob = state + .services + .canvas_for_track(user.id, track_id) + .await + .map_err(|error| { + tracing::error!(error = %error, "canvas alias lookup failed"); + MediaError::Internal + })? + .ok_or(MediaError::NotFound)?; + serve_canvas(&state, &blob, &headers, CANVAS_ALIAS_CACHE).await +} + +#[utoipa::path( + put, + path = "/api/v2/tracks/{track_id}/canvas", + tag = "media", + params(("track_id" = Uuid, Path)), + request_body(content = Vec, description = "An mp4 or webm loop"), + responses( + (status = 200, body = CanvasResponse), + (status = 401), + (status = 404), + (status = 409, description = "The library's canvas quota is full"), + (status = 413), + (status = 422, description = "Not a short loop this server will take") + ) +)] +pub async fn put_canvas( + State(state): State, + AxumPath(track_id): AxumPath, + headers: HeaderMap, + body: Bytes, +) -> Result, MediaError> { + let user = bearer_principal(&state, &headers).await?; + let device = crate::api::origin_device(&state, &headers, user.id) + .await + .map_err(|_| MediaError::InvalidRequest)?; + let blob = state + .services + .place_canvas(user.id, track_id, &body, device) + .await + .map_err(canvas_error)?; + Ok(axum::Json(CanvasResponse { + url: format!("/api/v2/canvas/{}", blob.hash), + hash: blob.hash, + format: blob.format, + byte_size: blob.byte_size, + })) +} + +#[utoipa::path( + delete, + path = "/api/v2/tracks/{track_id}/canvas", + tag = "media", + params(("track_id" = Uuid, Path)), + responses((status = 204), (status = 401), (status = 404)) +)] +pub async fn delete_canvas( + State(state): State, + AxumPath(track_id): AxumPath, + headers: HeaderMap, +) -> Result { + let user = bearer_principal(&state, &headers).await?; + let device = crate::api::origin_device(&state, &headers, user.id) + .await + .map_err(|_| MediaError::InvalidRequest)?; + state + .services + .remove_canvas(user.id, track_id, device) + .await + .map_err(canvas_error)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/api/v2/tracks/{track_id}/canvas-ticket", + tag = "media", + params(("track_id" = Uuid, Path)), + responses((status = 200, body = StreamTicketResponse), (status = 401), (status = 404)) +)] +pub async fn create_canvas_ticket( + State(state): State, + AxumPath(track_id): AxumPath, + headers: HeaderMap, +) -> Result, MediaError> { + let user = bearer_principal(&state, &headers).await?; + // Minting proves access now; redeeming re-proves it. A member removed + // between the two loses the canvas immediately rather than at expiry. + state + .services + .canvas_for_track(user.id, track_id) + .await + .map_err(|error| { + tracing::error!(error = %error, "canvas ticket authorization lookup failed"); + MediaError::Internal + })? + .ok_or(MediaError::NotFound)?; + let expires_at = i64::try_from(state.stream_ticket_ttl.as_millis()) + .ok() + .and_then(|ttl| crate::authentication::now_ms().checked_add(ttl)) + .ok_or_else(|| { + tracing::error!("stream ticket TTL does not fit in an epoch timestamp"); + MediaError::Internal + })?; + let ticket = crate::stream_ticket::mint( + &state.secret_box, + crate::stream_ticket::TicketKind::Canvas, + user.id, + track_id, + expires_at, + ) + .map_err(|error| { + tracing::error!(error = %error, "canvas ticket minting failed"); + MediaError::Internal + })?; + // Relative, for the same reason the audio ticket is: it is consumed by the + // client that just authenticated, not sent out of the application. + Ok(axum::Json(StreamTicketResponse { + url: format!("/api/v2/canvas-stream/{ticket}"), + expires_at, + })) +} + +#[utoipa::path( + get, + path = "/api/v2/canvas-stream/{ticket}", + tag = "media", + params(("ticket" = String, Path)), + responses( + (status = 200, description = "What