From e43b6f276116fe755a55a2b78ad2f57aab3007dc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 21 Apr 2026 00:09:30 -0400 Subject: [PATCH] feat(api): add conversion helpers for library DTOs --- internal/api/convert.go | 67 ++++++++++++++++++++++++++++++++ internal/api/convert_test.go | 75 ++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 internal/api/convert.go create mode 100644 internal/api/convert_test.go diff --git a/internal/api/convert.go b/internal/api/convert.go new file mode 100644 index 00000000..6b9de429 --- /dev/null +++ b/internal/api/convert.go @@ -0,0 +1,67 @@ +package api + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +// uuidToString renders a pgtype.UUID as a canonical hyphenated string. +// Returns "" when the UUID is invalid (unset). Duplicated from the subsonic +// package intentionally — the two packages must not depend on each other. +func uuidToString(u pgtype.UUID) string { + if !u.Valid { + return "" + } + b := u.Bytes + const hex = "0123456789abcdef" + out := make([]byte, 36) + j := 0 + for i, x := range b { + if i == 4 || i == 6 || i == 8 || i == 10 { + out[j] = '-' + j++ + } + out[j] = hex[x>>4] + out[j+1] = hex[x&0x0f] + j += 2 + } + return string(out) +} + +// parseUUID accepts the canonical hyphenated form produced by uuidToString. +// Returns ok=false on any malformed input so handlers can 400 cleanly. +func parseUUID(s string) (pgtype.UUID, bool) { + var u pgtype.UUID + if err := u.Scan(s); err != nil { + return pgtype.UUID{}, false + } + return u, u.Valid +} + +// yearFromDate returns the 4-digit year from a pgtype.Date, or 0 when the +// date is unset. Album release years are optional at scan time. +func yearFromDate(d pgtype.Date) int { + if !d.Valid { + return 0 + } + return d.Time.Year() +} + +// durationMsToSec converts millisecond durations (as stored in tracks.duration_ms) +// to seconds, rounding to nearest. Callers display seconds; sub-second precision +// is not useful in UI. +func durationMsToSec(ms int32) int { + return int((ms + 500) / 1000) +} + +// coverURL returns the /api relative URL for an album's cover art. The +// endpoint ships in Plan 3; until then it 404s, and the SPA does not wire +// to it. +func coverURL(albumID pgtype.UUID) string { + return "/api/albums/" + uuidToString(albumID) + "/cover" +} + +// streamURL returns the /api relative URL for a track's audio stream. The +// endpoint ships in Plan 3. +func streamURL(trackID pgtype.UUID) string { + return "/api/tracks/" + uuidToString(trackID) + "/stream" +} diff --git a/internal/api/convert_test.go b/internal/api/convert_test.go new file mode 100644 index 00000000..ad147415 --- /dev/null +++ b/internal/api/convert_test.go @@ -0,0 +1,75 @@ +package api + +import ( + "testing" + + "github.com/jackc/pgx/v5/pgtype" +) + +func TestUUIDToString(t *testing.T) { + var u pgtype.UUID + if err := u.Scan("00112233-4455-6677-8899-aabbccddeeff"); err != nil { + t.Fatalf("scan: %v", err) + } + got := uuidToString(u) + want := "00112233-4455-6677-8899-aabbccddeeff" + if got != want { + t.Errorf("uuidToString = %q, want %q", got, want) + } + + var zero pgtype.UUID + if s := uuidToString(zero); s != "" { + t.Errorf("uuidToString(invalid) = %q, want empty", s) + } +} + +func TestParseUUID(t *testing.T) { + u, ok := parseUUID("00112233-4455-6677-8899-aabbccddeeff") + if !ok || !u.Valid { + t.Fatalf("parseUUID valid: ok=%v valid=%v", ok, u.Valid) + } + if _, ok := parseUUID("not-a-uuid"); ok { + t.Error("parseUUID accepted garbage") + } + if _, ok := parseUUID(""); ok { + t.Error("parseUUID accepted empty string") + } +} + +func TestYearFromDate(t *testing.T) { + var d pgtype.Date + if y := yearFromDate(d); y != 0 { + t.Errorf("yearFromDate(invalid) = %d, want 0", y) + } +} + +func TestDurationMsToSec(t *testing.T) { + cases := []struct { + ms int32 + want int + }{ + {0, 0}, + {500, 1}, // rounds up + {499, 0}, // rounds down + {1500, 2}, // rounds up + {60000, 60}, + } + for _, c := range cases { + if got := durationMsToSec(c.ms); got != c.want { + t.Errorf("durationMsToSec(%d) = %d, want %d", c.ms, got, c.want) + } + } +} + +func TestCoverURLAndStreamURL(t *testing.T) { + var u pgtype.UUID + if err := u.Scan("00112233-4455-6677-8899-aabbccddeeff"); err != nil { + t.Fatalf("scan: %v", err) + } + if got := coverURL(u); got != "/api/albums/00112233-4455-6677-8899-aabbccddeeff/cover" { + t.Errorf("coverURL = %q", got) + } + if got := streamURL(u); got != "/api/tracks/00112233-4455-6677-8899-aabbccddeeff/stream" { + t.Errorf("streamURL = %q", got) + } +}