27f123f7d9
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>
122 lines
3.8 KiB
Go
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,
|
|
}
|
|
}
|