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 @@ -42,6 +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.
- **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
62 changes: 62 additions & 0 deletions migrations/20260609000000_playlist_track.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
-- =============================================================================
-- Phase 1.j.a — server-side `playlist_track` materialisation.
--
-- The desktop already emits sync ops on the wire shape
-- `entity: "playlist", field: "tracks", op: ("insert" | "delete" | "set")`
-- with a payload of `{ "track_ids": [N, …] }` (insert/delete) or
-- `{ "track_id": N, "position": M }` (set/reorder). Before this
-- migration the server accepted those ops into `sync_op` but had no
-- entity table to materialise them into, so `/api/v1/share/playlists/{token}`
-- always returned `tracks: []` regardless of the playlist's actual
-- content.
--
-- Shape mirrors the desktop SQLite mirror at
-- `src-tauri/migrations/profile/20260411120000_initial.sql:236` —
-- `(playlist_id, track_id)` PK + position index — so a future
-- `waveflow-core::repository::playlist_track` trait satisfies the
-- same shape against either backend (Postgres BIGINT ↔ SQLite
-- INTEGER, epoch-millis BIGINT for timestamps).
--
-- The `track_id` column carries the source desktop's local-i64
-- track id. The server can't resolve it cross-device (a track with
-- id=42 on device A is unrelated to id=42 on device B), so the
-- snapshot columns below carry the displayable values cross-device
-- — desktops on the 1.j.b wire bump populate them. A row with NULL
-- snapshot is still indexable + visible to the owner's other
-- devices on next sync, but is filtered out of the public share
-- preview (the public read has nothing displayable for it).
--
-- A future track-canonical migration can replace `track_id` with a
-- server-resolved foreign key once Phase 1.k plumbs the `track`
-- entity through the apply pipeline.
-- =============================================================================

CREATE TABLE playlist_track (
playlist_id BIGINT NOT NULL REFERENCES playlist(id) ON DELETE CASCADE,

-- Track id AS EMITTED BY THE SOURCE DESKTOP. Local-to-device
-- BIGINT, NOT a server canonical reference. No FK because the
-- server has no `track` table yet — see the migration header
-- for the migration path to a real FK.
track_id BIGINT NOT NULL,

position INTEGER NOT NULL CHECK (position >= 0),

added_at BIGINT NOT NULL,

-- Snapshot fields populated by desktops that emit the 1.j.b
-- wire bump (a future PR). Older desktops continue to emit ops
-- without these; the row is still tracked but invisible from
-- the public share preview, which filters on
-- `snapshot_title IS NOT NULL`.
snapshot_title TEXT,
snapshot_artist TEXT,
snapshot_duration_ms BIGINT CHECK (snapshot_duration_ms IS NULL OR snapshot_duration_ms > 0),

PRIMARY KEY (playlist_id, track_id)
);

-- Mirrors the SQLite index: ordered scans for the share preview
-- and for the owner's listing of a playlist's tracks.
CREATE INDEX idx_playlist_track_position
ON playlist_track(playlist_id, position);
60 changes: 45 additions & 15 deletions src/api/share.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,21 +287,51 @@ async fn get_public_playlist(
cover_hash,
created_at,
updated_at,
))) => (
StatusCode::OK,
Json(PublicPlaylistResponse {
id,
name,
description,
color_id,
icon_id,
cover_hash,
created_at,
updated_at,
tracks: Vec::new(),
}),
)
.into_response(),
))) => {
// Phase 1.j.a — populate the track list from
// `playlist_track`, filtered to rows that carry a
// snapshot title (the only ones we can display in the
// public preview). Pre-1.j.b desktops emit tracks ops
// without snapshots; their rows are still tracked but
// the public read skips them until the desktop ships
// the wire bump.
//
// Track-list lookup failure logs + falls back to empty
// rather than bubbling up — the playlist itself
// resolved cleanly, so a downstream tracks query hiccup
// shouldn't take the whole preview down. Worst case the
// crawler sees an empty list, same shape as a brand-
// new playlist.
let tracks = match crate::db::playlist_track::fetch_for_share(&state.db, id).await {
Ok(rows) => rows
.into_iter()
.map(|r| PublicTrack {
title: r.title,
artist: r.artist,
duration_ms: r.duration_ms,
})
.collect(),
Err(err) => {
tracing::warn!(error = %err, playlist_id = id, "share public tracks lookup failed, falling back to empty");
Vec::new()
}
};
(
StatusCode::OK,
Json(PublicPlaylistResponse {
id,
name,
description,
color_id,
icon_id,
cover_hash,
created_at,
updated_at,
tracks,
}),
)
.into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(err) => {
tracing::error!(error = %err, "share public lookup failed");
Expand Down
Loading
Loading