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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ CI runs the full suite only on Linux (service container); the Windows leg is a c
- **Tenancy is enforced at the storage layer.** Server handlers under `/api/v1/*` extract `UserId` from the `middleware::authenticate` middleware and call `*_for_user` methods only — never the single-tenant trait surface. `PostgresProfileRepository` deliberately does NOT implement `ProfileRepository`, so the compiler stops a careless `list_all()` from leaking another tenant's rows. Apply the same pattern when adding library / track / playlist repositories.
- **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.
- **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.
- **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).
- **Artwork cache (Phase 1.h.1 + 1.h.2 + 1.h.3).** Shared dedup-by-content store keyed on the BLAKE3 hex digest of the bytes. Three endpoints: `POST /api/v1/artwork` (JWT-authed, raw image bytes, `Content-Type` ∈ {`image/jpeg`, `image/png`, `image/webp`}, 4 MiB cap enforced by the handler + a matching `DefaultBodyLimit::max(MAX_UPLOAD_BYTES + 1024)` so the handler is the authoritative gatekeeper rather than axum's default 2 MiB) hashes server-side, runs the byte stream through [`artwork_pipeline::generate_variants`](src/artwork_pipeline.rs) (decode → fit-into-box resize → JPEG q85 encode per bucket; long-edge clamp prevents upscaling for sources already below the target) to produce `thumb` (≤ 128px) + `preview` (≤ 480px), and returns `{ hash, byte_size, mime, url, variants[] }` — idempotent, a re-upload of the same bytes skips both the storage write AND the pipeline run and echoes the stored row's `mime` + `byte_size` + the cached variant set; `GET /api/v1/artwork/{hash}` is public (the 64-hex hash IS the credential, same model as `share` tokens) and serves the bytes with the recorded Content-Type, `Cache-Control: public, max-age=31536000, immutable` + ETag = `"<hash>"` — the handler falls back to `metadata_artwork_variant` so a client that cached just the variant hash can hit the same route directly; `GET /api/v1/artwork/{hash}/{variant}` (variant ∈ {thumb, preview}) serves the resized JPEG with the same cache posture. Pipeline runs synchronously on the upload thread in 1.h.3 (apalis async lands in 1.i.1). Storage lives in [`src/storage.rs`](src/storage.rs) over Apache's `object_store` trait — `ArtworkBackend::{Local, S3}` enum picks the backend at boot via [`ArtworkBackend::from_env`](src/storage.rs); `Local` writes under `WAVEFLOW_ARTWORK_LOCAL_DIR` (created on the fly), `S3` uses `AmazonS3Builder` + an optional `WAVEFLOW_ARTWORK_S3_ENDPOINT` override to reach MinIO / Cloudflare R2 / Backblaze B2 (`with_allow_http(true)` flips on for the endpoint-override branch so an `http://minio:9000` deploy doesn't get refused; AWS itself ignores the flag). The two backends are mutually exclusive — setting both `WAVEFLOW_ARTWORK_LOCAL_DIR` + `WAVEFLOW_ARTWORK_S3_BUCKET` fails boot loudly. An optional `WAVEFLOW_ARTWORK_S3_PREFIX` wraps the inner store in `object_store::prefix::PrefixStore` so the shared `artwork/<hash>` key shape stays a single source of truth. Metadata persists in `metadata_artwork` (`hash` PK, `mime`, `byte_size`, `created_at`) + `metadata_artwork_variant` (`(parent_hash, variant)` PK, `hash`, `mime`, `byte_size`, `width`, `height`; `ON DELETE CASCADE` so a future GC of the parent reclaims the variants in the same tx), both written via `INSERT … ON CONFLICT DO NOTHING` for race-safe concurrent uploads; SQL helpers in [`db::artwork`](src/db.rs). All routes answer 503 when storage is disabled at boot (no backend env set). Async pipeline + on-demand re-generation land in 1.i.1.
- **Sync (Phase 1.f).** Append-only `sync_op` log keyed on `BIGSERIAL id`; per-`(user, device)` UNIQUEs on `operation_id` (idempotency) and `lamport_ts` (monotonicity). Three REST routes + one WebSocket under `/api/v1/sync/*`: `POST /ops` push (idempotent replay via `ON CONFLICT operation_id DO NOTHING`, 409 + `stored_max` on lamport regression), `GET /ops?since=N` pull (410 + `compacted_up_to` when `0 < since < watermark`; `since=0` is the bootstrap path and always skips the guard so fresh devices can converge from an empty cursor — read-only, never advances the cursor), `POST /ack` (the **only** path that writes `device_sync_cursor`, buffered in memory + flushed every 5 s), `GET /ws?device_id=…` WebSocket fan-out (one broadcast channel, per-frame `user_id` filter so cross-tenant ops never leak). Live state in `sync::SyncHub`: tokio `broadcast::Sender` + `DashMap<(user_id, device_id), AckEntry>` + daily compaction task that flushes ACKs first then collapses superseded ops in the same Postgres transaction as the watermark UPSERT. Watermark monotonic by UPSERT `WHERE` clause. Stale devices (`last_seen_at < now - 90 d`) skipped from the compaction MIN. Tests drive `SyncHub::for_tests` (no background tasks) and call `flush_acks` / `compact_once` directly for determinism.
Expand Down
71 changes: 71 additions & 0 deletions src/api/playlists.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,32 @@ pub struct UpdatePlaylistRequest {
pub icon_id: Option<String>,
}

/// Owner-facing track row. Mirrors `playlist_track` columns minus
/// the redundant `playlist_id` (path-derived). Snapshot fields are
/// nullable on purpose — pre-1.j.b desktops emitted tracks ops
/// without them, and the owner is allowed to see the rows anyway
/// (the public share preview is the only surface that filters NULL
/// snapshots).
#[derive(Debug, Serialize, ToSchema)]
pub struct PlaylistTrackResponse {
/// Source desktop's local i64 track id. NOT a server-canonical
/// reference — a track with id=42 on device A is unrelated to
/// id=42 on device B. The web client uses this only to key
/// rendered rows; cross-device track resolution is a future
/// concern (cf. `migrations/.../playlist_track.sql` header).
pub track_id: i64,
pub position: i32,
pub added_at: i64,
pub snapshot_title: Option<String>,
pub snapshot_artist: Option<String>,
pub snapshot_duration_ms: Option<i64>,
}

pub fn router(state: AppState) -> OpenApiRouter {
OpenApiRouter::new()
.routes(routes!(list_playlists, create_playlist))
.routes(routes!(get_playlist, update_playlist, delete_playlist))
.routes(routes!(list_playlist_tracks))
.with_state(state)
}

Expand Down Expand Up @@ -366,3 +388,52 @@ async fn delete_playlist(
}
}
}

/// List the tracks of a playlist owned by the caller. Same tenant
/// chain as `get_playlist` (`playlist → profile → user`), same 404
/// blur for non-existent / foreign-owned rows. The track ids carried
/// in the response are the source desktop's local i64 ids, NOT
/// server-canonical references — see [`PlaylistTrackResponse`].
#[utoipa::path(
get,
path = "/api/v1/profiles/{profile_id}/playlists/{id}/tracks",
tag = "playlists",
params(
("authorization" = String, Header, description = "Bearer JWT issued by Better Auth"),
("profile_id" = i64, Path, description = "Owning profile id"),
("id" = i64, Path, description = "Playlist id"),
),
responses(
(status = 200, description = "Tracks in position order (may be empty)", body = Vec<PlaylistTrackResponse>),
(status = 401, description = "Missing or invalid bearer token"),
(status = 404, description = "No playlist with that id under the profile owned by the calling user"),
(status = 500, description = "Database or internal failure (body is a plain-text reason)"),
),
)]
async fn list_playlist_tracks(
State(state): State<AppState>,
Extension(UserId(user_id)): Extension<UserId>,
Path((profile_id, id)): Path<(i64, i64)>,
) -> impl IntoResponse {
match crate::db::playlist_track::fetch_for_owner(&state.db, id, profile_id, user_id).await {
Ok(Some(rows)) => {
let body: Vec<PlaylistTrackResponse> = rows
.into_iter()
.map(|r| PlaylistTrackResponse {
track_id: r.track_id,
position: r.position,
added_at: r.added_at,
snapshot_title: r.snapshot_title,
snapshot_artist: r.snapshot_artist,
snapshot_duration_ms: r.snapshot_duration_ms,
})
.collect();
(StatusCode::OK, Json(body)).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, "playlist not found").into_response(),
Err(err) => {
tracing::error!(error = %err, id, profile_id, user_id, "list playlist tracks failed");
(StatusCode::INTERNAL_SERVER_ERROR, "list tracks failed").into_response()
}
}
}
71 changes: 71 additions & 0 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,77 @@ pub mod playlist_track {
})
.collect())
}

/// One row's projection for the owner-facing track list. Carries
/// the source desktop's local `track_id` (the `playlist_track`
/// PK component, NOT a server-canonical reference) + position +
/// added_at + the optional snapshot fields. The owner is allowed
/// to see rows whose snapshot is NULL — pre-1.j.b desktops
/// emitted ops without snapshots; the row still belongs to the
/// playlist and the owner can see "Track #<id>" placeholders
/// until they re-sync on a newer client. The public share
/// preview filters NULL snapshots, the owner read does not.
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct OwnerTrackRow {
pub track_id: i64,
pub position: i32,
pub added_at: i64,
pub snapshot_title: Option<String>,
pub snapshot_artist: Option<String>,
pub snapshot_duration_ms: Option<i64>,
}

/// Owner-facing track list: validate the tenant chain
/// `playlist → profile → user` first, then fetch every row in
/// position order. `Ok(None)` covers "no such playlist", "wrong
/// profile", and "wrong user" with the same response so the
/// handler can blur the three into a single 404.
///
/// Two round-trips on purpose: a single CTE-joined SELECT would
/// conflate "playlist not owned" with "playlist owned but empty"
/// in the result set (both yield zero rows), and we need the
/// 404 vs `[]` distinction at the HTTP boundary. The window
/// between the ownership check and the fetch is benign
/// *because* `playlist_track.playlist_id` carries
/// `ON DELETE CASCADE` (migration `20260609000000_playlist_track.sql`):
/// the only way a row can vanish between the two queries is via
/// parent-playlist deletion, which makes `[]` the correct answer
/// — same shape a brand-new empty playlist returns. If a future
/// migration adds another row-hiding mechanism (soft-delete,
/// archive flag, conditional unique), the rationale here needs
/// to be re-evaluated.
pub async fn fetch_for_owner(
pool: &sqlx::PgPool,
playlist_id: i64,
profile_id: i64,
user_id: i64,
) -> Result<Option<Vec<OwnerTrackRow>>, sqlx::Error> {
let owned: Option<(i64,)> = sqlx::query_as(
"SELECT pl.id
FROM playlist pl
INNER JOIN profile p ON p.id = pl.profile_id
WHERE pl.id = $1 AND pl.profile_id = $2 AND p.user_id = $3",
)
.bind(playlist_id)
.bind(profile_id)
.bind(user_id)
.fetch_optional(pool)
.await?;
if owned.is_none() {
return Ok(None);
}
let rows = sqlx::query_as::<_, OwnerTrackRow>(
"SELECT track_id, position, added_at,
snapshot_title, snapshot_artist, snapshot_duration_ms
FROM playlist_track
WHERE playlist_id = $1
ORDER BY position ASC, track_id ASC",
)
.bind(playlist_id)
.fetch_all(pool)
.await?;
Ok(Some(rows))
}
}

/// Shared artwork cache helpers (Phase 1.h.1). Reads + writes against
Expand Down
4 changes: 4 additions & 0 deletions tests/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ async fn openapi_doc_lists_every_handler(pool: PgPool) {
paths.contains_key("/api/v1/profiles/{profile_id}/playlists/{id}"),
"missing playlists item path in spec"
);
assert!(
paths.contains_key("/api/v1/profiles/{profile_id}/playlists/{id}/tracks"),
"missing playlist tracks path in spec"
);
assert!(
paths.contains_key(
"/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks/{track_id}/stream-url"
Expand Down
Loading
Loading