diff --git a/flutter_client/lib/cache/adapters.dart b/flutter_client/lib/cache/adapters.dart index 65ada48f..49bbe7dd 100644 --- a/flutter_client/lib/cache/adapters.dart +++ b/flutter_client/lib/cache/adapters.dart @@ -88,7 +88,7 @@ extension CachedPlaylistAdapter on CachedPlaylist { name: name, description: description, isPublic: isPublic, - systemVariant: null, // not tracked in cache + systemVariant: systemVariant, trackCount: trackCount, coverUrl: '', // server-derived; cache doesn't persist ownerUsername: ownerUsername, @@ -105,5 +105,6 @@ extension PlaylistDriftWrite on Playlist { description: drift.Value(description), isPublic: drift.Value(isPublic), trackCount: drift.Value(trackCount), + systemVariant: drift.Value(systemVariant), ); } diff --git a/flutter_client/lib/cache/db.dart b/flutter_client/lib/cache/db.dart index 83089e46..15df5372 100644 --- a/flutter_client/lib/cache/db.dart +++ b/flutter_client/lib/cache/db.dart @@ -76,6 +76,11 @@ class CachedPlaylists extends Table { TextColumn get coverPath => text().nullable()(); IntColumn get trackCount => integer().withDefault(const Constant(0))(); IntColumn get durationSec => integer().withDefault(const Constant(0))(); + /// Server's system_variant: null for user playlists, "for_you" / + /// "songs_like_artist" / "discover" for system-generated mixes. + /// Added in schemaVersion 2 to let the add-to-playlist sheet filter + /// out system playlists locally without a REST round-trip. + TextColumn get systemVariant => text().nullable()(); DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); @override Set get primaryKey => {id}; @@ -126,5 +131,22 @@ class AppDb extends _$AppDb { AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache')); @override - int get schemaVersion => 1; + int get schemaVersion => 2; + + @override + MigrationStrategy get migration => MigrationStrategy( + onCreate: (m) => m.createAll(), + onUpgrade: (m, from, to) async { + if (from < 2) { + // Schema 2: add CachedPlaylists.systemVariant. Existing + // rows get null and the next sync rebuilds them with the + // server's system_variant value. + await m.addColumn(cachedPlaylists, cachedPlaylists.systemVariant); + // Reset cursor so the next /api/library/sync re-emits all + // playlists; otherwise the new column would stay null on + // pre-existing rows until they happen to change server-side. + await customStatement('UPDATE sync_metadata SET cursor = 0'); + } + }, + ); } diff --git a/flutter_client/lib/cache/sync_controller.dart b/flutter_client/lib/cache/sync_controller.dart index 9484365f..1cc3cb2f 100644 --- a/flutter_client/lib/cache/sync_controller.dart +++ b/flutter_client/lib/cache/sync_controller.dart @@ -282,6 +282,7 @@ class SyncController extends AsyncNotifier { coverPath: drift.Value(j['cover_path'] as String?), trackCount: drift.Value((j['track_count'] as num?)?.toInt() ?? 0), durationSec: drift.Value((j['duration_sec'] as num?)?.toInt() ?? 0), + systemVariant: drift.Value(j['system_variant'] as String?), ); } diff --git a/flutter_client/lib/playlists/playlists_provider.dart b/flutter_client/lib/playlists/playlists_provider.dart index 8a1f82cf..56b9175c 100644 --- a/flutter_client/lib/playlists/playlists_provider.dart +++ b/flutter_client/lib/playlists/playlists_provider.dart @@ -17,12 +17,15 @@ final playlistsApiProvider = FutureProvider((ref) async { return PlaylistsApi(await ref.watch(dioProvider.future)); }); -/// Drift-first per #357 plan C. Returns all cached playlists for the -/// current user (owned) + public playlists from others. The `kind` -/// family arg only affects the REST cold-cache fetch shape — drift -/// can't distinguish 'user' from 'system' kind because systemVariant -/// isn't persisted (TODO: add column + schema bump in a follow-up). -/// For v1 this means add-to-playlist sheet shows system playlists too. +/// Drift-first per #357 plan C. Returns cached playlists filtered to +/// match the `kind` family arg: +/// - 'user' → only user-created (systemVariant null), owned by current user +/// - 'system' → only system-generated (systemVariant non-null), owned +/// - 'all' → everything: own user playlists + own system + others' public +/// +/// systemVariant column added in drift schema v2 (#357 follow-up); previous +/// behavior leaked system playlists into add-to-playlist sheet because we +/// couldn't filter locally. final playlistsListProvider = StreamProvider.family((ref, kind) { final db = ref.watch(appDbProvider); @@ -42,11 +45,16 @@ final playlistsListProvider = }, toResult: (rows) { if (user == null) return PlaylistsList.empty(); - final owned = rows + final filtered = rows.where((r) { + if (kind == 'user') return r.systemVariant == null; + if (kind == 'system') return r.systemVariant != null; + return true; // 'all' + }); + final owned = filtered .where((r) => r.userId == user.id) .map((r) => r.toRef()) .toList(); - final pub = rows + final pub = filtered .where((r) => r.userId != user.id && r.isPublic) .map((r) => r.toRef()) .toList(); diff --git a/internal/api/library_sync.go b/internal/api/library_sync.go index 5bf42489..96363ad1 100644 --- a/internal/api/library_sync.go +++ b/internal/api/library_sync.go @@ -141,7 +141,7 @@ func (h *handlers) hydrateUpserts( return nil, err } for _, a := range artists { - b, _ := json.Marshal(a) + b, _ := json.Marshal(toArtistSyncView(a)) out["artist"] = append(out["artist"], b) } } @@ -152,7 +152,7 @@ func (h *handlers) hydrateUpserts( return nil, err } for _, a := range albums { - b, _ := json.Marshal(a) + b, _ := json.Marshal(toAlbumSyncView(a)) out["album"] = append(out["album"], b) } } @@ -163,7 +163,7 @@ func (h *handlers) hydrateUpserts( return nil, err } for _, t := range tracks { - b, _ := json.Marshal(t) + b, _ := json.Marshal(toTrackSyncView(t)) out["track"] = append(out["track"], b) } } @@ -191,7 +191,7 @@ func (h *handlers) hydrateUpserts( if syncpkg.FormatUUID(p.UserID) != userIDStr && !p.IsPublic { continue } - b, _ := json.Marshal(p) + b, _ := json.Marshal(toPlaylistSyncView(p)) out["playlist"] = append(out["playlist"], b) } } diff --git a/internal/api/library_sync_views.go b/internal/api/library_sync_views.go new file mode 100644 index 00000000..d33dbb9e --- /dev/null +++ b/internal/api/library_sync_views.go @@ -0,0 +1,121 @@ +package api + +// Wire shapes for /api/library/sync upserts. The sqlc-generated row +// structs (dbq.Artist, dbq.Album, dbq.Track, dbq.Playlist) have no +// JSON tags (sqlc.yaml: emit_json_tags=false), so a raw json.Marshal +// of them produces PascalCase field names that the Flutter sync +// controller (which reads snake_case keys) can't parse. +// +// These view structs add the JSON tag layer + flatten pgtype.UUID and +// pgtype.Timestamptz / Date into strings, mirroring the pattern +// playlistRowView already established for /api/playlists. +// +// Field names match flutter_client/lib/cache/sync_controller.dart's +// _*FromJson helpers exactly. Adding a field server-side requires a +// matching read in the Flutter helper or it'll be silently dropped. + +import ( + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" +) + +type artistSyncView struct { + ID string `json:"id"` + Name string `json:"name"` + SortName string `json:"sort_name"` + Mbid *string `json:"mbid"` + ArtistThumbPath *string `json:"artist_thumb_path"` + ArtistFanartPath *string `json:"artist_fanart_path"` +} + +func toArtistSyncView(a dbq.Artist) artistSyncView { + return artistSyncView{ + ID: syncpkg.FormatUUID(a.ID), + Name: a.Name, + SortName: a.SortName, + Mbid: a.Mbid, + ArtistThumbPath: a.ArtistThumbPath, + ArtistFanartPath: a.ArtistFanartPath, + } +} + +type albumSyncView struct { + ID string `json:"id"` + ArtistID string `json:"artist_id"` + Title string `json:"title"` + SortTitle string `json:"sort_title"` + ReleaseDate *string `json:"release_date"` + CoverArtPath *string `json:"cover_art_path"` + Mbid *string `json:"mbid"` +} + +func toAlbumSyncView(a dbq.Album) albumSyncView { + var releaseDate *string + if a.ReleaseDate.Valid { + s := a.ReleaseDate.Time.Format("2006-01-02") + releaseDate = &s + } + return albumSyncView{ + ID: syncpkg.FormatUUID(a.ID), + ArtistID: syncpkg.FormatUUID(a.ArtistID), + Title: a.Title, + SortTitle: a.SortTitle, + ReleaseDate: releaseDate, + CoverArtPath: a.CoverArtPath, + Mbid: a.Mbid, + } +} + +type trackSyncView struct { + ID string `json:"id"` + AlbumID string `json:"album_id"` + ArtistID string `json:"artist_id"` + Title string `json:"title"` + DurationMs int32 `json:"duration_ms"` + TrackNumber *int32 `json:"track_number"` + DiscNumber *int32 `json:"disc_number"` + FilePath string `json:"file_path"` + FileFormat string `json:"file_format"` + Genre *string `json:"genre"` +} + +func toTrackSyncView(t dbq.Track) trackSyncView { + return trackSyncView{ + ID: syncpkg.FormatUUID(t.ID), + AlbumID: syncpkg.FormatUUID(t.AlbumID), + ArtistID: syncpkg.FormatUUID(t.ArtistID), + Title: t.Title, + DurationMs: t.DurationMs, + TrackNumber: t.TrackNumber, + DiscNumber: t.DiscNumber, + FilePath: t.FilePath, + FileFormat: t.FileFormat, + Genre: t.Genre, + } +} + +type playlistSyncView struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` + CoverPath *string `json:"cover_path"` + TrackCount int32 `json:"track_count"` + DurationSec int32 `json:"duration_sec"` + SystemVariant *string `json:"system_variant"` +} + +func toPlaylistSyncView(p dbq.Playlist) playlistSyncView { + return playlistSyncView{ + ID: syncpkg.FormatUUID(p.ID), + UserID: syncpkg.FormatUUID(p.UserID), + Name: p.Name, + Description: p.Description, + IsPublic: p.IsPublic, + CoverPath: p.CoverPath, + TrackCount: p.TrackCount, + DurationSec: p.DurationSec, + SystemVariant: p.SystemVariant, + } +} diff --git a/internal/api/library_sync_views_test.go b/internal/api/library_sync_views_test.go new file mode 100644 index 00000000..3691ed69 --- /dev/null +++ b/internal/api/library_sync_views_test.go @@ -0,0 +1,101 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// These tests pin the wire-format keys for /api/library/sync upserts. +// Without them, sqlc model field-name drift or accidental +// `json.Marshal(rawStruct)` regressions would silently break the +// Flutter client (which reads snake_case keys via _*FromJson helpers +// in flutter_client/lib/cache/sync_controller.dart). + +// validUUID is a deterministic test UUID — not a real value, just +// something that pgtype.UUID.Valid will accept. +var validUUID = pgtype.UUID{ + Bytes: [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}, + Valid: true, +} + +func assertJSONKeys(t *testing.T, label string, b []byte, want []string) { + t.Helper() + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("%s: invalid json: %v\nbody: %s", label, err, b) + } + for _, k := range want { + if _, ok := m[k]; !ok { + t.Errorf("%s: missing key %q in %s", label, k, b) + } + } +} + +func TestArtistSyncView_WireKeys(t *testing.T) { + a := dbq.Artist{ID: validUUID, Name: "Aphex Twin", SortName: "Aphex Twin"} + b, err := json.Marshal(toArtistSyncView(a)) + if err != nil { + t.Fatal(err) + } + assertJSONKeys(t, "artist", b, []string{ + "id", "name", "sort_name", "mbid", + "artist_thumb_path", "artist_fanart_path", + }) +} + +func TestAlbumSyncView_WireKeys(t *testing.T) { + a := dbq.Album{ID: validUUID, ArtistID: validUUID, Title: "Drukqs", SortTitle: "Drukqs"} + b, err := json.Marshal(toAlbumSyncView(a)) + if err != nil { + t.Fatal(err) + } + assertJSONKeys(t, "album", b, []string{ + "id", "artist_id", "title", "sort_title", + "release_date", "cover_art_path", "mbid", + }) +} + +func TestTrackSyncView_WireKeys(t *testing.T) { + tr := dbq.Track{ + ID: validUUID, AlbumID: validUUID, ArtistID: validUUID, + Title: "Avril 14th", DurationMs: 121_000, + FilePath: "x", FileFormat: "flac", + } + b, err := json.Marshal(toTrackSyncView(tr)) + if err != nil { + t.Fatal(err) + } + assertJSONKeys(t, "track", b, []string{ + "id", "album_id", "artist_id", "title", "duration_ms", + "track_number", "disc_number", "file_path", "file_format", "genre", + }) +} + +func TestPlaylistSyncView_WireKeys(t *testing.T) { + variant := "discover" + p := dbq.Playlist{ + ID: validUUID, UserID: validUUID, Name: "Test", + IsPublic: true, TrackCount: 5, DurationSec: 600, + SystemVariant: &variant, + } + b, err := json.Marshal(toPlaylistSyncView(p)) + if err != nil { + t.Fatal(err) + } + assertJSONKeys(t, "playlist", b, []string{ + "id", "user_id", "name", "description", + "is_public", "cover_path", "track_count", + "duration_sec", "system_variant", + }) + // Sanity: system_variant carries the value, not just present. + var m map[string]any + _ = json.Unmarshal(b, &m) + if got := m["system_variant"]; got != "discover" { + t.Errorf("system_variant: want 'discover', got %v", got) + } +}