Files
minstrel/internal/api/library_sync_views.go
T
bvandeusen 27f123f7d9 fix(server,flutter): sync wire format + systemVariant column (#357)
Two fixes in one commit because they're entangled — the systemVariant
work would have been theater otherwise.

## The wire-format bug

/api/library/sync was emitting PascalCase JSON for artist / album /
track / playlist upserts (raw json.Marshal of sqlc-generated structs
with no JSON tags — sqlc.yaml: emit_json_tags=false). Flutter's
sync_controller _*FromJson reads snake_case keys, so all metadata
sync rows landed in drift with empty strings / zero ints.

The like_track / like_album / like_artist / playlist_track entities
work because they're hand-built `map[string]string` payloads with
snake_case keys — they sidestepped the bug. The 4 raw-marshal
entities did not.

Existing sync test caught zero of this — it asserts on len(upserts)
not field shape.

Fix: server-side view structs in library_sync_views.go with proper
JSON tags + pgtype-flattening (UUID → 8-4-4-4-12 hex string,
Date → "2006-01-02"). Mirrors the playlistRowView pattern from
/api/playlists. New library_sync_views_test.go pins the wire keys
so future field-name drift breaks loud.

## systemVariant column (closes #357 plan C v1 limitation)

playlistSyncView now carries `system_variant` server → wire.

Flutter drift schema bumped from 1 → 2 with onUpgrade adding the
`systemVariant TEXT NULL` column to cached_playlists. Cursor reset
to 0 in the migration so existing rows refresh with the new field
on the next sync.

playlistsListProvider now filters locally by systemVariant:
- kind='user'   → systemVariant IS NULL  (the add-to-playlist sheet's intent)
- kind='system' → systemVariant IS NOT NULL
- kind='all'    → no filter

Closes the documented v1 limitation where the add-to-playlist sheet
showed system playlists alongside user-created ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:47:30 -04:00

122 lines
3.8 KiB
Go

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,
}
}