Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ CI runs the full suite only on Linux (service container); the Windows leg is a c
- **Auth: JWT-only (Phase 1.d.2).** `middleware::authenticate` requires a Bearer JWT signed by the upstream Better Auth issuer. The middleware verifies the token via [`AppState::jwt_verifier`], then `db::users::find_or_provision_by_external_id(state.db, &sub, now_ms)` lazy-onboards the user on first request — a valid signature is the authoritative onboarding signal, so no separate `POST /api/v1/users` exists. Boot requires the full `WAVEFLOW_JWT_*` triple (`_JWKS_URL` / `_ISSUER` / `_AUDIENCE`); the legacy `X-User-Id` dev shim retired alongside `WAVEFLOW_DEV_AUTH`.
- **Streaming (Phase 1.e).** Two endpoints: `POST /api/v1/profiles/{p}/libraries/{l}/tracks/{t}/stream-url` (JWT-authed) verifies tenant ownership and signs a short-lived (≤ 60 s) URL via [`stream_token::mint`]; `GET /api/v1/stream/{token}` is mounted OUTSIDE the JWT layer because browsers can't attach a Bearer to `<audio src>` — the HMAC in the token IS the auth. The stream handler canonicalises `<music_root>/<file_path>` and refuses anything resolving outside `WAVEFLOW_MUSIC_ROOT` (path-traversal guard via `std::fs::canonicalize` + prefix check). Range requests handled in-process: `Accept-Ranges: bytes`, 206 + `Content-Range` on partial, 416 on unsatisfiable. Both endpoints answer 503 when streaming is disabled at boot (`WAVEFLOW_MUSIC_ROOT` + `WAVEFLOW_STREAM_SECRET` unset). Until 1.f's sync ships, files must be placed manually under the music root.
- **Track sync (Phase 4.d.0.2).** Adds `"track"` to the apply pipeline alongside `"playlist"` / `"library"` (all three profile-scoped, so they share the dispatcher's `profile_canonical_id` gate). Wire shape: `entity_id = file_path` — the per-library natural identity (`UNIQUE (library_id, file_path)` from `20260530000003_track.sql:64`). The BLAKE3 hash rides as a payload field (`file_hash`), not as `entity_id`: using the hash as identity would break the tag-editor re-emit flow because lofty rewrites embedded metadata frames on save (file_hash changes, file_path doesn't) — the upsert would miss its arbiter, fall through to INSERT, and trip the pre-existing `(library_id, file_path)` UNIQUE. The cross-device-content identity from `20260604000000_apply_pipeline.sql:26-33` still lives in the `track.file_hash` column for liked_track / rating joins; it just isn't the row identity for the `track` entity itself. `payload.library_canonical_id` carries the tenant scope. INSERT payload also packs the full track metadata + the album/artist plumbing: `album_title?`, `album_artist_name?` (None → compilation), `is_compilation?`, `artists?: [String, ...]` (the desktop's `";"`-split list — position derives from array index). Empty-string `album_title` / `album_artist_name` are rejected at the apply boundary as InvalidPayload (400-family) so they never trip the `length(...) > 0` CHECK constraints on `album` / `artist` (which would surface as 500). The handler chain: resolve library_id from canonical id (Skipped if not yet materialised), upsert every contributor artist, resolve album_artist_id (dedup against contributors), upsert album, upsert track on `(library_id, file_path)`, then DELETE + single UNNEST INSERT for the `track_artist` link rows. SET ops are intentionally Unknown: the desktop's tag-editor save rewrites the audio file and re-emits a full INSERT — the upsert handles re-emit as a merge (every scalar column overwrites on conflict, including `file_hash`). DELETE is keyed on `(library_id, file_path)` and cascades into `track_artist` via the schema FK. Helpers in `db::track_sync` (`upsert_artist`, `upsert_album`, `upsert_track`, `replace_track_artists`, `lookup_library_id`); apply orchestration in `apply.rs::track`.
- **Album + artist browse (Phase 4.d.0.4).** Four read-only endpoints expose the per-library album / artist surface materialised by the apply pipeline: `GET /api/v1/profiles/{p}/libraries/{l}/albums` + `/artists` list every row under the tenant chain `library → profile → user`, most-recently-updated first (rides the `album_library_updated_idx` / `artist_library_updated_idx` planted in `20260608120000_album_artist.sql:217-221`; `id ASC` tie-break on equal `updated_at` so the order is deterministic when a batch upsert stamps several rows at the same millisecond). The album list joins `artist` once to surface `album_artist_name` so the web client doesn't fan out N artist lookups (compilation rows project `null` `album_artist_id` + `album_artist_name` + `is_compilation = true`). Drill-down: `/albums/{id}/tracks` orders by `(disc_number, track_number, id)` (sleeve order, rides `track_album_idx`); `/artists/{id}/tracks` joins through `track_artist` so a multi-artist track surfaces under every contributor (`DISTINCT` is defensive since the PK already guarantees no dupes — keeps a future schema relax safe). All four use the **2-query ownership-check + fetch** pattern from [`db::playlist_track::fetch_for_owner`](src/db.rs): an ownership SELECT first to distinguish 404 (library / album / artist missing or foreign-owned) from 200 `[]` (owned but empty); the race window between the two is benign because every parent carries `ON DELETE CASCADE` (the only way a row can vanish mid-request is parent deletion, which makes `[]` the correct answer). Writes are NOT exposed — album / artist rows materialise from `apply::track` (4.d.0.2). 404-blur convention matches every other tenant-scoped endpoint. Helpers in [`db::album`](src/db.rs) + [`db::artist`](src/db.rs); handlers in [`api/albums.rs`](src/api/albums.rs) + [`api/artists.rs`](src/api/artists.rs).
- **Album + artist schema (Phase 4.d.0.1).** `album`, `artist`, `track_artist` tables ship in [`migrations/20260608120000_album_artist.sql`](migrations/20260608120000_album_artist.sql), plus a nullable `track.album_id` FK. Per-library scope (matches `track.library_id`) — the same album in two libraries = two rows, deleting a library reclaims its album + artist + track_artist rows in one cascade. Natural keys: `album` is `(library_id, canonical_title, album_artist_id)` with `UNIQUE NULLS NOT DISTINCT` (PG15+; we target PG17) so two compilation rows with NULL `album_artist_id` collapse to one — without it the apply-time upsert would have to special-case the IS NULL match. `artist` is `(library_id, name)`. `track_artist` PK is `(track_id, artist_id)` with a `position` column preserving the multi-artist ordering the desktop ships (semicolon-split per WaveFlow CLAUDE.md "Multi-artist queries"). FK posture: `track_artist` cascades both ways (track or artist gone → row gone), `album.album_artist_id` SET NULL on artist delete (don't lose the album row), `track.album_id` SET NULL on album delete (don't lose the audio file row). **Cross-library scope is enforced at the schema** via composite FKs — every entity-to-entity link carries `library_id` in BOTH columns of the FK with the parent's `UNIQUE (id, library_id)` as target, so an attempt to set `album.album_artist_id` to an artist in a different library (or `track.album_id` to a foreign album, or `track_artist` to a cross-library pair) is rejected at INSERT/UPDATE time. `track_artist` carries a denormalised `library_id` for this; the apply pipeline (4.d.0.2) derives it from `track.library_id` at upsert. `ON DELETE SET NULL (col)` (PG15+ column-level form) drops only the FK link when the parent is deleted, leaving `library_id` intact. Schema-only here — the apply pipeline upsert lands in 4.d.0.2 and the read endpoints in 4.d.0.4.
- **playlist_track materialisation (Phase 1.j.a).** Apply pipeline writes `playlist + field: "tracks"` ops into a dedicated `playlist_track` table that mirrors the desktop SQLite shape at [`profile/20260411120000_initial.sql:236`](https://github.com/InstaZDLL/WaveFlow/blob/main/src-tauri/migrations/profile/20260411120000_initial.sql) — `(playlist_id, track_id)` PK + position index, BIGINT epoch-millis for `added_at`. The `track_id` column is the source desktop's local-i64 id (no FK because the server has no `track` table yet — Phase 1.k territory); per-row snapshot columns (`snapshot_title`, `snapshot_artist`, `snapshot_duration_ms`) carry the displayable values cross-device so the public share preview can render the tracks. Wire shape:`payload.track_ids: [N, …]` for insert + delete (required); optional `payload.snapshots: { "<id_str>": { title, artist?, duration_ms? } }` for the 1.j.b wire bump that desktops gain in a follow-up release. Pre-1.j.b desktops emit ops without `snapshots` — rows land with NULL snapshot fields and stay invisible in the public preview (`fetch_for_share` filters `snapshot_title IS NOT NULL`). `set tracks` carries `{ track_id, position }` for single-row reorder. `insert_tracks` UPSERTs with `COALESCE`-merged snapshots so a future re-emit with richer metadata enriches the existing row instead of clobbering it. Parent-playlist lookup misses surface as `Skipped` (not `Applied`) so the durable log keeps the op for replay once the playlist insert lands. **Owner read surface (Phase 1.j.c)**: `GET /api/v1/profiles/{profile_id}/playlists/{id}/tracks` returns every row in `(position ASC, track_id ASC)` order — snapshot fields nullable in the response because the owner is allowed to see pre-1.j.b rows (the public-preview snapshot filter does NOT apply here). 404 blurs "no such playlist" / "wrong profile" / "wrong user" with the same shape, same no-existence-leak rationale as `get_playlist`. Two round-trips on purpose (`db::playlist_track::fetch_for_owner`) — a single CTE-joined SELECT conflates "not owned" with "owned but empty" in the result set, and we need that distinction at the HTTP boundary.
- **Artwork background scanner (Phase 1.i.1).** Tokio task spawned at boot when both the artwork backend AND the scanner are configured (`WAVEFLOW_ARTWORK_SCANNER_DISABLED` to opt out; defaults: 5-minute cadence, 50 parents per cycle). Lives in [`src/artwork_jobs.rs`](src/artwork_jobs.rs); same shape as the sync compaction loop (`SyncHub::spawn`'s nightly task). Each cycle calls `db::artwork::list_partial_parents` (`COUNT(metadata_artwork_variant) < EXPECTED_VARIANT_COUNT`, oldest-first), pulls the source bytes from object_store, re-runs the resize pipeline, and inserts only the variants still missing — race-safe via `ON CONFLICT (parent_hash, variant) DO NOTHING`, so a concurrent upload-side repair (or a peer scanner instance in a multi-replica deploy) collapses cleanly. Per-parent failures are logged + skipped; the cycle only errors on a top-level DB failure. **We deliberately picked a tokio polling loop over `apalis` for 1.i.1** — the workload is "periodic catch-up" rather than "queue-driven retries with priorities", and the surrounding infra already has a compaction loop to generalise from. `apalis` lands when a job type genuinely needs persistent queues + priorities (e.g. RFC-004 community moderation).
Expand Down
173 changes: 173 additions & 0 deletions src/api/albums.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//! `/api/v1/profiles/{profile_id}/libraries/{library_id}/albums*` —
//! tenant-scoped read surface over the `album` table (Phase 4.d.0.4).
//!
//! Same nested pattern as `tracks.rs`: every handler reads [`UserId`]
//! from the request extension, threads the path's `profile_id` +
//! `library_id` through, and calls into [`crate::db::album`]. The
//! repository SQL walks `album → library → profile → user` inline so
//! requests targeting a foreign profile / foreign library / foreign
//! album short-circuit at the storage layer.
//!
//! Writes are NOT exposed here. Album rows materialise from the sync
//! apply pipeline (`apply::track`, phase 4.d.0.2) — they're derived
//! from the desktop's tag metadata, not user-created on the server.
//! Same rationale as the artist endpoints in [`super::artists`].
//!
//! 404 (vs 403) on missing / foreign-owned rows is deliberate — same
//! no-existence-leak rationale as every other tenant-scoped endpoint.

use axum::{
extract::{Extension, Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::Serialize;
use utoipa::ToSchema;
use utoipa_axum::{router::OpenApiRouter, routes};

use crate::{db, middleware::UserId, AppState};

use super::tracks::TrackResponse;

/// Wire-format album row. Surfaces the `album_artist_name` joined
/// from `artist` so the web client's album-grid doesn't have to
/// resolve N artist names one-by-one. `album_artist_id` rides
/// alongside so the UI can deep-link straight into the artist
/// drill-down without a name → id lookup.
///
/// `album_artist_*` are `None` for compilations (the schema's
/// `NULLS NOT DISTINCT` natural key collapses NULL `album_artist_id`
/// to a single row per `(library, title)`); the UI renders the
/// "Various Artists" label client-side based on `is_compilation`.
#[derive(Debug, Serialize, ToSchema)]
pub struct AlbumResponse {
pub id: i64,
pub canonical_title: String,
pub album_artist_id: Option<i64>,
pub album_artist_name: Option<String>,
pub year: Option<i64>,
/// BLAKE3 hex of the album cover in the shared metadata cache.
/// `None` until the server-side cover-extraction pipeline ships.
pub cover_hash: Option<String>,
pub is_compilation: bool,
pub created_at: i64,
pub updated_at: i64,
}

impl From<db::album::AlbumRow> for AlbumResponse {
fn from(row: db::album::AlbumRow) -> Self {
Self {
id: row.id,
canonical_title: row.canonical_title,
album_artist_id: row.album_artist_id,
album_artist_name: row.album_artist_name,
year: row.year,
cover_hash: row.cover_hash,
is_compilation: row.is_compilation,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
}

pub fn router(state: AppState) -> OpenApiRouter {
OpenApiRouter::new()
.routes(routes!(list_albums))
.routes(routes!(list_album_tracks))
.with_state(state)
}

/// List every album under `(profile_id, library_id)` owned by the
/// calling user, most-recently-updated first. 404 covers "no such
/// library" / "library belongs to a foreign profile" / "foreign user"
/// — same no-leak blur as `get_library`. An owned-but-empty library
/// returns `200 []`.
#[utoipa::path(
get,
path = "/api/v1/profiles/{profile_id}/libraries/{library_id}/albums",
tag = "albums",
params(
("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"),
("profile_id" = i64, Path, description = "Owning profile id"),
("library_id" = i64, Path, description = "Owning library id"),
),
responses(
(status = 200, description = "Albums under the library, most-recently-updated first", body = Vec<AlbumResponse>),
(status = 401, description = "Missing or invalid bearer token"),
(status = 404, description = "Library / profile not owned by the calling user"),
(status = 500, description = "Database or internal failure (body is a plain-text reason)"),
),
)]
async fn list_albums(
State(state): State<AppState>,
Extension(UserId(user_id)): Extension<UserId>,
Path((profile_id, library_id)): Path<(i64, i64)>,
) -> impl IntoResponse {
match db::album::list_for_library(&state.db, library_id, profile_id, user_id).await {
Ok(Some(rows)) => {
let body: Vec<AlbumResponse> = rows.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(body)).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, "library not found").into_response(),
Err(err) => {
tracing::error!(
error = %err,
user_id,
profile_id,
library_id,
"list albums failed"
);
(StatusCode::INTERNAL_SERVER_ERROR, "list failed").into_response()
}
}
}

/// Drill-down: list every track linked to `album_id` under
/// `(profile_id, library_id)`, ordered `(disc_number, track_number,
/// id)` so the standard "Side A → Side B" sleeve order falls out
/// naturally. 404 blurs every non-owned case. An owned album with
/// no remaining tracks (every linked track was deleted; the album
/// row outlives its tracks via `ON DELETE SET NULL`) returns
/// `200 []`.
#[utoipa::path(
get,
path = "/api/v1/profiles/{profile_id}/libraries/{library_id}/albums/{id}/tracks",
tag = "albums",
params(
("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"),
("profile_id" = i64, Path, description = "Owning profile id"),
("library_id" = i64, Path, description = "Owning library id"),
("id" = i64, Path, description = "Album id"),
),
responses(
(status = 200, description = "Tracks under the album, in sleeve order (may be empty)", body = Vec<TrackResponse>),
(status = 401, description = "Missing or invalid bearer token"),
(status = 404, description = "No album with that id under the (library, profile) owned by the calling user"),
(status = 500, description = "Database or internal failure (body is a plain-text reason)"),
),
)]
async fn list_album_tracks(
State(state): State<AppState>,
Extension(UserId(user_id)): Extension<UserId>,
Path((profile_id, library_id, id)): Path<(i64, i64, i64)>,
) -> impl IntoResponse {
match db::album::list_tracks_for_album(&state.db, id, library_id, profile_id, user_id).await {
Ok(Some(rows)) => {
let body: Vec<TrackResponse> = rows.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(body)).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, "album not found").into_response(),
Err(err) => {
tracing::error!(
error = %err,
id,
user_id,
profile_id,
library_id,
"list album tracks failed"
);
(StatusCode::INTERNAL_SERVER_ERROR, "list tracks failed").into_response()
}
}
}
Loading
Loading