Prefer UUIDs in the API (Phase 3 of the UUID migration)
CI / docker (push) Successful in 46s
CI / integration (push) Successful in 23s
CI / test (push) Successful in 30s
CI / publish (push) Skipped

- New /api/v1 endpoints: GET /soundtracks, /soundtracks/{uuid},
  /songs/{uuid} and /songs/{uuid}/file (streams the song, Range support,
  locked during sync)
- UUIDs added to every response with soundtracks or songs: queue info and
  list (SoundtrackUuid, SongUuid) and statistics (soundtrack_uuid,
  song_uuid). Existing fields stay for backward compatibility
- Play counts and broken-song removal use song UUIDs instead of
  soundtrack ID + song name, so songs with the same name are counted
  separately
- Statistics return an empty song list for soundtracks without songs
  instead of a single empty entry
- The legacy .id file cleanup stays until Phase 4 (#31)

Closes #29
This commit is contained in:
Sansan committed 2026-10-06 15:54:41 +02:00
1 parent 8c974a49cc
commit 2c3e97190d
25 files changed
+1521 -117

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
package backend
import (
"context"
"errors"
"time"
"music-server/internal/db/repository"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Soundtrack is a soundtrack as returned by the API.
type Soundtrack struct {
Uuid uuid.UUID `json:"uuid"`
Name string `json:"name"`
TimesPlayed int32 `json:"times_played"`
LastPlayed *time.Time `json:"last_played,omitempty"`
Added time.Time `json:"added"`
}
// SoundtrackWithSongList is a soundtrack with all its songs.
type SoundtrackWithSongList struct {
Soundtrack
Songs []Song `json:"songs"`
}
// Song is a song as returned by the API.
type Song struct {
Uuid uuid.UUID `json:"uuid"`
Name string `json:"name"`
FileName *string `json:"file_name,omitempty"`
TimesPlayed int32 `json:"times_played"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"`
}
// LibraryService looks up soundtracks and songs by UUID.
type LibraryService struct {
q repository.Querier
}
// NewLibraryService creates a LibraryService.
func NewLibraryService(q repository.Querier) *LibraryService {
return &LibraryService{q: q}
}
func toSoundtrack(s repository.Soundtrack) Soundtrack {
return Soundtrack{
Uuid: s.Uuid,
Name: s.SoundtrackName,
TimesPlayed: s.TimesPlayed,
LastPlayed: s.LastPlayed,
Added: s.Added,
}
}
func notFound(err error) error {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
// Soundtracks returns all active soundtracks, sorted by name.
func (l *LibraryService) Soundtracks(ctx context.Context) ([]Soundtrack, error) {
rows, err := l.q.FindAllSoundtracks(ctx)
if err != nil {
return nil, err
}
soundtracks := make([]Soundtrack, len(rows))
for i, row := range rows {
soundtracks[i] = toSoundtrack(row)
}
return soundtracks, nil
}
// Soundtrack returns an active soundtrack with its songs.
func (l *LibraryService) Soundtrack(ctx context.Context, id uuid.UUID) (SoundtrackWithSongList, error) {
row, err := l.q.GetSoundtrackByUUID(ctx, id)
if err != nil {
return SoundtrackWithSongList{}, notFound(err)
}
songRows, err := l.q.GetSongsBySoundtrackUUID(ctx, id)
if err != nil {
return SoundtrackWithSongList{}, err
}
songs := make([]Song, len(songRows))
for i, song := range songRows {
songs[i] = toSong(song, row.SoundtrackName)
}
return SoundtrackWithSongList{Soundtrack: toSoundtrack(row), Songs: songs}, nil
}
func toSong(s repository.Song, soundtrackName string) Song {
return Song{
Uuid: s.Uuid,
Name: s.SongName,
FileName: s.FileName,
TimesPlayed: s.TimesPlayed,
SoundtrackUuid: s.SoundtrackUuid,
SoundtrackName: soundtrackName,
}
}
// Song returns a song of an active soundtrack.
func (l *LibraryService) Song(ctx context.Context, id uuid.UUID) (Song, error) {
row, err := l.q.GetActiveSongByUUID(ctx, id)
if err != nil {
return Song{}, notFound(err)
}
return toSong(row.Song, row.SoundtrackName), nil
}
// SongPath returns the file path of a song of an active soundtrack, if the file can be played.
func (l *LibraryService) SongPath(ctx context.Context, id uuid.UUID) (string, error) {
row, err := l.q.GetActiveSongByUUID(ctx, id)
if err != nil {
return "", notFound(err)
}
if !songIsPlayable(row.Song, row.SoundtrackPath) {
return "", ErrNotFound
}
return row.Song.Path, nil
}
@@ -0,0 +1,81 @@
//go:build integration
package backend
import (
"context"
"os"
"path/filepath"
"testing"
"music-server/internal/db/repository"
"music-server/internal/testutil/pgtest"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestLibraryAndStatisticsWithPostgres checks the UUID and statistics queries against a real database.
func TestLibraryAndStatisticsWithPostgres(t *testing.T) {
ctx := context.Background()
q := repository.New(pgtest.NewMigratedDatabase(t).Pool)
musicDir := copyTestMusic(t)
require.NoError(t, os.Mkdir(filepath.Join(musicDir, "Empty Soundtrack"), 0o755))
writeFile(t, filepath.Join(musicDir, "Empty Soundtrack", "cover.jpg"), "image")
s := NewSyncService(q, musicDir, nil)
require.True(t, s.Start(false))
s.Wait()
require.Empty(t, s.Result().CatchedErrors)
library := NewLibraryService(q)
soundtracks, err := library.Soundtracks(ctx)
require.NoError(t, err)
require.Len(t, soundtracks, 4)
bird, err := library.Soundtrack(ctx, soundtracks[0].Uuid)
require.NoError(t, err)
assert.Equal(t, "A Bird Story", bird.Name)
require.Len(t, bird.Songs, 6)
song := bird.Songs[0]
assert.Equal(t, bird.Uuid, song.SoundtrackUuid)
info, err := library.Song(ctx, song.Uuid)
require.NoError(t, err)
assert.Equal(t, "A Bird Story", info.SoundtrackName)
path, err := library.SongPath(ctx, song.Uuid)
require.NoError(t, err)
assert.FileExists(t, path)
_, err = library.Song(ctx, uuid.New())
assert.ErrorIs(t, err, ErrNotFound)
// Play counts by UUID.
require.NoError(t, q.AddSongPlayedByUUID(ctx, song.Uuid))
require.NoError(t, q.AddSoundtrackPlayedByUUID(ctx, bird.Uuid))
info, err = library.Song(ctx, song.Uuid)
require.NoError(t, err)
assert.Equal(t, int32(1), info.TimesPlayed)
stats := NewStatisticsService(q)
most, err := stats.MostPlayedSoundtracks(ctx, 10)
require.NoError(t, err)
require.NotEmpty(t, most)
assert.Equal(t, bird.Uuid, most[0].SoundtrackUuid)
assert.Len(t, most[0].Songs, 6)
assert.NotEqual(t, uuid.Nil, most[0].Songs[0].SongUuid)
songs, err := stats.MostPlayedSongs(ctx, 1)
require.NoError(t, err)
assert.Equal(t, song.Uuid, songs[0].SongUuid)
assert.Equal(t, bird.Uuid, songs[0].SoundtrackUuid)
never, err := stats.NeverPlayedSoundtracks(ctx)
require.NoError(t, err)
for _, st := range never {
assert.NotEqual(t, uuid.Nil, st.SoundtrackUuid)
if st.SoundtrackName == "Empty Soundtrack" {
assert.Empty(t, st.Songs, "a soundtrack without songs has an empty song list")
}
}
}
+82
View File
@@ -0,0 +1,82 @@
package backend
import (
"context"
"os"
"testing"
"time"
"music-server/internal/db/repository"
"music-server/internal/testutil/fakedb"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLibrarySoundtracks(t *testing.T) {
store := fakedb.New()
b := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "B Game", TimesPlayed: 3})
a := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "A Game"})
deleted := time.Now()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Gone", Deleted: &deleted})
soundtracks, err := NewLibraryService(store).Soundtracks(context.Background())
require.NoError(t, err)
require.Len(t, soundtracks, 2)
assert.Equal(t, Soundtrack{Uuid: a.Uuid, Name: "A Game"}, soundtracks[0])
assert.Equal(t, b.Uuid, soundtracks[1].Uuid)
assert.Equal(t, int32(3), soundtracks[1].TimesPlayed)
}
func TestLibrarySoundtrackWithSongs(t *testing.T) {
store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song B", "Song A")
l := NewLibraryService(store)
result, err := l.Soundtrack(context.Background(), st.Uuid)
require.NoError(t, err)
assert.Equal(t, "Game A", result.Name)
require.Len(t, result.Songs, 2)
assert.Equal(t, "Song A", result.Songs[0].Name)
assert.Equal(t, st.Uuid, result.Songs[0].SoundtrackUuid)
assert.Equal(t, "Game A", result.Songs[0].SoundtrackName)
_, err = l.Soundtrack(context.Background(), uuid.New())
assert.ErrorIs(t, err, ErrNotFound)
}
func TestLibrarySong(t *testing.T) {
store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song")
song := store.SongsOf(st.ID)[0]
l := NewLibraryService(store)
ctx := context.Background()
result, err := l.Song(ctx, song.Uuid)
require.NoError(t, err)
assert.Equal(t, Song{Uuid: song.Uuid, Name: "Song", FileName: song.FileName, SoundtrackUuid: st.Uuid, SoundtrackName: "Game A"}, result)
path, err := l.SongPath(ctx, song.Uuid)
require.NoError(t, err)
assert.Equal(t, song.Path, path)
require.NoError(t, os.Remove(song.Path))
_, err = l.SongPath(ctx, song.Uuid)
assert.ErrorIs(t, err, ErrNotFound, "missing files are not served")
_, err = l.Song(ctx, uuid.New())
assert.ErrorIs(t, err, ErrNotFound)
}
func TestLibraryHidesSongsOfDeletedSoundtracks(t *testing.T) {
store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song")
song := store.SongsOf(st.ID)[0]
require.NoError(t, store.MarkSoundtracksDeletedExcept(context.Background(), nil))
_, err := NewLibraryService(store).Song(context.Background(), song.Uuid)
assert.ErrorIs(t, err, ErrNotFound)
_, err = NewLibraryService(store).Soundtrack(context.Background(), st.Uuid)
assert.ErrorIs(t, err, ErrNotFound)
}
+17 -10
View File
@@ -13,6 +13,7 @@ import (
"music-server/internal/db/repository"
"music-server/internal/logging"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
)
@@ -21,12 +22,14 @@ import (
const maxPickAttempts = 100
type SongInfo struct {
Soundtrack string `json:"Soundtrack"`
SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
Song string `json:"Song"`
SongPlayed int32 `json:"SongPlayed"`
CurrentlyPlaying bool `json:"CurrentlyPlaying"`
SongNo int `json:"SongNo"`
SoundtrackUuid uuid.UUID `json:"SoundtrackUuid"`
Soundtrack string `json:"Soundtrack"`
SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
SongUuid uuid.UUID `json:"SongUuid"`
Song string `json:"Song"`
SongPlayed int32 `json:"SongPlayed"`
CurrentlyPlaying bool `json:"CurrentlyPlaying"`
SongNo int `json:"SongNo"`
}
// MusicService picks songs and keeps track of the shared play queue.
@@ -111,10 +114,10 @@ func (m *MusicService) SetPlayed(ctx context.Context, songNumber int) error {
}
func (m *MusicService) markPlayed(ctx context.Context, song repository.Song) error {
if err := m.q.AddSoundtrackPlayed(ctx, song.SoundtrackID); err != nil {
if err := m.q.AddSoundtrackPlayedByUUID(ctx, song.SoundtrackUuid); err != nil {
return err
}
return m.q.AddSongPlayed(ctx, repository.AddSongPlayedParams{SoundtrackID: song.SoundtrackID, SongName: song.SongName})
return m.q.AddSongPlayedByUUID(ctx, song.Uuid)
}
func (m *MusicService) validIndex(i int) bool {
@@ -182,8 +185,10 @@ func (m *MusicService) SongInfo(ctx context.Context) SongInfo {
song := m.queue[m.currentSong]
soundtrack := m.soundtrackFor(ctx, song)
return SongInfo{
SoundtrackUuid: soundtrack.Uuid,
Soundtrack: soundtrack.SoundtrackName,
SoundtrackPlayed: soundtrack.TimesPlayed,
SongUuid: song.Uuid,
Song: song.SongName,
SongPlayed: song.TimesPlayed,
CurrentlyPlaying: true,
@@ -197,8 +202,10 @@ func (m *MusicService) PlayedSongs(ctx context.Context) []SongInfo {
for i, song := range m.queue {
soundtrack := m.soundtrackFor(ctx, song)
songList = append(songList, SongInfo{
SoundtrackUuid: soundtrack.Uuid,
Soundtrack: soundtrack.SoundtrackName,
SoundtrackPlayed: soundtrack.TimesPlayed,
SongUuid: song.Uuid,
Song: song.SongName,
SongPlayed: song.TimesPlayed,
CurrentlyPlaying: i == m.currentSong,
@@ -331,7 +338,7 @@ func (m *MusicService) removeBrokenSong(ctx context.Context, song repository.Son
logging.GetLogger().Warn("Song not found, removing from database",
zap.String("song", song.SongName),
zap.String("path", song.Path))
return m.q.RemoveBrokenSong(ctx, repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
return m.q.RemoveBrokenSongs(ctx, []uuid.UUID{song.Uuid})
}
// songIsPlayable reports whether the song file exists and its stored path is consistent.
@@ -352,7 +359,7 @@ func (m *MusicService) soundtrackFor(ctx context.Context, song repository.Song)
return repository.Soundtrack{}
}
for _, soundtrack := range soundtracks {
if soundtrack.ID == song.SoundtrackID {
if soundtrack.Uuid == song.SoundtrackUuid {
return soundtrack
}
}
+33
View File
@@ -9,6 +9,7 @@ import (
"music-server/internal/db/repository"
"music-server/internal/testutil/fakedb"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -84,6 +85,9 @@ func TestMusicServiceQueue(t *testing.T) {
info := m.SongInfo(ctx)
assert.Equal(t, "Game A", info.Soundtrack)
soundtrack, _ := store.Soundtrack("Game A")
assert.Equal(t, soundtrack.Uuid, info.SoundtrackUuid)
assert.Contains(t, []string{first, second}, songPathByUUID(store, info.SongUuid))
assert.Equal(t, 1, info.SongNo)
assert.True(t, info.CurrentlyPlaying)
@@ -222,3 +226,32 @@ func TestSoundCheckSong(t *testing.T) {
_, err = NewMusicService(fakedb.New(), filepath.Join(dir, "missing")).SoundCheckSong()
assert.Error(t, err)
}
func songPathByUUID(store *fakedb.Store, id uuid.UUID) string {
for _, song := range store.Songs {
if song.Uuid == id {
return song.Path
}
}
return ""
}
func TestMarkPlayedUsesSongUUID(t *testing.T) {
// Two songs with the same name in one soundtrack: only the played one is counted.
store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song")
fileName := "Song (copy).mp3"
writeFile(t, st.Path+fileName, "copy")
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Song", Path: st.Path + fileName, FileName: &fileName})
m := NewMusicService(store, t.TempDir())
ctx := context.Background()
_, err := m.RandomSong(ctx)
require.NoError(t, err)
m.AddLatestToQueue()
require.NoError(t, m.AddLatestPlayed(ctx))
songs := store.SongsOf(st.ID)
require.Len(t, songs, 2)
assert.ElementsMatch(t, []int32{0, 1}, []int32{songs[0].TimesPlayed, songs[1].TimesPlayed})
}
+15 -7
View File
@@ -6,25 +6,31 @@ import (
"time"
"music-server/internal/db/repository"
"github.com/google/uuid"
)
// SoundtrackWithSongs represents a soundtrack with its songs for statistics
type SoundtrackWithSongs struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"`
Songs []SongInfoForStats `json:"songs"`
}
// SongInfoForStats represents a song with soundtrack info for statistics
// SongInfoForStats represents a song with soundtrack info for statistics.
// The field order matches the generated query rows so they can be converted directly.
type SongInfoForStats struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
Path string `json:"path"`
TimesPlayed int32 `json:"times_played"`
FileName *string `json:"file_name,omitempty"`
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SongUuid uuid.UUID `json:"song_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
Path string `json:"path"`
TimesPlayed int32 `json:"times_played"`
FileName *string `json:"file_name,omitempty"`
}
// StatisticsSummary holds overall statistics
@@ -65,6 +71,7 @@ func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs,
row := repository.GetMostPlayedSoundtracksWithSongsRow(r)
result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackUuid: row.SoundtrackUuid,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
@@ -129,6 +136,7 @@ func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]Sound
for _, row := range rows {
result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackUuid: row.SoundtrackUuid,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
Songs: parseSongs(row.Songs),
+5 -1
View File
@@ -57,7 +57,11 @@ func TestSyncWithPostgres(t *testing.T) {
// Play a song, then rename one of its files and, in a later sync, the soundtrack folder.
// (Renaming both at once changes the content hash, so the folder would count as new.)
require.NoError(t, q.AddSongPlayed(ctx, repository.AddSongPlayedParams{SoundtrackID: bird.ID, SongName: "2. Yesterday"}))
for _, song := range songs {
if song.SongName == "2. Yesterday" {
require.NoError(t, q.AddSongPlayedByUUID(ctx, song.Uuid))
}
}
birdDir := filepath.Join(musicDir, "A Bird Story")
require.NoError(t, os.Rename(filepath.Join(birdDir, "2. Yesterday.mp3"), filepath.Join(birdDir, "02 Yesterday.mp3")))
+1 -1
View File
@@ -229,7 +229,7 @@ func TestSyncDetectsRenamedSoundtrack(t *testing.T) {
f.write("Game A/01.mp3", "a1")
f.run(false)
a := f.soundtrack("Game A")
require.NoError(t, f.store.AddSongPlayed(context.Background(), repository.AddSongPlayedParams{SoundtrackID: a.ID, SongName: "01"}))
require.NoError(t, f.store.AddSongPlayedByUUID(context.Background(), f.store.SongsOf(a.ID)[0].Uuid))
require.NoError(t, os.Rename(filepath.Join(f.dir, "Game A"), filepath.Join(f.dir, "Game A Remastered")))
result := f.run(false)
+3
View File
@@ -19,8 +19,11 @@ var data = []VersionData{
"#21 - Refactor handlers into domain-specific files",
"#22 - Change VersionData Changelog from string to string array",
"#23 - Update all dependencies to latest versions",
"#29 - UUIDs in API responses, new soundtrack and song endpoints by UUID",
"Added search cache backend",
"Backend sync improvements",
"Faster sync that skips unchanged soundtracks",
"Security and stability fixes",
},
},
{