Skip to content

feat(playlist_track): server-side materialisation + share preview (phase 1.j.a) - #32

Merged
InstaZDLL merged 1 commit into
mainfrom
feat/1-j-a-playlist-track-materialization
Jun 6, 2026
Merged

feat(playlist_track): server-side materialisation + share preview (phase 1.j.a)#32
InstaZDLL merged 1 commit into
mainfrom
feat/1-j-a-playlist-track-materialization

Conversation

@InstaZDLL

@InstaZDLL InstaZDLL commented Jun 6, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the loop on Phase 1.g playlists. Until now /api/v1/share/playlists/{token} always returned tracks: [] regardless of the playlist's actual content because the server had no entity table to materialise the desktop's playlist + field: \"tracks\" ops into. This PR adds the table + the apply pipeline branches + the share preview SELECT.

Wire change is additive — the current desktop emitter (payload.track_ids: [N, …] only) keeps working. New optional payload.snapshots map carries the displayable title/artist/duration so the public share preview can render the rows without a server-side track resolver. Snapshot population lands in 1.j.b (desktop wire bump, future PR).

Architecture decision

Per the post-1.g sprint plan, the playlist_track materialisation needed an architecture choice (file_hash refs / canonical id end-to-end / opaque server storage). User picked snapshot DTO côté desktop: minimal wire change (additive), server stocke le snapshot, share preview reste self-contained sans avoir besoin d'un track table server-side.

Wire shape

Op Field Payload
insert tracks \"tracks\" { \"track_ids\": [N, …], \"snapshots\"?: { \"<id_str>\": { title, artist?, duration_ms? } } }
delete tracks \"tracks\" { \"track_ids\": [N, …] }
set tracks \"tracks\" { \"track_id\": N, \"position\": M } (single-row reorder)

Schema

playlist_track mirrors the SQLite shape at profile/20260411120000_initial.sql:236 for parity with waveflow-core traits:

  • playlist_id BIGINT FK ON DELETE CASCADE
  • track_id BIGINT NOT NULL (no FK — server has no track table yet, Phase 1.k territory)
  • position INTEGER NOT NULL CHECK (position >= 0)
  • added_at BIGINT NOT NULL (epoch-millis per CLAUDE.md convention)
  • snapshot_title TEXT, snapshot_artist TEXT, snapshot_duration_ms BIGINT — all NULLable, populated by 1.j.b-and-later desktops
  • PK (playlist_id, track_id)
  • Index on (playlist_id, position) for ordered scans

Backward-compat strategy

  • Pre-1.j.b desktops emit payload.track_ids only. Rows land with NULL snapshot fields.
  • db::playlist_track::fetch_for_share filters WHERE snapshot_title IS NOT NULL, so NULL-snapshot rows are invisible to the public preview.
  • INSERT … ON CONFLICT DO UPDATE SET … = COALESCE(EXCLUDED.…, playlist_track.…) — a future re-emit with richer metadata enriches the existing row instead of clobbering it. Rows that started life NULL-snapshot become visible automatically once any snapshot-aware client re-syncs.

Parent-playlist ordering

If a tracks op lands before its playlist's own insert (out-of-order pull, retry race), lookup_playlist_id returns None and the apply handler returns Skipped. The durable log keeps the op for replay; the next pass after the playlist materialises picks it up.

Test plan

  • `cargo fmt --all --check` ✅ locally
  • `cargo clippy --all-targets --all-features -- -D warnings` ✅ locally
  • `cargo test --lib` — 34/34 pass (no new unit tests; the new module is exercised end-to-end via integration)
  • `cargo test` integration suite — needs reachable Postgres. 7 new integration tests added:
    • `playlist_insert_tracks_materialises_rows_without_snapshot` — pre-1.j.b wire shape
    • `playlist_insert_tracks_carries_snapshot` — 1.j.b wire shape, asserts snapshot landed
    • `playlist_delete_tracks_drops_rows`
    • `playlist_set_tracks_reorders_position`
    • `playlist_insert_tracks_without_parent_is_skipped` — out-of-order op stays in log
    • `public_get_lists_tracks_when_snapshots_present` — end-to-end share preview with snapshots
    • `public_get_hides_tracks_without_snapshots` — bare wire emission filters out

Follow-up

  • 1.j.b — desktop wire bump (include snapshot map in tracks ops payload). Wire-additive, doesn't break older servers.
  • 1.j.c — web `/p/$token` route renders the populated `tracks[]` array (the DTO field already exists in the web client; just needs UI rendering).

Part of Sprint 2 of the post-1.g sprint plan.

Summary by CodeRabbit

Release Notes

  • New Features

    • Les playlists partagées publiquement affichent désormais la liste des morceaux avec titre, artiste et durée.
    • Ajout de la gestion complète des morceaux de playlist : insertion, suppression et réorganisation.
  • Documentation

    • Documentation technique sur la matérialisation des données de morceaux de playlist.

…ase 1.j.a)

Closes the loop on Phase 1.g playlists: the apply pipeline now
writes `playlist + field: "tracks"` ops into a dedicated
`playlist_track` table, and the public share preview lists the
tracks instead of always returning `tracks: []`.

Schema mirrors the desktop SQLite shape at
`profile/20260411120000_initial.sql:236` — `(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 without a
server-side track resolver.

Wire shape (additive — doesn't break the current desktop emitter):
- `payload.track_ids: [N, …]` (required) for insert + delete
- `payload.snapshots: { "<id_str>": { title, artist?, duration_ms? } }`
  (optional, populated by the 1.j.b wire bump that desktops gain in
  a follow-up release)
- `set tracks` carries `{ track_id, position }` for single-row
  reorder

Snapshot fields land into `playlist_track` with `INSERT … ON
CONFLICT DO UPDATE SET … = COALESCE(EXCLUDED.…, playlist_track.…)`
so a future re-emit with richer metadata enriches the existing row
instead of clobbering it. Pre-1.j.b desktops emit ops without
`snapshots` — rows land with NULL snapshot fields and stay
invisible in the public share preview (`fetch_for_share` filters
`snapshot_title IS NOT NULL`); they become visible automatically
once any snapshot-aware client re-syncs the same playlist.

Parent-playlist lookup misses surface as `Skipped` (not `Applied`)
so the durable log keeps the op for replay once the playlist
insert lands.

Tests (6 new):
- `playlist_insert_tracks_materialises_rows_without_snapshot`
- `playlist_insert_tracks_carries_snapshot`
- `playlist_delete_tracks_drops_rows`
- `playlist_set_tracks_reorders_position`
- `playlist_insert_tracks_without_parent_is_skipped`
- `public_get_lists_tracks_when_snapshots_present`
- `public_get_hides_tracks_without_snapshots`

Follow-up tracked:
- 1.j.b — desktop wire bump (snapshot in tracks ops payload).
- 1.j.c — web `/p/$token` route renders the tracks from the
  populated `tracks[]` array.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Cette PR matérialise complètement les pistes de playlist côté serveur. Elle ajoute une table de jointure, implémente les opérations CRUD, intègre le traitement dans le pipeline d'apply, expose les pistes sur l'API publique via snapshot, et couvre l'ensemble avec des tests end-to-end.

Changes

Playlist Tracks End-to-End

Layer / File(s) Summary
Schéma et types
CLAUDE.md, migrations/20260609000000_playlist_track.sql, src/db.rs
La table playlist_track mappe (playlist_id, track_id) avec position, timestamp et colonnes snapshot optionnelles pour rendu public. Types Rust PublicTrackRow et TrackSnapshot définissent les contrats de persistance.
Opérations de base de données
src/db.rs
Couche playlist_track : append_tracks (upsert avec calcul de position), remove_tracks (suppression en lot), set_position (reorder), fetch_for_share (lecture filtrée ordonnée par position).
Infrastructure du pipeline apply
src/apply.rs
Setup du traitement field = "tracks" : dispatch explicite pour insert/delete/set, imports et helpers de parsing (track_ids_from_payload, snapshots_from_payload).
Gestionnaires apply pour mutations tracks
src/apply.rs
Trois handlers : insert_tracks (lookup parent + append), delete_tracks (lookup + remove), reorder_track (validation position + set). Retourne Skipped si playlist parent absent.
Intégration API publique
src/api/share.rs
Endpoint GET /api/v1/share/playlists/{token} charge les pistes via fetch_for_share, les mappe en PublicTrack, avec fallback sur liste vide en erreur.
Tests du pipeline apply
tests/apply.rs
Cinq tests : insertion sans/avec snapshots (positions auto-assignées, fields snapshot populés), suppression (préservation des autres), reorder (position mise à jour), absence de parent (Skipped).
Tests de l'API publique
tests/share.rs
Helper materialise_playlist_tracks_via_sync + deux tests : avec snapshots (pistes visibles, valeurs intactes), sans snapshots (liste vide due au filtrage).

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ApplyPipeline as Apply Pipeline
  participant Lookup as lookup_playlist_id
  participant DB as db::playlist_track
  
  Client->>ApplyPipeline: sync op (insert/delete/set tracks)
  ApplyPipeline->>ApplyPipeline: dispatch par field & op_type
  alt insert_tracks
    ApplyPipeline->>Lookup: resolve playlist_id
    Lookup-->>ApplyPipeline: playlist_id | None
    ApplyPipeline->>ApplyPipeline: track_ids_from_payload
    ApplyPipeline->>ApplyPipeline: snapshots_from_payload (opt)
    ApplyPipeline->>DB: append_tracks
  else delete_tracks
    ApplyPipeline->>Lookup: resolve playlist_id
    ApplyPipeline->>ApplyPipeline: track_ids_from_payload
    ApplyPipeline->>DB: remove_tracks
  else set_tracks (reorder)
    ApplyPipeline->>Lookup: resolve playlist_id
    ApplyPipeline->>ApplyPipeline: validate {track_id, position}
    ApplyPipeline->>DB: set_position
  end
  DB-->>ApplyPipeline: success | error
  ApplyPipeline-->>Client: Applied | Skipped | Error
Loading
sequenceDiagram
  participant Client
  participant ShareHandler as share.rs handler
  participant DB as db::playlist_track
  
  Client->>ShareHandler: GET /api/v1/share/playlists/{token}
  ShareHandler->>ShareHandler: lookup playlist par token
  alt playlist trouvée
    ShareHandler->>DB: fetch_for_share(playlist_id)
    DB->>DB: SELECT * ORDER BY position<br/>WHERE snapshot_title IS NOT NULL
    DB-->>ShareHandler: Vec<PublicTrackRow>
    alt requête réussie
      ShareHandler->>ShareHandler: map vers Vec<PublicTrack>
    else erreur DB
      ShareHandler->>ShareHandler: warn log, tracks = []
    end
  else playlist non trouvée
    ShareHandler-->>Client: 404
  end
  ShareHandler-->>Client: PublicPlaylistResponse {tracks, ...}
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • InstaZDLL/waveflow-server#25: Introduit le handler GET /api/v1/share/playlists/{token} avec un tableau tracks vide, lequel est maintenant peuplé par cette PR via fetch_for_share.
  • InstaZDLL/waveflow-server#26: Établit le cadre du pipeline apply_op ; cette PR l'étend spécifiquement pour les opérations field = "tracks" et la matérialisation dans playlist_track.

Poem

📚 Des pistes en ordre attendent le partage,
🎵 Snapshots gravés, position par position,
🔄 L'apply les sème, les tests les valident,
🌟 Public et lisible, la playlist s'illumine !

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Le titre décrit précisément la fonctionnalité principale : matérialisation serveur des pistes de playlist et aperçu de partage public (phase 1.j.a), couvrant tous les changements clés du PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1-j-a-playlist-track-materialization

Comment @coderabbitai help to get the list of available commands and usage tips.

@InstaZDLL InstaZDLL self-assigned this Jun 6, 2026
@InstaZDLL
InstaZDLL merged commit 4508d9b into main Jun 6, 2026
8 checks passed
@InstaZDLL
InstaZDLL deleted the feat/1-j-a-playlist-track-materialization branch June 6, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant