diff --git a/internal/backend/music.go b/internal/backend/music.go index 69b9a8e..8ac4138 100644 --- a/internal/backend/music.go +++ b/internal/backend/music.go @@ -32,23 +32,19 @@ type SongInfo struct { SongNo int `json:"SongNo"` } -// MusicService picks songs and keeps track of the shared play queue. +// MusicService picks songs and keeps track of the play queues. Every client has its own queue, +// stored in the database and identified by its UUID. type MusicService struct { q repository.Querier soundCheckPath string cacheMu sync.Mutex soundtracks []repository.Soundtrack - - // Queue state is shared by all clients and intentionally not locked. - queue []repository.Song - currentSong int - lastFetched repository.Song } // NewMusicService creates a MusicService. soundCheckPath is the directory holding sound check songs. func NewMusicService(q repository.Querier, soundCheckPath string) *MusicService { - return &MusicService{q: q, soundCheckPath: soundCheckPath, currentSong: -1} + return &MusicService{q: q, soundCheckPath: soundCheckPath} } func (m *MusicService) allSoundtracks(ctx context.Context) ([]repository.Soundtrack, error) { @@ -64,10 +60,8 @@ func (m *MusicService) allSoundtracks(ctx context.Context) ([]repository.Soundtr return m.soundtracks, nil } -// Reset clears the play queue and reloads the soundtrack list. -func (m *MusicService) Reset(ctx context.Context) error { - m.queue = nil - m.currentSong = -1 +// ReloadSoundtracks reloads the soundtrack list. +func (m *MusicService) ReloadSoundtracks(ctx context.Context) error { m.cacheMu.Lock() m.soundtracks = nil m.cacheMu.Unlock() @@ -75,6 +69,36 @@ func (m *MusicService) Reset(ctx context.Context) error { return err } +// ResolveQueue returns the queue with the given ID if it is a running queue. +// Otherwise, for example when id is empty or the queue has ended, it starts a new queue. +func (m *MusicService) ResolveQueue(ctx context.Context, id string) (uuid.UUID, error) { + if queueID, err := uuid.Parse(id); err == nil { + queue, err := m.q.GetQueue(ctx, queueID) + if err == nil && !queue.EndedAt.Valid { + return queue.Uuid, nil + } + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, err + } + } + queue, err := m.q.CreateQueue(ctx) + return queue.Uuid, err +} + +// NewQueue ends the given queue and starts a new one. +func (m *MusicService) NewQueue(ctx context.Context, queueID uuid.UUID) (uuid.UUID, error) { + if err := m.q.EndQueue(ctx, queueID); err != nil { + return uuid.Nil, err + } + queue, err := m.q.CreateQueue(ctx) + return queue.Uuid, err +} + +// EndAllQueues ends every running queue. Clients get a new queue on their next request. +func (m *MusicService) EndAllQueues(ctx context.Context) error { + return m.q.EndActiveQueues(ctx) +} + // SoundCheckSong returns the path of a random sound check song. func (m *MusicService) SoundCheckSong() (string, error) { files, err := os.ReadDir(m.soundCheckPath) @@ -88,44 +112,53 @@ func (m *MusicService) SoundCheckSong() (string, error) { return filepath.Join(m.soundCheckPath, files[rand.IntN(len(files))].Name()), nil } -// AddLatestToQueue appends the last fetched random song to the queue. -func (m *MusicService) AddLatestToQueue() { - if m.lastFetched.Path != "" { - m.currentSong = len(m.queue) - m.queue = append(m.queue, m.lastFetched) - m.lastFetched = repository.Song{} - } +// AddLatestToQueue appends the last fetched random song to the queue and makes it the current song. +func (m *MusicService) AddLatestToQueue(ctx context.Context, queueID uuid.UUID) error { + return m.q.AddLastFetchedToQueue(ctx, queueID) } // AddLatestPlayed increments the play counters of the current song. -func (m *MusicService) AddLatestPlayed(ctx context.Context) error { - if !m.validIndex(m.currentSong) { - return nil +func (m *MusicService) AddLatestPlayed(ctx context.Context, queueID uuid.UUID) error { + queue, songs, err := m.loadQueue(ctx, queueID) + if err != nil { + return err } - return m.markPlayed(ctx, m.queue[m.currentSong]) + return m.markPlayed(ctx, songs, int(queue.CurrentSong)) } // SetPlayed increments the play counters of the song at the given queue position. -func (m *MusicService) SetPlayed(ctx context.Context, songNumber int) error { - if !m.validIndex(songNumber) { - return nil +func (m *MusicService) SetPlayed(ctx context.Context, queueID uuid.UUID, songNumber int) error { + _, songs, err := m.loadQueue(ctx, queueID) + if err != nil { + return err } - return m.markPlayed(ctx, m.queue[songNumber]) + return m.markPlayed(ctx, songs, songNumber) } -func (m *MusicService) markPlayed(ctx context.Context, song repository.Song) error { +// markPlayed increments the play counters of songs[songNumber]. Positions outside the queue +// and songs that have been removed from the library are ignored. +func (m *MusicService) markPlayed(ctx context.Context, songs []repository.ListQueueSongsRow, songNumber int) error { + if songNumber < 0 || songNumber >= len(songs) || songs[songNumber].Path == nil { + return nil + } + song := songs[songNumber] if err := m.q.AddSoundtrackPlayedByUUID(ctx, song.SoundtrackUuid); err != nil { return err } - return m.q.AddSongPlayedByUUID(ctx, song.Uuid) + return m.q.AddSongPlayedByUUID(ctx, song.SongUuid) } -func (m *MusicService) validIndex(i int) bool { - return i >= 0 && i < len(m.queue) +func (m *MusicService) loadQueue(ctx context.Context, queueID uuid.UUID) (repository.Queue, []repository.ListQueueSongsRow, error) { + queue, err := m.q.GetQueue(ctx, queueID) + if err != nil { + return repository.Queue{}, nil, err + } + songs, err := m.q.ListQueueSongs(ctx, queueID) + return queue, songs, err } // RandomSong picks a random song from a random soundtrack. -func (m *MusicService) RandomSong(ctx context.Context) (string, error) { +func (m *MusicService) RandomSong(ctx context.Context, queueID uuid.UUID) (string, error) { soundtracks, err := m.allSoundtracks(ctx) if err != nil { return "", err @@ -134,29 +167,37 @@ func (m *MusicService) RandomSong(ctx context.Context) (string, error) { for i, s := range soundtracks { candidates[i] = weightedSoundtrack{soundtrack: s, weight: 1} } - return m.pickAndRemember(ctx, candidates) + return m.pickAndRemember(ctx, queueID, candidates) } // RandomSongLowChance picks a random song, favouring soundtracks played less than average. -func (m *MusicService) RandomSongLowChance(ctx context.Context) (string, error) { +func (m *MusicService) RandomSongLowChance(ctx context.Context, queueID uuid.UUID) (string, error) { soundtracks, err := m.allSoundtracks(ctx) if err != nil { return "", err } - return m.pickAndRemember(ctx, lowChanceWeights(soundtracks)) + return m.pickAndRemember(ctx, queueID, lowChanceWeights(soundtracks)) } -func (m *MusicService) pickAndRemember(ctx context.Context, candidates []weightedSoundtrack) (string, error) { +func (m *MusicService) pickAndRemember(ctx context.Context, queueID uuid.UUID, candidates []weightedSoundtrack) (string, error) { song, err := m.pickSong(ctx, candidates) if err != nil { return "", err } - m.lastFetched = song + return m.remember(ctx, queueID, song) +} + +// remember stores the song as the queue's last fetched song, for AddLatestToQueue. +func (m *MusicService) remember(ctx context.Context, queueID uuid.UUID, song repository.Song) (string, error) { + err := m.q.SetQueueLastFetched(ctx, repository.SetQueueLastFetchedParams{Uuid: queueID, LastFetchedSongUuid: song.Uuid}) + if err != nil { + return "", err + } return song.Path, nil } // RandomSongClassic picks a random song where every song has the same chance. -func (m *MusicService) RandomSongClassic(ctx context.Context) (string, error) { +func (m *MusicService) RandomSongClassic(ctx context.Context, queueID uuid.UUID) (string, error) { for range maxPickAttempts { row, err := m.q.GetRandomActiveSong(ctx) if errors.Is(err, pgx.ErrNoRows) { @@ -171,73 +212,97 @@ func (m *MusicService) RandomSongClassic(ctx context.Context) (string, error) { } continue } - m.lastFetched = row.Song - return row.Song.Path, nil + return m.remember(ctx, queueID, row.Song) } return "", ErrNoSongs } // SongInfo returns information about the current song in the queue. -func (m *MusicService) SongInfo(ctx context.Context) SongInfo { - if !m.validIndex(m.currentSong) { - return SongInfo{} +func (m *MusicService) SongInfo(ctx context.Context, queueID uuid.UUID) (SongInfo, error) { + queue, songs, err := m.loadQueue(ctx, queueID) + if err != nil { + return SongInfo{}, err } - 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, - SongNo: m.currentSong, + current := int(queue.CurrentSong) + if current < 0 || current >= len(songs) { + return SongInfo{}, nil } + return m.songInfo(ctx, songs[current], true), nil } // PlayedSongs returns all songs in the queue. -func (m *MusicService) PlayedSongs(ctx context.Context) []SongInfo { - songList := []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, - SongNo: i, - }) +func (m *MusicService) PlayedSongs(ctx context.Context, queueID uuid.UUID) ([]SongInfo, error) { + queue, songs, err := m.loadQueue(ctx, queueID) + if err != nil { + return nil, err } - return songList + songList := []SongInfo{} + for _, song := range songs { + songList = append(songList, m.songInfo(ctx, song, song.SongNo == queue.CurrentSong)) + } + return songList, nil +} + +// songInfo describes a queued song. Songs removed from the library keep their stored names. +func (m *MusicService) songInfo(ctx context.Context, song repository.ListQueueSongsRow, current bool) SongInfo { + info := SongInfo{ + SoundtrackUuid: song.SoundtrackUuid, + Soundtrack: song.SoundtrackName, + SongUuid: song.SongUuid, + Song: song.SongName, + CurrentlyPlaying: current, + SongNo: int(song.SongNo), + } + if song.TimesPlayed != nil { + info.SongPlayed = *song.TimesPlayed + } + if soundtrack, ok := m.soundtrackFor(ctx, song.SoundtrackUuid); ok { + info.Soundtrack = soundtrack.SoundtrackName + info.SoundtrackPlayed = soundtrack.TimesPlayed + } + return info } // Song jumps to the given queue position (clamped to the queue) and returns its path. -func (m *MusicService) Song(songNumber int) (string, error) { - if len(m.queue) == 0 { - return "", ErrQueueEmpty +func (m *MusicService) Song(ctx context.Context, queueID uuid.UUID, songNumber int) (string, error) { + _, songs, err := m.loadQueue(ctx, queueID) + if err != nil { + return "", err } - m.currentSong = max(0, min(songNumber, len(m.queue)-1)) - return m.queue[m.currentSong].Path, nil + return m.jump(ctx, queueID, songs, songNumber) } // NextSong moves to the next song in the queue, staying on the last one at the end. -func (m *MusicService) NextSong() (string, error) { - if len(m.queue) == 0 { - return "", ErrQueueEmpty +func (m *MusicService) NextSong(ctx context.Context, queueID uuid.UUID) (string, error) { + queue, songs, err := m.loadQueue(ctx, queueID) + if err != nil { + return "", err } - return m.Song(m.currentSong + 1) + return m.jump(ctx, queueID, songs, int(queue.CurrentSong)+1) } // PreviousSong moves to the previous song in the queue, staying on the first one at the start. -func (m *MusicService) PreviousSong() (string, error) { - if len(m.queue) == 0 { +func (m *MusicService) PreviousSong(ctx context.Context, queueID uuid.UUID) (string, error) { + queue, songs, err := m.loadQueue(ctx, queueID) + if err != nil { + return "", err + } + return m.jump(ctx, queueID, songs, int(queue.CurrentSong)-1) +} + +func (m *MusicService) jump(ctx context.Context, queueID uuid.UUID, songs []repository.ListQueueSongsRow, songNumber int) (string, error) { + if len(songs) == 0 { return "", ErrQueueEmpty } - return m.Song(m.currentSong - 1) + songNumber = max(0, min(songNumber, len(songs)-1)) + err := m.q.SetQueueCurrentSong(ctx, repository.SetQueueCurrentSongParams{Uuid: queueID, CurrentSong: int32(songNumber)}) + if err != nil { + return "", err + } + if songs[songNumber].Path == nil { + return "", ErrNotFound + } + return *songs[songNumber].Path, nil } // SoundtrackNames returns all soundtrack names in alphabetical order. @@ -352,16 +417,16 @@ func songIsPlayable(song repository.Song, soundtrackPath string) bool { return err == nil && !info.IsDir() } -func (m *MusicService) soundtrackFor(ctx context.Context, song repository.Song) repository.Soundtrack { +func (m *MusicService) soundtrackFor(ctx context.Context, soundtrackUUID uuid.UUID) (repository.Soundtrack, bool) { soundtracks, err := m.allSoundtracks(ctx) if err != nil { logging.GetLogger().Error("Failed to load soundtracks", zap.Error(err)) - return repository.Soundtrack{} + return repository.Soundtrack{}, false } for _, soundtrack := range soundtracks { - if soundtrack.Uuid == song.SoundtrackUuid { - return soundtrack + if soundtrack.Uuid == soundtrackUUID { + return soundtrack, true } } - return repository.Soundtrack{} + return repository.Soundtrack{}, false } diff --git a/internal/backend/music_test.go b/internal/backend/music_test.go index e67e0e7..19befcb 100644 --- a/internal/backend/music_test.go +++ b/internal/backend/music_test.go @@ -33,30 +33,55 @@ func addSoundtrackWithSongs(t *testing.T, store *fakedb.Store, musicDir, name st return st } +// newQueue starts a queue for the tests. +func newQueue(t *testing.T, m *MusicService) uuid.UUID { + t.Helper() + id, err := m.ResolveQueue(context.Background(), "") + require.NoError(t, err) + return id +} + +// queueSong fetches a random song and adds it to the queue. +func queueSong(t *testing.T, m *MusicService, queueID uuid.UUID) string { + t.Helper() + path, err := m.RandomSong(context.Background(), queueID) + require.NoError(t, err) + require.NoError(t, m.AddLatestToQueue(context.Background(), queueID)) + return path +} + func TestMusicServiceEmptyQueue(t *testing.T) { m := NewMusicService(fakedb.New(), t.TempDir()) + ctx := context.Background() + q := newQueue(t, m) - _, err := m.NextSong() + _, err := m.NextSong(ctx, q) assert.ErrorIs(t, err, ErrQueueEmpty) - _, err = m.PreviousSong() + _, err = m.PreviousSong(ctx, q) assert.ErrorIs(t, err, ErrQueueEmpty) - _, err = m.Song(0) + _, err = m.Song(ctx, q, 0) assert.ErrorIs(t, err, ErrQueueEmpty) - assert.Equal(t, SongInfo{}, m.SongInfo(context.Background())) - assert.Empty(t, m.PlayedSongs(context.Background())) - assert.NoError(t, m.AddLatestPlayed(context.Background())) - assert.NoError(t, m.SetPlayed(context.Background(), -1)) + info, err := m.SongInfo(ctx, q) + require.NoError(t, err) + assert.Equal(t, SongInfo{}, info) + played, err := m.PlayedSongs(ctx, q) + require.NoError(t, err) + assert.Empty(t, played) + assert.NotNil(t, played, "an empty queue is [] in JSON") + assert.NoError(t, m.AddLatestPlayed(ctx, q)) + assert.NoError(t, m.SetPlayed(ctx, q, -1)) } func TestMusicServiceNoSoundtracks(t *testing.T) { m := NewMusicService(fakedb.New(), t.TempDir()) ctx := context.Background() + q := newQueue(t, m) - _, err := m.RandomSong(ctx) + _, err := m.RandomSong(ctx, q) assert.ErrorIs(t, err, ErrNoSongs) - _, err = m.RandomSongLowChance(ctx) + _, err = m.RandomSongLowChance(ctx, q) assert.ErrorIs(t, err, ErrNoSongs) - _, err = m.RandomSongClassic(ctx) + _, err = m.RandomSongClassic(ctx, q) assert.ErrorIs(t, err, ErrNoSongs) } @@ -65,7 +90,7 @@ func TestMusicServiceSoundtracksWithoutSongs(t *testing.T) { store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Empty"}) m := NewMusicService(store, t.TempDir()) - _, err := m.RandomSong(context.Background()) + _, err := m.RandomSong(context.Background(), newQueue(t, m)) assert.ErrorIs(t, err, ErrNoSongs) } @@ -75,70 +100,182 @@ func TestMusicServiceQueue(t *testing.T) { addSoundtrackWithSongs(t, store, musicDir, "Game A", 0, "Song 1", "Song 2") m := NewMusicService(store, t.TempDir()) ctx := context.Background() + q := newQueue(t, m) - first, err := m.RandomSong(ctx) - require.NoError(t, err) - m.AddLatestToQueue() - second, err := m.RandomSong(ctx) - require.NoError(t, err) - m.AddLatestToQueue() + first := queueSong(t, m, q) + second := queueSong(t, m, q) - info := m.SongInfo(ctx) + info, err := m.SongInfo(ctx, q) + require.NoError(t, err) 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, second, songPathByUUID(store, info.SongUuid)) assert.Equal(t, 1, info.SongNo) assert.True(t, info.CurrentlyPlaying) - path, err := m.PreviousSong() + path, err := m.PreviousSong(ctx, q) require.NoError(t, err) assert.Equal(t, first, path) - path, err = m.PreviousSong() + path, err = m.PreviousSong(ctx, q) require.NoError(t, err) assert.Equal(t, first, path, "previous stays on the first song") - path, err = m.NextSong() + path, err = m.NextSong(ctx, q) require.NoError(t, err) assert.Equal(t, second, path) - path, err = m.NextSong() + path, err = m.NextSong(ctx, q) require.NoError(t, err) assert.Equal(t, second, path, "next stays on the last song") - path, err = m.Song(99) + path, err = m.Song(ctx, q, 99) require.NoError(t, err) assert.Equal(t, second, path, "song number is clamped") - played := m.PlayedSongs(ctx) + played, err := m.PlayedSongs(ctx, q) + require.NoError(t, err) require.Len(t, played, 2) assert.False(t, played[0].CurrentlyPlaying) assert.True(t, played[1].CurrentlyPlaying) +} - require.NoError(t, m.Reset(ctx)) - _, err = m.NextSong() +func TestMusicServiceQueuesAreSeparate(t *testing.T) { + store := fakedb.New() + addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1") + m := NewMusicService(store, t.TempDir()) + ctx := context.Background() + q1, q2 := newQueue(t, m), newQueue(t, m) + require.NotEqual(t, q1, q2) + + queueSong(t, m, q1) + queueSong(t, m, q1) + queueSong(t, m, q2) + + played, err := m.PlayedSongs(ctx, q1) + require.NoError(t, err) + assert.Len(t, played, 2) + played, err = m.PlayedSongs(ctx, q2) + require.NoError(t, err) + assert.Len(t, played, 1) +} + +func TestMusicServiceQueueSurvivesRestart(t *testing.T) { + store := fakedb.New() + addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1") + m := NewMusicService(store, t.TempDir()) + q := newQueue(t, m) + path := queueSong(t, m, q) + + restarted := NewMusicService(store, t.TempDir()) + id, err := restarted.ResolveQueue(context.Background(), q.String()) + require.NoError(t, err) + assert.Equal(t, q, id) + current, err := restarted.Song(context.Background(), q, 0) + require.NoError(t, err) + assert.Equal(t, path, current) +} + +func TestMusicServiceNewQueue(t *testing.T) { + store := fakedb.New() + addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1") + m := NewMusicService(store, t.TempDir()) + ctx := context.Background() + old := newQueue(t, m) + queueSong(t, m, old) + + q, err := m.NewQueue(ctx, old) + require.NoError(t, err) + assert.NotEqual(t, old, q) + _, err = m.NextSong(ctx, q) assert.ErrorIs(t, err, ErrQueueEmpty) + + resolved, err := m.ResolveQueue(ctx, old.String()) + require.NoError(t, err) + assert.NotEqual(t, old, resolved, "an ended queue is not used again") + played, err := m.PlayedSongs(ctx, old) + require.NoError(t, err) + assert.Len(t, played, 1, "ended queues are kept") +} + +func TestMusicServiceEndAllQueues(t *testing.T) { + m := NewMusicService(fakedb.New(), t.TempDir()) + ctx := context.Background() + q1, q2 := newQueue(t, m), newQueue(t, m) + + require.NoError(t, m.EndAllQueues(ctx)) + for _, q := range []uuid.UUID{q1, q2} { + id, err := m.ResolveQueue(ctx, q.String()) + require.NoError(t, err) + assert.NotEqual(t, q, id) + } +} + +func TestMusicServiceResolveQueue(t *testing.T) { + m := NewMusicService(fakedb.New(), t.TempDir()) + ctx := context.Background() + q := newQueue(t, m) + + id, err := m.ResolveQueue(ctx, q.String()) + require.NoError(t, err) + assert.Equal(t, q, id) + for _, header := range []string{"", "not-a-uuid", uuid.NewString()} { + id, err := m.ResolveQueue(ctx, header) + require.NoError(t, err) + assert.NotEqual(t, uuid.Nil, id, header) + assert.NotEqual(t, q, id, header) + } +} + +func TestMusicServiceRemovedSongInQueue(t *testing.T) { + store := fakedb.New() + addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1") + m := NewMusicService(store, t.TempDir()) + ctx := context.Background() + q := newQueue(t, m) + queueSong(t, m, q) + require.NoError(t, store.ClearSongs(ctx)) + + _, err := m.Song(ctx, q, 0) + assert.ErrorIs(t, err, ErrNotFound) + info, err := m.SongInfo(ctx, q) + require.NoError(t, err) + assert.Equal(t, "Game A", info.Soundtrack, "the stored names are kept") + assert.Equal(t, "Song 1", info.Song) + assert.NoError(t, m.AddLatestPlayed(ctx, q), "removed songs are not counted") } func TestMusicServiceAddLatestToQueueWithoutFetch(t *testing.T) { m := NewMusicService(fakedb.New(), t.TempDir()) - m.AddLatestToQueue() - _, err := m.NextSong() + q := newQueue(t, m) + require.NoError(t, m.AddLatestToQueue(context.Background(), q)) + _, err := m.NextSong(context.Background(), q) assert.ErrorIs(t, err, ErrQueueEmpty) } +func TestMusicServiceAddLatestToQueueOnlyOnce(t *testing.T) { + store := fakedb.New() + addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1") + m := NewMusicService(store, t.TempDir()) + q := newQueue(t, m) + queueSong(t, m, q) + require.NoError(t, m.AddLatestToQueue(context.Background(), q)) + + played, err := m.PlayedSongs(context.Background(), q) + require.NoError(t, err) + assert.Len(t, played, 1) +} + func TestMusicServiceMarkPlayed(t *testing.T) { store := fakedb.New() st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1") m := NewMusicService(store, t.TempDir()) ctx := context.Background() + q := newQueue(t, m) + queueSong(t, m, q) - _, err := m.RandomSong(ctx) - require.NoError(t, err) - m.AddLatestToQueue() - - require.NoError(t, m.AddLatestPlayed(ctx)) - require.NoError(t, m.SetPlayed(ctx, 0)) - require.NoError(t, m.SetPlayed(ctx, 5), "out of range is ignored") + require.NoError(t, m.AddLatestPlayed(ctx, q)) + require.NoError(t, m.SetPlayed(ctx, q, 0)) + require.NoError(t, m.SetPlayed(ctx, q, 5), "out of range is ignored") updated, _ := store.Soundtrack("Game A") assert.Equal(t, int32(2), updated.TimesPlayed) @@ -155,9 +292,10 @@ func TestMusicServiceRemovesBrokenSongs(t *testing.T) { writeFile(t, st.Path+wav, "wav") store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Wave", Path: st.Path + wav, FileName: &wav}) m := NewMusicService(store, t.TempDir()) + q := newQueue(t, m) for range 20 { - path, err := m.RandomSong(context.Background()) + path, err := m.RandomSong(context.Background(), q) require.NoError(t, err) assert.Equal(t, st.Path+"Good.mp3", path) } @@ -172,9 +310,10 @@ func TestMusicServiceRandomSongClassic(t *testing.T) { missing := "Missing.mp3" store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Missing", Path: st.Path + missing, FileName: &missing}) m := NewMusicService(store, t.TempDir()) + q := newQueue(t, m) for range 20 { - path, err := m.RandomSongClassic(context.Background()) + path, err := m.RandomSongClassic(context.Background(), q) require.NoError(t, err) assert.Equal(t, st.Path+"Good.mp3", path) } @@ -244,12 +383,9 @@ func TestMarkPlayedUsesSongUUID(t *testing.T) { 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)) + q := newQueue(t, m) + queueSong(t, m, q) + require.NoError(t, m.AddLatestPlayed(context.Background(), q)) songs := store.SongsOf(st.ID) require.Len(t, songs, 2) diff --git a/internal/backend/queue_integration_test.go b/internal/backend/queue_integration_test.go new file mode 100644 index 0000000..2cd2a72 --- /dev/null +++ b/internal/backend/queue_integration_test.go @@ -0,0 +1,89 @@ +//go:build integration + +package backend + +import ( + "context" + "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" +) + +// TestQueueWithPostgres checks the queue queries against a real database. +func TestQueueWithPostgres(t *testing.T) { + ctx := context.Background() + database := pgtest.NewMigratedDatabase(t) + q := repository.New(database.Pool) + s := NewSyncService(q, copyTestMusic(t), nil) + require.True(t, s.Start(false)) + s.Wait() + + m := NewMusicService(q, t.TempDir()) + queueID, err := m.ResolveQueue(ctx, "") + require.NoError(t, err) + + require.NoError(t, m.AddLatestToQueue(ctx, queueID), "nothing fetched yet") + var paths []string + for range 3 { + path, err := m.RandomSong(ctx, queueID) + require.NoError(t, err) + require.NoError(t, m.AddLatestToQueue(ctx, queueID)) + paths = append(paths, path) + } + require.NoError(t, m.AddLatestToQueue(ctx, queueID), "a fetched song is only added once") + + played, err := m.PlayedSongs(ctx, queueID) + require.NoError(t, err) + require.Len(t, played, 3) + for i, song := range played { + assert.Equal(t, i, song.SongNo) + assert.Equal(t, i == 2, song.CurrentlyPlaying, "the last added song is current") + assert.NotEmpty(t, song.Soundtrack) + assert.NotEqual(t, uuid.Nil, song.SoundtrackUuid) + } + + path, err := m.PreviousSong(ctx, queueID) + require.NoError(t, err) + assert.Equal(t, paths[1], path) + require.NoError(t, m.AddLatestPlayed(ctx, queueID)) + info, err := m.SongInfo(ctx, queueID) + require.NoError(t, err) + assert.Equal(t, 1, info.SongNo) + assert.Equal(t, int32(1), info.SongPlayed) + + // A new service, as after a restart, sees the same queue. + restarted := NewMusicService(q, t.TempDir()) + same, err := restarted.ResolveQueue(ctx, queueID.String()) + require.NoError(t, err) + assert.Equal(t, queueID, same) + + newID, err := m.NewQueue(ctx, queueID) + require.NoError(t, err) + assert.NotEqual(t, queueID, newID) + resolved, err := m.ResolveQueue(ctx, queueID.String()) + require.NoError(t, err) + assert.NotEqual(t, queueID, resolved, "an ended queue is not used again") + + other, err := m.ResolveQueue(ctx, "") + require.NoError(t, err) + require.NoError(t, m.EndAllQueues(ctx)) + var active int + require.NoError(t, database.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM queue WHERE ended_at IS NULL").Scan(&active)) + assert.Zero(t, active) + _, err = m.ResolveQueue(ctx, other.String()) + require.NoError(t, err) + + // Ended queues keep their songs, also after the songs are removed from the library. + require.NoError(t, q.ClearSongs(ctx)) + played, err = m.PlayedSongs(ctx, queueID) + require.NoError(t, err) + require.Len(t, played, 3) + assert.NotEmpty(t, played[0].Song) + _, err = m.Song(ctx, queueID, 0) + assert.ErrorIs(t, err, ErrNotFound) +} diff --git a/internal/db/migration_integration_test.go b/internal/db/migration_integration_test.go index 5fdfa25..66de2f6 100644 --- a/internal/db/migration_integration_test.go +++ b/internal/db/migration_integration_test.go @@ -65,7 +65,7 @@ func TestMigrationsKeepData(t *testing.T) { assert.Error(t, err, "game table is renamed") } -func TestFingerprintMigrationDown(t *testing.T) { +func TestFingerprintAndQueueMigrationDown(t *testing.T) { database := openDatabase(t, pgtest.NewDatabase(t)) m, closeFn, err := database.NewMigrator() require.NoError(t, err) @@ -75,6 +75,8 @@ func TestFingerprintMigrationDown(t *testing.T) { require.NoError(t, m.Steps(-1)) assert.Equal(t, 0, count(t, database, "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'soundtrack' AND column_name = 'fingerprint'")) + assert.Equal(t, 0, count(t, database, + "SELECT COUNT(*) FROM information_schema.tables WHERE table_name IN ('queue', 'queue_song')")) require.NoError(t, m.Up()) } diff --git a/internal/db/migrations/000006_add_soundtrack_fingerprint.down.sql b/internal/db/migrations/000006_add_fingerprint_and_queue.down.sql similarity index 53% rename from internal/db/migrations/000006_add_soundtrack_fingerprint.down.sql rename to internal/db/migrations/000006_add_fingerprint_and_queue.down.sql index 39afc14..6b58d09 100644 --- a/internal/db/migrations/000006_add_soundtrack_fingerprint.down.sql +++ b/internal/db/migrations/000006_add_fingerprint_and_queue.down.sql @@ -1 +1,3 @@ +DROP TABLE queue_song; +DROP TABLE queue; ALTER TABLE soundtrack DROP COLUMN fingerprint; diff --git a/internal/db/migrations/000006_add_fingerprint_and_queue.up.sql b/internal/db/migrations/000006_add_fingerprint_and_queue.up.sql new file mode 100644 index 0000000..c6fd159 --- /dev/null +++ b/internal/db/migrations/000006_add_fingerprint_and_queue.up.sql @@ -0,0 +1,26 @@ +-- Cheap fingerprint (file names, sizes and modification times) used to skip +-- content hashing of soundtracks that have not changed since the last sync. +ALTER TABLE soundtrack ADD COLUMN fingerprint TEXT NOT NULL DEFAULT ''; + +-- Play queues. Every client has its own queue, identified by the queue's UUID. +-- Ended queues are kept as history. +CREATE TABLE queue ( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + current_song INT4 NOT NULL DEFAULT -1, + -- The last random song, added to the queue by /music/addQue. + last_fetched_song_uuid UUID NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ NULL +); +CREATE INDEX idx_queue_active ON queue(uuid) WHERE ended_at IS NULL; + +-- song_uuid has no foreign key: a sync can delete songs. The names are kept for the history. +CREATE TABLE queue_song ( + queue_uuid UUID NOT NULL REFERENCES queue(uuid) ON DELETE CASCADE, + song_no INT4 NOT NULL, + song_uuid UUID NOT NULL, + soundtrack_name VARCHAR NOT NULL, + song_name VARCHAR NOT NULL, + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (queue_uuid, song_no) +); diff --git a/internal/db/migrations/000006_add_soundtrack_fingerprint.up.sql b/internal/db/migrations/000006_add_soundtrack_fingerprint.up.sql deleted file mode 100644 index bb15eb6..0000000 --- a/internal/db/migrations/000006_add_soundtrack_fingerprint.up.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Cheap fingerprint (file names, sizes and modification times) used to skip --- content hashing of soundtracks that have not changed since the last sync. -ALTER TABLE soundtrack ADD COLUMN fingerprint TEXT NOT NULL DEFAULT ''; diff --git a/internal/db/queries/queue.sql b/internal/db/queries/queue.sql new file mode 100644 index 0000000..a3992aa --- /dev/null +++ b/internal/db/queries/queue.sql @@ -0,0 +1,51 @@ +-- name: CreateQueue :one +INSERT INTO queue DEFAULT VALUES +RETURNING *; + +-- name: GetQueue :one +SELECT * +FROM queue +WHERE uuid = $1; + +-- name: EndQueue :exec +UPDATE queue SET ended_at = NOW() +WHERE uuid = $1 AND ended_at IS NULL; + +-- name: EndActiveQueues :exec +UPDATE queue SET ended_at = NOW() +WHERE ended_at IS NULL; + +-- name: SetQueueLastFetched :exec +UPDATE queue SET last_fetched_song_uuid = $2 +WHERE uuid = $1; + +-- name: SetQueueCurrentSong :exec +UPDATE queue SET current_song = $2 +WHERE uuid = $1; + +-- name: AddLastFetchedToQueue :exec +-- Appends the queue's last fetched song and makes it the current song. +-- Does nothing when no song has been fetched since the last call. +WITH added AS ( + INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name) + SELECT q.uuid, + (SELECT COALESCE(MAX(qs.song_no), -1) + 1 FROM queue_song qs WHERE qs.queue_uuid = q.uuid), + s.uuid, st.soundtrack_name, s.song_name + FROM queue q + JOIN song s ON s.uuid = q.last_fetched_song_uuid + JOIN soundtrack st ON st.id = s.soundtrack_id + WHERE q.uuid = $1 + RETURNING queue_uuid, song_no +) +UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL +FROM added +WHERE queue.uuid = added.queue_uuid; + +-- name: ListQueueSongs :many +-- The song columns are NULL when the song has been removed from the library since. +SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name, + s.path, s.times_played, s.soundtrack_uuid +FROM queue_song qs +LEFT JOIN song s ON s.uuid = qs.song_uuid +WHERE qs.queue_uuid = $1 +ORDER BY qs.song_no; diff --git a/internal/db/repository/models.go b/internal/db/repository/models.go index 14b9f9d..b3ab564 100644 --- a/internal/db/repository/models.go +++ b/internal/db/repository/models.go @@ -11,6 +11,23 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type Queue struct { + Uuid uuid.UUID `json:"uuid"` + CurrentSong int32 `json:"current_song"` + LastFetchedSongUuid uuid.UUID `json:"last_fetched_song_uuid"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type QueueSong struct { + QueueUuid uuid.UUID `json:"queue_uuid"` + SongNo int32 `json:"song_no"` + SongUuid uuid.UUID `json:"song_uuid"` + SoundtrackName string `json:"soundtrack_name"` + SongName string `json:"song_name"` + AddedAt pgtype.Timestamptz `json:"added_at"` +} + type Session struct { Token string `json:"token"` IpAddress string `json:"ip_address"` diff --git a/internal/db/repository/querier.go b/internal/db/repository/querier.go index 1148fa6..3eb6e50 100644 --- a/internal/db/repository/querier.go +++ b/internal/db/repository/querier.go @@ -13,6 +13,9 @@ import ( type Querier interface { AddHashToSong(ctx context.Context, arg AddHashToSongParams) error + // Appends the queue's last fetched song and makes it the current song. + // Does nothing when no song has been fetched since the last call. + AddLastFetchedToQueue(ctx context.Context, argUuid uuid.UUID) error AddSong(ctx context.Context, arg AddSongParams) error AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error AddSongPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error @@ -23,9 +26,12 @@ type Querier interface { ClearSongs(ctx context.Context) error ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error ClearSoundtracks(ctx context.Context) error + CreateQueue(ctx context.Context) (Queue, error) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) DeleteExpiredSessions(ctx context.Context) error DeleteSession(ctx context.Context, token string) error + EndActiveQueues(ctx context.Context) error + EndQueue(ctx context.Context, argUuid uuid.UUID) error FetchAllSongs(ctx context.Context) ([]Song, error) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error) @@ -48,6 +54,7 @@ type Querier interface { GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error) // Oldest played soundtracks (least recently played, but has been played at least once) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error) + GetQueue(ctx context.Context, argUuid uuid.UUID) (Queue, error) GetRandomActiveSong(ctx context.Context) (GetRandomActiveSongRow, error) GetSession(ctx context.Context, token string) (Session, error) GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error) @@ -64,12 +71,16 @@ type Querier interface { InsertSongInList(ctx context.Context, arg InsertSongInListParams) error InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error) InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error + // The song columns are NULL when the song has been removed from the library since. + ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]ListQueueSongsRow, error) ListSessions(ctx context.Context) ([]Session, error) MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error ResetSoundtrackIdSeq(ctx context.Context) (int64, error) + SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSongParams) error + SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error SetSoundtrackDeletionDate(ctx context.Context) error SetSoundtrackFingerprint(ctx context.Context, arg SetSoundtrackFingerprintParams) error UpdateSong(ctx context.Context, arg UpdateSongParams) error diff --git a/internal/db/repository/queue.sql.go b/internal/db/repository/queue.sql.go new file mode 100644 index 0000000..8962a71 --- /dev/null +++ b/internal/db/repository/queue.sql.go @@ -0,0 +1,171 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: queue.sql + +package repository + +import ( + "context" + + "github.com/google/uuid" +) + +const addLastFetchedToQueue = `-- name: AddLastFetchedToQueue :exec +WITH added AS ( + INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name) + SELECT q.uuid, + (SELECT COALESCE(MAX(qs.song_no), -1) + 1 FROM queue_song qs WHERE qs.queue_uuid = q.uuid), + s.uuid, st.soundtrack_name, s.song_name + FROM queue q + JOIN song s ON s.uuid = q.last_fetched_song_uuid + JOIN soundtrack st ON st.id = s.soundtrack_id + WHERE q.uuid = $1 + RETURNING queue_uuid, song_no +) +UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL +FROM added +WHERE queue.uuid = added.queue_uuid +` + +// Appends the queue's last fetched song and makes it the current song. +// Does nothing when no song has been fetched since the last call. +func (q *Queries) AddLastFetchedToQueue(ctx context.Context, argUuid uuid.UUID) error { + _, err := q.db.Exec(ctx, addLastFetchedToQueue, argUuid) + return err +} + +const createQueue = `-- name: CreateQueue :one +INSERT INTO queue DEFAULT VALUES +RETURNING uuid, current_song, last_fetched_song_uuid, started_at, ended_at +` + +func (q *Queries) CreateQueue(ctx context.Context) (Queue, error) { + row := q.db.QueryRow(ctx, createQueue) + var i Queue + err := row.Scan( + &i.Uuid, + &i.CurrentSong, + &i.LastFetchedSongUuid, + &i.StartedAt, + &i.EndedAt, + ) + return i, err +} + +const endActiveQueues = `-- name: EndActiveQueues :exec +UPDATE queue SET ended_at = NOW() +WHERE ended_at IS NULL +` + +func (q *Queries) EndActiveQueues(ctx context.Context) error { + _, err := q.db.Exec(ctx, endActiveQueues) + return err +} + +const endQueue = `-- name: EndQueue :exec +UPDATE queue SET ended_at = NOW() +WHERE uuid = $1 AND ended_at IS NULL +` + +func (q *Queries) EndQueue(ctx context.Context, argUuid uuid.UUID) error { + _, err := q.db.Exec(ctx, endQueue, argUuid) + return err +} + +const getQueue = `-- name: GetQueue :one +SELECT uuid, current_song, last_fetched_song_uuid, started_at, ended_at +FROM queue +WHERE uuid = $1 +` + +func (q *Queries) GetQueue(ctx context.Context, argUuid uuid.UUID) (Queue, error) { + row := q.db.QueryRow(ctx, getQueue, argUuid) + var i Queue + err := row.Scan( + &i.Uuid, + &i.CurrentSong, + &i.LastFetchedSongUuid, + &i.StartedAt, + &i.EndedAt, + ) + return i, err +} + +const listQueueSongs = `-- name: ListQueueSongs :many +SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name, + s.path, s.times_played, s.soundtrack_uuid +FROM queue_song qs +LEFT JOIN song s ON s.uuid = qs.song_uuid +WHERE qs.queue_uuid = $1 +ORDER BY qs.song_no +` + +type ListQueueSongsRow struct { + SongNo int32 `json:"song_no"` + 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"` + SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` +} + +// The song columns are NULL when the song has been removed from the library since. +func (q *Queries) ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]ListQueueSongsRow, error) { + rows, err := q.db.Query(ctx, listQueueSongs, queueUuid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListQueueSongsRow + for rows.Next() { + var i ListQueueSongsRow + if err := rows.Scan( + &i.SongNo, + &i.SongUuid, + &i.SoundtrackName, + &i.SongName, + &i.Path, + &i.TimesPlayed, + &i.SoundtrackUuid, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setQueueCurrentSong = `-- name: SetQueueCurrentSong :exec +UPDATE queue SET current_song = $2 +WHERE uuid = $1 +` + +type SetQueueCurrentSongParams struct { + Uuid uuid.UUID `json:"uuid"` + CurrentSong int32 `json:"current_song"` +} + +func (q *Queries) SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSongParams) error { + _, err := q.db.Exec(ctx, setQueueCurrentSong, arg.Uuid, arg.CurrentSong) + return err +} + +const setQueueLastFetched = `-- name: SetQueueLastFetched :exec +UPDATE queue SET last_fetched_song_uuid = $2 +WHERE uuid = $1 +` + +type SetQueueLastFetchedParams struct { + Uuid uuid.UUID `json:"uuid"` + LastFetchedSongUuid uuid.UUID `json:"last_fetched_song_uuid"` +} + +func (q *Queries) SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error { + _, err := q.db.Exec(ctx, setQueueLastFetched, arg.Uuid, arg.LastFetchedSongUuid) + return err +} diff --git a/internal/server/api.go b/internal/server/api.go index 9a7d612..0bc1279 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -8,6 +8,7 @@ import ( "music-server/internal/backend" "music-server/internal/logging" + "music-server/internal/server/middleware" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humaecho" @@ -50,47 +51,42 @@ func init() { } } -// apis holds the documented /api/v1 API and the hidden, deprecated legacy routes. -type apis struct { - v1 huma.API - legacy huma.API -} - -func newAPIs(e *echo.Echo) apis { +func newAPI(e *echo.Echo) huma.API { config := huma.DefaultConfig("MusicServer API", backend.GetLatestVersion().Version) // No $schema fields or Link headers in responses. config.CreateHooks = nil config.DocsRenderer = huma.DocsRendererScalar config.Info.Description = "API for the MusicServer application.\n\n" + "Get a token with `POST /api/v1/token` and send it as `Authorization: Bearer `.\n\n" + - "The same endpoints without the `/api/v1` prefix (legacy routes) are deprecated, need no token " + - "and are not documented here. They will be removed." + "Every client has its own play queue. The music endpoints return its ID in the `X-Queue-Id` header; " + + "send it back in the same header to keep using that queue. Without it, or when the queue has ended " + + "(after a reset or a sync), a new queue is started." config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ bearerAuthName: {Type: "http", Scheme: "bearer", Description: "Token from POST /api/v1/token"}, } - - legacyConfig := huma.DefaultConfig("MusicServer legacy API", backend.GetLatestVersion().Version) - legacyConfig.CreateHooks = nil - legacyConfig.OpenAPIPath = "" - legacyConfig.DocsPath = "" - legacyConfig.SchemasPath = "" - - return apis{v1: humaecho.New(e, config), legacy: humaecho.New(e, legacyConfig)} + return humaecho.New(e, config) } -// route describes an endpoint registered on /api/v1 and, optionally, as a legacy route. +// route describes an endpoint registered on /api/v1. type route struct { op huma.Operation // public endpoints need no token. public bool - // legacyPath is the deprecated path without /api/v1 (empty: no legacy route). - legacyPath string // locked endpoints answer 423 while a sync is running. locked bool + // queue endpoints work on the client's play queue, see middleware.Queue. + queue bool } -// register adds the route's operation to the v1 API and, if it has one, its hidden legacy route. -func register[I, O any](s *Server, a apis, r route, handler func(context.Context, *I) (*O, error)) { +var queueHeaderParam = &huma.Param{ + Name: middleware.QueueHeader, + In: "header", + Description: "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", + Schema: &huma.Schema{Type: huma.TypeString, Format: "uuid"}, +} + +// register adds the route's operation to the API. +func register[I, O any](s *Server, a huma.API, r route, handler func(context.Context, *I) (*O, error)) { op := r.op op.Path = apiPrefix + r.op.Path if !r.public { @@ -98,23 +94,39 @@ func register[I, O any](s *Server, a apis, r route, handler func(context.Context op.Errors = append(op.Errors, http.StatusUnauthorized) } if r.locked { - op.Middlewares = append(op.Middlewares, s.syncLock(a.v1)) + op.Middlewares = append(op.Middlewares, s.syncLock(a)) op.Errors = append(op.Errors, http.StatusLocked) } - huma.Register(a.v1, op, handler) + if r.queue { + op.Middlewares = append(op.Middlewares, middleware.Queue(a, s.music.ResolveQueue)) + op.Parameters = append(op.Parameters, queueHeaderParam) + } + huma.Register(a, op, handler) + if r.queue { + documentQueueHeader(a, op) + } +} - if r.legacyPath == "" { - return +// documentQueueHeader adds the X-Queue-Id header to the operation's success responses. +func documentQueueHeader(a huma.API, op huma.Operation) { + path := a.OpenAPI().Paths[op.Path] + registered := map[string]*huma.Operation{ + http.MethodGet: path.Get, http.MethodPut: path.Put, http.MethodPost: path.Post, http.MethodDelete: path.Delete, + }[op.Method] + for status, resp := range registered.Responses { + if !strings.HasPrefix(status, "2") { + continue + } + if resp.Headers == nil { + resp.Headers = map[string]*huma.Param{} + } + if _, ok := resp.Headers[middleware.QueueHeader]; !ok { + resp.Headers[middleware.QueueHeader] = &huma.Param{ + Description: "ID of the client's play queue. Send it with the following requests.", + Schema: &huma.Schema{Type: huma.TypeString, Format: "uuid"}, + } + } } - legacy := r.op - legacy.OperationID = "legacy-" + r.op.OperationID - legacy.Path = r.legacyPath - legacy.Hidden = true - legacy.Deprecated = true - if r.locked { - legacy.Middlewares = append(legacy.Middlewares, s.syncLock(a.legacy)) - } - huma.Register(a.legacy, legacy, handler) } // syncLock answers 423 Locked while a sync is running. diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 03575b8..c5e3d9c 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -23,32 +23,80 @@ func TestMusicRandomAndQueue(t *testing.T) { e := newTestEnv(t) e.addSong("Game A", "Song", "0123456789") - rec := e.do(http.MethodGet, "/music/rand") + rec := e.do(http.MethodGet, "/api/v1/music/rand") require.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type")) assert.Equal(t, "0123456789", rec.Body.String()) - assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addQue").Code) - assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addPlayed").Code) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addPlayed").Code) var info backend.SongInfo - require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/info").Body.Bytes(), &info)) + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/info").Body.Bytes(), &info)) assert.Equal(t, "Game A", info.Soundtrack) assert.Equal(t, "Song", info.Song) - for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/api/v1/music?song=0"} { + for _, path := range []string{"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music?song=0"} { assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path) } - assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/music/played?song=0").Code) - assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodPut, "/music/played?song=x").Code) - assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/reset").Code) + assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/api/v1/music/played?song=0").Code) + assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodPut, "/api/v1/music/played?song=x").Code) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/reset").Code) +} + +func TestMusicQueueID(t *testing.T) { + e := newTestEnv(t) + e.addSong("Game A", "Song", "x") + + rec := e.do(http.MethodGet, "/api/v1/music/rand") + require.Equal(t, http.StatusOK, rec.Code) + first := rec.Header().Get("X-Queue-Id") + require.NotEmpty(t, first, "a request without a queue ID gets a new queue") + require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code) + rec = e.do(http.MethodGet, "/api/v1/music/list") + assert.Equal(t, first, rec.Header().Get("X-Queue-Id"), "the sent queue is kept") + assert.Contains(t, rec.Body.String(), `"Song":"Song"`) + + // Another client has its own queue. + other := e.do(http.MethodGet, "/api/v1/music/list", "X-Queue-Id", "") + assert.NotEqual(t, first, other.Header().Get("X-Queue-Id")) + assert.JSONEq(t, "[]", other.Body.String()) + e.queueID = first + + rec = e.do(http.MethodGet, "/api/v1/music/reset") + require.Equal(t, http.StatusOK, rec.Code) + second := rec.Header().Get("X-Queue-Id") + assert.NotEqual(t, first, second, "reset starts a new queue") + assert.JSONEq(t, "[]", e.do(http.MethodGet, "/api/v1/music/list").Body.String()) + + // The old ID has ended, so it gets a new queue too. + rec = e.do(http.MethodGet, "/api/v1/music/list", "X-Queue-Id", first) + assert.NotEqual(t, first, rec.Header().Get("X-Queue-Id")) + + assert.Empty(t, e.do(http.MethodGet, "/api/v1/music/all", "X-Queue-Id", "").Header().Get("X-Queue-Id"), + "endpoints that do not use the queue do not start one") +} + +func TestMusicQueueEndsAfterSync(t *testing.T) { + e := newTestEnv(t) + e.addSong("Game A", "Song", "x") + e.do(http.MethodGet, "/api/v1/music/rand") + e.do(http.MethodGet, "/api/v1/music/addQue") + before := e.queueID + + require.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code) + e.server.sync.Wait() + + rec := e.do(http.MethodGet, "/api/v1/music/list") + assert.NotEqual(t, before, rec.Header().Get("X-Queue-Id")) + assert.JSONEq(t, "[]", rec.Body.String()) } func TestMusicRangeRequest(t *testing.T) { e := newTestEnv(t) e.addSong("Game A", "Song", "0123456789") - rec := e.do(http.MethodGet, "/music/rand", "Range", "bytes=2-5") + rec := e.do(http.MethodGet, "/api/v1/music/rand", "Range", "bytes=2-5") assert.Equal(t, http.StatusPartialContent, rec.Code) assert.Equal(t, "bytes 2-5/10", rec.Header().Get("Content-Range")) assert.Equal(t, "2345", rec.Body.String()) @@ -57,21 +105,21 @@ func TestMusicRangeRequest(t *testing.T) { func TestMusicEmptyStates(t *testing.T) { e := newTestEnv(t) - for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/music/rand", "/music/rand/low", "/music/rand/classic"} { + for _, path := range []string{"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music/rand", "/api/v1/music/rand/low", "/api/v1/music/rand/classic"} { assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path) } // Invalid or missing parameters fail validation. - assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/music?song=abc").Code) - assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/music").Code) + assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/music?song=abc").Code) + assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/music").Code) - rec := e.do(http.MethodGet, "/music/list") + rec := e.do(http.MethodGet, "/api/v1/music/list") assert.Equal(t, http.StatusOK, rec.Code) assert.JSONEq(t, "[]", rec.Body.String()) } func TestMusicSoundCheck(t *testing.T) { e := newTestEnv(t) - rec := e.do(http.MethodGet, "/music/soundTest") + rec := e.do(http.MethodGet, "/api/v1/music/soundTest") assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "sound check", rec.Body.String()) } @@ -82,31 +130,31 @@ func TestMusicSoundtrackLists(t *testing.T) { e.addSong("A Game", "Song", "a") var ordered []string - require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/all/order").Body.Bytes(), &ordered)) + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/all/order").Body.Bytes(), &ordered)) assert.Equal(t, []string{"A Game", "B Game"}, ordered) var random []string - require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/all").Body.Bytes(), &random)) + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/all").Body.Bytes(), &random)) assert.ElementsMatch(t, ordered, random) } func TestMusicDatabaseError(t *testing.T) { e := newTestEnv(t) e.store.Err = errors.New("db down") - assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/music/rand").Code) - assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/music/all/order").Code) + assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/music/rand").Code) + assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/music/all/order").Code) } func TestCharacterEndpoints(t *testing.T) { e := newTestEnv(t) var list []string - require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/characters").Body.Bytes(), &list)) + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list)) assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list) require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list)) assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list) - rec := e.do(http.MethodGet, "/character?name=Adol.png") + rec := e.do(http.MethodGet, "/api/v1/character?name=Adol.png") assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "image/png", rec.Header().Get("Content-Type")) @@ -114,14 +162,14 @@ func TestCharacterEndpoints(t *testing.T) { assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type")) - assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/character?name=Missing.png").Code) + assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/api/v1/character?name=Missing.png").Code) } func TestCharacterPathTraversal(t *testing.T) { e := newTestEnv(t) - assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/character?name=").Code, "name is required") + assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/character?name=").Code, "name is required") for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd"} { - rec := e.do(http.MethodGet, "/character?name="+name) + rec := e.do(http.MethodGet, "/api/v1/character?name="+name) assert.Equal(t, http.StatusBadRequest, rec.Code, name) assert.NotContains(t, rec.Body.String(), "secret", name) } @@ -140,23 +188,23 @@ func TestDownloadEndpoints(t *testing.T) { url := newGitea(t, http.StatusOK) e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = url }) - rec := e.do(http.MethodGet, "/download") + rec := e.do(http.MethodGet, "/api/v1/download") assert.Equal(t, http.StatusOK, rec.Code) assert.JSONEq(t, `"v2.0.0"`, rec.Body.String()) rec = e.do(http.MethodGet, "/api/v1/download/list") assert.JSONEq(t, `["Player.exe"]`, rec.Body.String()) - rec = e.do(http.MethodGet, "/download/windows") + rec = e.do(http.MethodGet, "/api/v1/download/windows") assert.Equal(t, http.StatusFound, rec.Code) assert.Equal(t, "https://example.com/Player.exe", rec.Header().Get("Location")) - assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/download/linux").Code) + assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/api/v1/download/linux").Code) } func TestDownloadUpstreamDown(t *testing.T) { e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = newGitea(t, http.StatusServiceUnavailable) }) - for _, path := range []string{"/download", "/download/list", "/download/windows"} { + for _, path := range []string{"/api/v1/download", "/api/v1/download/list", "/api/v1/download/windows"} { assert.Equal(t, http.StatusBadGateway, e.do(http.MethodGet, path).Code, path) } } @@ -191,7 +239,6 @@ func TestTokenAuth(t *testing.T) { token := e.createToken("secret") auth := []string{"Authorization", "Bearer " + token} assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/version", auth...).Code) - assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/version").Code, "legacy routes need no token") assert.Equal(t, http.StatusOK, e.do(http.MethodDelete, "/api/v1/token", auth...).Code) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", auth...).Code) diff --git a/internal/server/helpers_test.go b/internal/server/helpers_test.go index 0a77e95..9f7016c 100644 --- a/internal/server/helpers_test.go +++ b/internal/server/helpers_test.go @@ -14,6 +14,7 @@ import ( "music-server/internal/config" "music-server/internal/db/repository" "music-server/internal/logging" + "music-server/internal/server/middleware" "music-server/internal/testutil/fakedb" "github.com/stretchr/testify/require" @@ -48,6 +49,8 @@ type testEnv struct { db *fakeDatabase server *Server handler http.Handler + // queueID is sent as X-Queue-Id and updated from responses, like a client keeping its queue. + queueID string } func writeFile(t *testing.T, path, content string) { @@ -111,10 +114,16 @@ func (e *testEnv) doBody(method, path, body string, headers ...string) *httptest if body != "" { req.Header.Set("Content-Type", "application/json") } + if e.queueID != "" { + req.Header.Set(middleware.QueueHeader, e.queueID) + } for i := 0; i+1 < len(headers); i += 2 { req.Header.Set(headers[i], headers[i+1]) } rec := httptest.NewRecorder() e.handler.ServeHTTP(rec, req) + if id := rec.Header().Get(middleware.QueueHeader); id != "" { + e.queueID = id + } return rec } diff --git a/internal/server/library_handler.go b/internal/server/library_handler.go index a33a8ff..c84a016 100644 --- a/internal/server/library_handler.go +++ b/internal/server/library_handler.go @@ -19,7 +19,7 @@ func (in *uuidInput) id() uuid.UUID { return uuid.MustParse(in.UUID) } -func (s *Server) registerLibrary(a apis) { +func (s *Server) registerLibrary(a huma.API) { l := s.library op := func(id, path, summary, description string, errs ...int) huma.Operation { return huma.Operation{ diff --git a/internal/server/library_handler_test.go b/internal/server/library_handler_test.go index fb644e9..bd7c920 100644 --- a/internal/server/library_handler_test.go +++ b/internal/server/library_handler_test.go @@ -55,7 +55,6 @@ func TestLibraryEndpointErrors(t *testing.T) { func TestLibraryEndpointsRequireToken(t *testing.T) { e := newTestEnv(t, withAuth("secret")) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/soundtracks").Code) - assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/soundtracks").Code, "not available on legacy routes") } func TestSongFileLockedDuringSync(t *testing.T) { @@ -72,11 +71,11 @@ func TestSongFileLockedDuringSync(t *testing.T) { func TestQueueResponsesIncludeUUIDs(t *testing.T) { e := newTestEnv(t) song := e.addSong("Game A", "Song", "x") - require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/rand").Code) - require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addQue").Code) + require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/rand").Code) + require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code) var info map[string]any - require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/info").Body.Bytes(), &info)) + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/info").Body.Bytes(), &info)) assert.Equal(t, song.Uuid.String(), info["SongUuid"]) assert.Equal(t, song.SoundtrackUuid.String(), info["SoundtrackUuid"]) diff --git a/internal/server/middleware/deprecation.go b/internal/server/middleware/deprecation.go deleted file mode 100644 index 919f83c..0000000 --- a/internal/server/middleware/deprecation.go +++ /dev/null @@ -1,10 +0,0 @@ -package middleware - -import "github.com/danielgtaylor/huma/v2" - -// Deprecation marks responses from legacy routes as deprecated in favour of /api/v1. -func Deprecation(ctx huma.Context, next func(huma.Context)) { - ctx.AppendHeader("Warning", `299 - "Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead."`) - ctx.AppendHeader("Deprecation", "true") - next(ctx) -} diff --git a/internal/server/middleware/queue.go b/internal/server/middleware/queue.go new file mode 100644 index 0000000..1f2d125 --- /dev/null +++ b/internal/server/middleware/queue.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "context" + "net/http" + + "music-server/internal/logging" + + "github.com/danielgtaylor/huma/v2" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// QueueHeader carries the client's queue ID in requests and responses. +const QueueHeader = "X-Queue-Id" + +type queueKey struct{} + +// QueueID returns the queue ID resolved by Queue. +func QueueID(ctx context.Context) uuid.UUID { + id, _ := ctx.Value(queueKey{}).(uuid.UUID) + return id +} + +// Queue resolves the queue ID in the X-Queue-Id header with resolve, which starts a new queue +// when the header is missing or the queue has ended. The ID is returned in the same header. +func Queue(api huma.API, resolve func(context.Context, string) (uuid.UUID, error)) Middleware { + return func(ctx huma.Context, next func(huma.Context)) { + id, err := resolve(ctx.Context(), ctx.Header(QueueHeader)) + if err != nil { + logging.GetLogger().Error("Failed to resolve queue", zap.Error(err)) + _ = huma.WriteErr(api, ctx, http.StatusInternalServerError, "Internal Server Error") + return + } + ctx.SetHeader(QueueHeader, id.String()) + next(huma.WithValue(ctx, queueKey{}, id)) + } +} diff --git a/internal/server/misc_handler.go b/internal/server/misc_handler.go index 13bc3ff..ba92152 100644 --- a/internal/server/misc_handler.go +++ b/internal/server/misc_handler.go @@ -22,7 +22,7 @@ type healthOutput struct { Body map[string]string } -func (s *Server) registerMisc(a apis) { +func (s *Server) registerMisc(a huma.API) { get := func(id, path, tag, summary, description string, errs ...int) huma.Operation { return huma.Operation{ OperationID: id, @@ -37,30 +37,30 @@ func (s *Server) registerMisc(a apis) { register(s, a, route{op: get("get-health", "/health", "server", "Health check", "Returns {\"status\": \"up\"} when the server and database work, otherwise status \"down\" and an error."), - public: true, legacyPath: "/health"}, + public: true}, func(ctx context.Context, _ *struct{}) (*healthOutput, error) { return &healthOutput{Body: s.db.Health(ctx)}, nil }) register(s, a, route{op: get("get-version", "/version", "server", "Latest server version", - "Returns the server version and its changelog."), legacyPath: "/version"}, + "Returns the server version and its changelog.")}, func(ctx context.Context, _ *struct{}) (*jsonBody[backend.VersionData], error) { return ok(backend.GetLatestVersion(), nil) }) register(s, a, route{op: get("get-version-history", "/version/history", "server", "Version history", - "Returns all server versions with their changelogs, newest first."), legacyPath: "/version/history"}, + "Returns all server versions with their changelogs, newest first.")}, func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.VersionData], error) { return ok(backend.GetVersionHistory(), nil) }) register(s, a, route{op: get("list-characters", "/character/list", "characters", "List characters", - "Returns the file names of all character images."), legacyPath: "/characters"}, + "Returns the file names of all character images.")}, func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) { return ok(s.characters.List()) }) image := get("get-character", "/character", "characters", "Character image", "Returns a character image.", http.StatusBadRequest, http.StatusNotFound) image.Responses = fileResponses("The image", "image/png", "image/jpeg") - register(s, a, route{op: image, legacyPath: "/character"}, + register(s, a, route{op: image}, func(ctx context.Context, in *characterInput) (*huma.StreamResponse, error) { f, err := s.characters.Open(in.Name) if err != nil { @@ -71,12 +71,12 @@ func (s *Server) registerMisc(a apis) { r := s.releases register(s, a, route{op: get("get-latest-client-version", "/download", "download", "Latest client version", - "Returns the name of the latest MusicPlayer release.", http.StatusBadGateway), legacyPath: "/download"}, + "Returns the name of the latest MusicPlayer release.", http.StatusBadGateway)}, func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) { return ok(r.LatestVersion(ctx)) }) register(s, a, route{op: get("list-client-assets", "/download/list", "download", "Latest client assets", - "Returns the file names of the latest MusicPlayer release.", http.StatusBadGateway), legacyPath: "/download/list"}, + "Returns the file names of the latest MusicPlayer release.", http.StatusBadGateway)}, func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) { return ok(r.AssetNames(ctx)) }) @@ -85,7 +85,7 @@ func (s *Server) registerMisc(a apis) { "Redirects to the download URL of the latest MusicPlayer release for "+platform+".", http.StatusNotFound, http.StatusBadGateway) op.DefaultStatus = http.StatusFound - register(s, a, route{op: op, legacyPath: path}, + register(s, a, route{op: op}, func(ctx context.Context, _ *struct{}) (*redirectOutput, error) { url, err := r.AssetURL(ctx, suffix) if err != nil { diff --git a/internal/server/music_handler.go b/internal/server/music_handler.go index 5de98c6..236ff6d 100644 --- a/internal/server/music_handler.go +++ b/internal/server/music_handler.go @@ -6,6 +6,7 @@ import ( "music-server/internal/backend" "music-server/internal/logging" + "music-server/internal/server/middleware" "github.com/danielgtaylor/huma/v2" "go.uber.org/zap" @@ -15,9 +16,14 @@ type songNumberInput struct { Song int `query:"song" required:"true" doc:"Position of the song in the queue (SongNo)"` } +// resetOutput returns the ID of the new queue. +type resetOutput struct { + QueueID string `header:"X-Queue-Id" doc:"ID of the new queue"` +} + const audioMPEG = "audio/mpeg" -func (s *Server) registerMusic(a apis) { +func (s *Server) registerMusic(a huma.API) { m := s.music audio := func(id, path, summary, description string, errs ...int) route { return route{ @@ -31,8 +37,7 @@ func (s *Server) registerMusic(a apis) { Responses: fileResponses("The song (MP3)", audioMPEG), Errors: errs, }, - legacyPath: path, - locked: true, + locked: true, } } status := func(id, method, path, summary, description string) route { @@ -46,8 +51,8 @@ func (s *Server) registerMusic(a apis) { Tags: []string{"music"}, DefaultStatus: http.StatusOK, }, - legacyPath: path, - locked: true, + locked: true, + queue: true, } } list := func(id, path, summary, description string, locked bool) route { @@ -60,54 +65,59 @@ func (s *Server) registerMusic(a apis) { Description: description, Tags: []string{"music"}, }, - legacyPath: path, - locked: locked, + locked: locked, } } + // queued marks a route that works on the client's play queue. + queued := func(r route) route { + r.queue = true + return r + } + queue := middleware.QueueID - register(s, a, audio("get-queue-song", "/music", "Play a song from the queue", - "Jumps to the song at the given queue position (clamped to the queue) and returns it.", http.StatusNotFound), + register(s, a, queued(audio("get-queue-song", "/music", "Play a song from the queue", + "Jumps to the song at the given queue position (clamped to the queue) and returns it.", http.StatusNotFound)), func(ctx context.Context, in *songNumberInput) (*huma.StreamResponse, error) { - return streamSong(m.Song(in.Song)) + return streamSong(m.Song(ctx, queue(ctx), in.Song)) }) register(s, a, audio("get-sound-test-song", "/music/soundTest", "Sound check song", "Returns a random song for checking the volume.", http.StatusNotFound), func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { return streamSong(m.SoundCheckSong()) }) - register(s, a, audio("get-random-song", "/music/rand", "Random song", - "Picks a random song from a random soundtrack. Add it to the queue with /music/addQue.", http.StatusNotFound), + register(s, a, queued(audio("get-random-song", "/music/rand", "Random song", + "Picks a random song from a random soundtrack. Add it to the queue with /music/addQue.", http.StatusNotFound)), func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { - return streamSong(m.RandomSong(ctx)) + return streamSong(m.RandomSong(ctx, queue(ctx))) }) - register(s, a, audio("get-random-song-low-chance", "/music/rand/low", "Random song, favouring rarely played", - "Picks a random song, favouring soundtracks that have been played less than average.", http.StatusNotFound), + register(s, a, queued(audio("get-random-song-low-chance", "/music/rand/low", "Random song, favouring rarely played", + "Picks a random song, favouring soundtracks that have been played less than average.", http.StatusNotFound)), func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { - return streamSong(m.RandomSongLowChance(ctx)) + return streamSong(m.RandomSongLowChance(ctx, queue(ctx))) }) - register(s, a, audio("get-random-song-classic", "/music/rand/classic", "Random song, every song equal", - "Picks a random song where every song has the same chance, regardless of soundtrack.", http.StatusNotFound), + register(s, a, queued(audio("get-random-song-classic", "/music/rand/classic", "Random song, every song equal", + "Picks a random song where every song has the same chance, regardless of soundtrack.", http.StatusNotFound)), func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { - return streamSong(m.RandomSongClassic(ctx)) + return streamSong(m.RandomSongClassic(ctx, queue(ctx))) }) - register(s, a, audio("get-next-song", "/music/next", "Next song in the queue", - "Moves to the next song in the queue and returns it. Stays on the last song at the end.", http.StatusNotFound), + register(s, a, queued(audio("get-next-song", "/music/next", "Next song in the queue", + "Moves to the next song in the queue and returns it. Stays on the last song at the end.", http.StatusNotFound)), func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { - return streamSong(m.NextSong()) + return streamSong(m.NextSong(ctx, queue(ctx))) }) - register(s, a, audio("get-previous-song", "/music/previous", "Previous song in the queue", - "Moves to the previous song in the queue and returns it. Stays on the first song at the start.", http.StatusNotFound), + register(s, a, queued(audio("get-previous-song", "/music/previous", "Previous song in the queue", + "Moves to the previous song in the queue and returns it. Stays on the first song at the start.", http.StatusNotFound)), func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { - return streamSong(m.PreviousSong()) + return streamSong(m.PreviousSong(ctx, queue(ctx))) }) - register(s, a, list("get-song-info", "/music/info", "Current song", "Returns the current song in the queue.", false), + register(s, a, queued(list("get-song-info", "/music/info", "Current song", "Returns the current song in the queue.", false)), func(ctx context.Context, _ *struct{}) (*jsonBody[backend.SongInfo], error) { - return ok(m.SongInfo(ctx), nil) + return ok(m.SongInfo(ctx, queue(ctx))) }) - register(s, a, list("get-played-songs", "/music/list", "Queue", "Returns all songs in the queue.", false), + register(s, a, queued(list("get-played-songs", "/music/list", "Queue", "Returns all songs in the queue.", false)), func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SongInfo], error) { - return ok(m.PlayedSongs(ctx), nil) + return ok(m.PlayedSongs(ctx, queue(ctx))) }) randomNames := func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) { return ok(m.SoundtrackNamesRandom(ctx)) @@ -123,23 +133,30 @@ func (s *Server) registerMusic(a apis) { }) register(s, a, status("reset-queue", http.MethodGet, "/music/reset", "Reset the queue", - "Clears the queue and reloads the soundtrack list."), - func(ctx context.Context, _ *struct{}) (*noBody, error) { - if err := m.Reset(ctx); err != nil { + "Ends the queue, starts a new one and reloads the soundtrack list. "+ + "The new queue's ID is returned in the X-Queue-Id header; send it with the following requests."), + func(ctx context.Context, _ *struct{}) (*resetOutput, error) { + id, err := m.NewQueue(ctx, queue(ctx)) + if err != nil { return nil, apiError(err) } - return &noBody{}, nil + if err := m.ReloadSoundtracks(ctx); err != nil { + return nil, apiError(err) + } + return &resetOutput{QueueID: id.String()}, nil }) register(s, a, status("add-latest-to-queue", http.MethodGet, "/music/addQue", "Add the last random song to the queue", "Adds the song returned by the last random song request to the end of the queue."), func(ctx context.Context, _ *struct{}) (*noBody, error) { - m.AddLatestToQueue() + if err := m.AddLatestToQueue(ctx, queue(ctx)); err != nil { + return nil, apiError(err) + } return &noBody{}, nil }) register(s, a, status("mark-current-played", http.MethodGet, "/music/addPlayed", "Mark the current song as played", "Increments the play counters of the current song and its soundtrack."), func(ctx context.Context, _ *struct{}) (*noBody, error) { - if err := m.AddLatestPlayed(ctx); err != nil { + if err := m.AddLatestPlayed(ctx, queue(ctx)); err != nil { return nil, apiError(err) } return &noBody{}, nil @@ -148,7 +165,7 @@ func (s *Server) registerMusic(a apis) { "Increments the play counters of the song at the given queue position. Positions outside the queue are ignored."), func(ctx context.Context, in *songNumberInput) (*noBody, error) { logging.GetLogger().Info("Marking song as played", zap.Int("song_no", in.Song)) - if err := m.SetPlayed(ctx, in.Song); err != nil { + if err := m.SetPlayed(ctx, queue(ctx), in.Song); err != nil { return nil, apiError(err) } return &noBody{}, nil diff --git a/internal/server/openapi_test.go b/internal/server/openapi_test.go index 7b20761..495e543 100644 --- a/internal/server/openapi_test.go +++ b/internal/server/openapi_test.go @@ -118,7 +118,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) { "POST /api/v1/token", "POST /api/v1/token/cleanup", "PUT /api/v1/music/played", - }, operations, "/sync/reset is hidden and legacy routes are not documented") + }, operations, "/api/v1/sync/reset is hidden and legacy routes are not documented") assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type) assert.Equal(t, "bearer", spec.Components.SecuritySchemes[bearerAuthName].Value.Scheme) diff --git a/internal/server/routes.go b/internal/server/routes.go index 25de1dd..8e654fe 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -24,10 +24,10 @@ func (s *Server) RegisterRoutes() http.Handler { // the handlers are registered but never called. func OpenAPISpec() *huma.OpenAPI { _, a := (&Server{}).routes() - return a.v1.OpenAPI() + return a.OpenAPI() } -func (s *Server) routes() (*echo.Echo, apis) { +func (s *Server) routes() (*echo.Echo, huma.API) { e := echo.New() e.HTTPErrorHandler = httpErrorHandler if len(s.cfg.TrustedProxies) > 0 { @@ -43,15 +43,15 @@ func (s *Server) routes() (*echo.Echo, apis) { e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ AllowOrigins: []string{"https://*", "http://*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, - AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", middleware.QueueHeader}, + ExposeHeaders: []string{middleware.QueueHeader}, AllowCredentials: true, MaxAge: 300, })) - // The API: /api/v1 is documented at /docs and /openapi.json; legacy routes are hidden. - a := newAPIs(e) - a.v1.UseMiddleware(middleware.ClientInfo, middleware.TokenAuth(a.v1, s.q, s.cfg.DisableSessionAuth)) - a.legacy.UseMiddleware(middleware.ClientInfo, middleware.Deprecation) + // The API: /api/v1 is documented at /docs and /openapi.json. + a := newAPI(e) + a.UseMiddleware(middleware.ClientInfo, middleware.TokenAuth(a, s.q, s.cfg.DisableSessionAuth)) s.registerToken(a) s.registerMisc(a) s.registerSync(a) diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 00e6ec8..ba51978 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -28,64 +28,56 @@ func (e *testEnv) blockSync() (release func()) { func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) { e := newTestEnv(t) e.addSong("Game A", "Song", "a") - for _, prefix := range []string{"", "/api/v1"} { - for _, path := range []string{"/sync", "/sync/full", "/sync/reset"} { - // GET falls through to the frontend's static files, which answer 404. - assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, prefix+path).Code, prefix+path) - } + for _, path := range []string{"/api/v1/sync", "/api/v1/sync/full", "/api/v1/sync/reset"} { + // GET falls through to the frontend's static files, which answer 404. + assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path) } assert.False(t, e.server.sync.IsSyncing(), "no sync was started") assert.Len(t, e.store.Songs, 1, "nothing was deleted") } func TestSyncStartAndLock(t *testing.T) { - for _, prefix := range []string{"", "/api/v1"} { - t.Run("prefix="+prefix, func(t *testing.T) { - e := newTestEnv(t) - e.addSong("Game A", "Song", "a") - release := e.blockSync() + e := newTestEnv(t) + e.addSong("Game A", "Song", "a") + release := e.blockSync() - assert.Equal(t, http.StatusOK, e.do(http.MethodPost, prefix+"/sync").Code) - assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/full").Code) - assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/reset").Code) - assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, prefix+"/music/rand").Code) - assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/info").Code, "info is available during sync") + assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code) + assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, "/api/v1/sync/full").Code) + assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, "/api/v1/sync/reset").Code) + assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, "/api/v1/music/rand").Code) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/info").Code, "info is available during sync") - progress := e.do(http.MethodGet, prefix+"/sync/progress") - assert.Equal(t, http.StatusOK, progress.Code) - assert.Contains(t, progress.Body.String(), `"progress"`) + progress := e.do(http.MethodGet, "/api/v1/sync/progress") + assert.Equal(t, http.StatusOK, progress.Code) + assert.Contains(t, progress.Body.String(), `"progress"`) - release() - assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/all").Code) - result := e.do(http.MethodGet, prefix+"/sync/progress") - assert.Contains(t, result.Body.String(), `"total_time"`) - }) - } + release() + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/all").Code) + result := e.do(http.MethodGet, "/api/v1/sync/progress") + assert.Contains(t, result.Body.String(), `"total_time"`) } func TestSyncResetEndpoint(t *testing.T) { e := newTestEnv(t) e.addSong("Game A", "Song", "a") - assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/sync/reset").Code) + assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync/reset").Code) assert.Empty(t, e.store.Soundtracks) assert.Empty(t, e.store.Songs) } -func TestLegacyRoutesAreDeprecated(t *testing.T) { +func TestLegacyRoutesAreRemoved(t *testing.T) { e := newTestEnv(t) - rec := e.do(http.MethodGet, "/version") - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "true", rec.Header().Get("Deprecation")) - - rec = e.do(http.MethodGet, "/api/v1/version") - assert.Equal(t, http.StatusOK, rec.Code) - assert.Empty(t, rec.Header().Get("Deprecation")) + for _, path := range []string{"/version", "/health", "/music/list", "/music/rand", "/characters", "/sync/progress"} { + assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path) + } + assert.Equal(t, http.StatusMethodNotAllowed, e.do(http.MethodPost, "/sync").Code) + assert.False(t, e.server.sync.IsSyncing()) } func TestVersionEndpoints(t *testing.T) { e := newTestEnv(t) var latest backend.VersionData - require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/version").Body.Bytes(), &latest)) + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/version").Body.Bytes(), &latest)) assert.Equal(t, backend.GetLatestVersion(), latest) var history []backend.VersionData @@ -95,28 +87,19 @@ func TestVersionEndpoints(t *testing.T) { func TestHealthEndpoint(t *testing.T) { e := newTestEnv(t) - rec := e.do(http.MethodGet, "/health") + rec := e.do(http.MethodGet, "/api/v1/health") assert.Equal(t, http.StatusOK, rec.Code) assert.JSONEq(t, `{"status":"up"}`, rec.Body.String()) } func TestSyncAliasesAreRemoved(t *testing.T) { e := newTestEnv(t) - for _, prefix := range []string{"", "/api/v1"} { - for _, path := range []string{"/sync/new", "/sync/quick", "/sync/new/full"} { - assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, prefix+path).Code, prefix+path) - } + for _, path := range []string{"/api/v1/sync/new", "/api/v1/sync/quick", "/api/v1/sync/new/full"} { + assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, path).Code, path) } assert.False(t, e.server.sync.IsSyncing()) } -func TestV1HealthEndpoint(t *testing.T) { - e := newTestEnv(t) - rec := e.do(http.MethodGet, "/api/v1/health") - assert.Equal(t, http.StatusOK, rec.Code) - assert.JSONEq(t, `{"status":"up"}`, rec.Body.String()) -} - func TestDocsAndSpecLinks(t *testing.T) { e := newTestEnv(t) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/docs").Code) diff --git a/internal/server/server.go b/internal/server/server.go index 9cade88..c9d77d3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -68,8 +68,11 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e // afterSync refreshes cached data once a sync has finished. func (s *Server) afterSync(ctx context.Context) { - if err := s.music.Reset(ctx); err != nil { - logging.GetLogger().Error("Failed to reset music after sync", zap.Error(err)) + if err := s.music.EndAllQueues(ctx); err != nil { + logging.GetLogger().Error("Failed to end the queues after sync", zap.Error(err)) + } + if err := s.music.ReloadSoundtracks(ctx); err != nil { + logging.GetLogger().Error("Failed to reload soundtracks after sync", zap.Error(err)) } if err := s.search.Reload(ctx); err != nil { logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err)) diff --git a/internal/server/statistics_handler.go b/internal/server/statistics_handler.go index c693896..2616990 100644 --- a/internal/server/statistics_handler.go +++ b/internal/server/statistics_handler.go @@ -19,7 +19,7 @@ func (in *limitInput) capped() int32 { return int32(min(in.Limit, maxStatsLimit)) } -func (s *Server) registerStatistics(a apis) { +func (s *Server) registerStatistics(a huma.API) { st := s.stats op := func(id, path, summary, description string) route { return route{op: huma.Operation{ diff --git a/internal/server/sync_handler.go b/internal/server/sync_handler.go index 9e6959f..b309e4e 100644 --- a/internal/server/sync_handler.go +++ b/internal/server/sync_handler.go @@ -11,8 +11,8 @@ import ( "github.com/danielgtaylor/huma/v2" ) -func (s *Server) registerSync(a apis) { - registry := a.v1.OpenAPI().Components.Schemas +func (s *Server) registerSync(a huma.API) { + registry := a.OpenAPI().Components.Schemas progress := huma.Operation{ OperationID: "get-sync-progress", Method: http.MethodGet, @@ -30,7 +30,7 @@ func (s *Server) registerSync(a apis) { }, }, } - register(s, a, route{op: progress, legacyPath: "/sync/progress"}, + register(s, a, route{op: progress}, func(ctx context.Context, _ *struct{}) (*jsonBody[any], error) { if s.sync.IsSyncing() { return ok[any](s.sync.Progress(), nil) @@ -49,7 +49,6 @@ func (s *Server) registerSync(a apis) { Tags: []string{"sync"}, Errors: []int{http.StatusLocked}, }, - legacyPath: path, }, func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) { message := "Start syncing soundtracks" if full { @@ -78,7 +77,7 @@ func (s *Server) registerSync(a apis) { Tags: []string{"sync"}, Hidden: true, } - register(s, a, route{op: reset, legacyPath: "/sync/reset", locked: true}, + register(s, a, route{op: reset, locked: true}, func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) { logging.GetLogger().Info("Resetting soundtracks database") return ok("Soundtracks and songs are deleted from the database", s.sync.ResetDB(ctx)) diff --git a/internal/server/token_handler.go b/internal/server/token_handler.go index 7e96f5f..8e29f30 100644 --- a/internal/server/token_handler.go +++ b/internal/server/token_handler.go @@ -55,7 +55,7 @@ func (s *Server) passwordValid(password string) bool { return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.APIPassword)) == 1 } -func (s *Server) registerToken(a apis) { +func (s *Server) registerToken(a huma.API) { create := huma.Operation{ OperationID: "create-token", Method: http.MethodPost, @@ -65,7 +65,7 @@ func (s *Server) registerToken(a apis) { "Requires the API password when the server has one. Limited to 10 requests per minute per client.", Tags: []string{"auth"}, Errors: []int{http.StatusUnauthorized, http.StatusTooManyRequests}, - Middlewares: huma.Middlewares{middleware.RateLimit(a.v1, 10, time.Minute)}, + Middlewares: huma.Middlewares{middleware.RateLimit(a, 10, time.Minute)}, } register(s, a, route{op: create, public: true}, func(ctx context.Context, in *tokenInput) (*jsonBody[TokenResponse], error) { diff --git a/internal/testutil/fakedb/fakedb.go b/internal/testutil/fakedb/fakedb.go index 533d4bd..f59fc8f 100644 --- a/internal/testutil/fakedb/fakedb.go +++ b/internal/testutil/fakedb/fakedb.go @@ -13,6 +13,7 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) // Store is an in-memory implementation of the queries used by the application. @@ -25,6 +26,8 @@ type Store struct { Soundtracks []repository.Soundtrack Songs []repository.Song Sessions map[string]repository.Session + Queues []repository.Queue + QueueSongs []repository.QueueSong // Err, when set, is returned by every implemented query. Err error @@ -373,6 +376,122 @@ func (s *Store) HasSession(token string) bool { return ok } +func (s *Store) queueIndex(id uuid.UUID) int { + return slices.IndexFunc(s.Queues, func(q repository.Queue) bool { return q.Uuid == id }) +} + +func (s *Store) updateQueue(id uuid.UUID, f func(*repository.Queue)) error { + defer s.lock()() + if s.Err != nil { + return s.Err + } + if i := s.queueIndex(id); i >= 0 { + f(&s.Queues[i]) + } + return nil +} + +func (s *Store) CreateQueue(context.Context) (repository.Queue, error) { + defer s.lock()() + if s.Err != nil { + return repository.Queue{}, s.Err + } + queue := repository.Queue{Uuid: uuid.New(), CurrentSong: -1, StartedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}} + s.Queues = append(s.Queues, queue) + return queue, nil +} + +func (s *Store) GetQueue(_ context.Context, id uuid.UUID) (repository.Queue, error) { + defer s.lock()() + if s.Err != nil { + return repository.Queue{}, s.Err + } + if i := s.queueIndex(id); i >= 0 { + return s.Queues[i], nil + } + return repository.Queue{}, pgx.ErrNoRows +} + +func endQueue(q *repository.Queue) { + if !q.EndedAt.Valid { + q.EndedAt = pgtype.Timestamptz{Time: time.Now(), Valid: true} + } +} + +func (s *Store) EndQueue(_ context.Context, id uuid.UUID) error { + return s.updateQueue(id, endQueue) +} + +func (s *Store) EndActiveQueues(context.Context) error { + defer s.lock()() + for i := range s.Queues { + endQueue(&s.Queues[i]) + } + return s.Err +} + +func (s *Store) SetQueueLastFetched(_ context.Context, arg repository.SetQueueLastFetchedParams) error { + return s.updateQueue(arg.Uuid, func(q *repository.Queue) { q.LastFetchedSongUuid = arg.LastFetchedSongUuid }) +} + +func (s *Store) SetQueueCurrentSong(_ context.Context, arg repository.SetQueueCurrentSongParams) error { + return s.updateQueue(arg.Uuid, func(q *repository.Queue) { q.CurrentSong = arg.CurrentSong }) +} + +func (s *Store) AddLastFetchedToQueue(_ context.Context, id uuid.UUID) error { + defer s.lock()() + if s.Err != nil { + return s.Err + } + i := s.queueIndex(id) + if i < 0 { + return nil + } + queue := &s.Queues[i] + songIndex := slices.IndexFunc(s.Songs, func(song repository.Song) bool { return song.Uuid == queue.LastFetchedSongUuid }) + if songIndex < 0 { + return nil + } + song := s.Songs[songIndex] + var soundtrackName string + if st := s.soundtrackIndex(song.SoundtrackID); st >= 0 { + soundtrackName = s.Soundtracks[st].SoundtrackName + } + songNo := int32(0) + for _, qs := range s.QueueSongs { + if qs.QueueUuid == id { + songNo = max(songNo, qs.SongNo+1) + } + } + s.QueueSongs = append(s.QueueSongs, repository.QueueSong{ + QueueUuid: id, SongNo: songNo, SongUuid: song.Uuid, SoundtrackName: soundtrackName, SongName: song.SongName, + }) + queue.CurrentSong = songNo + queue.LastFetchedSongUuid = uuid.Nil + return nil +} + +func (s *Store) ListQueueSongs(_ context.Context, id uuid.UUID) ([]repository.ListQueueSongsRow, error) { + defer s.lock()() + if s.Err != nil { + return nil, s.Err + } + var rows []repository.ListQueueSongsRow + for _, qs := range s.QueueSongs { + if qs.QueueUuid != id { + continue + } + row := repository.ListQueueSongsRow{SongNo: qs.SongNo, SongUuid: qs.SongUuid, SoundtrackName: qs.SoundtrackName, SongName: qs.SongName} + if i := slices.IndexFunc(s.Songs, func(song repository.Song) bool { return song.Uuid == qs.SongUuid }); i >= 0 { + song := s.Songs[i] + row.Path, row.TimesPlayed, row.SoundtrackUuid = &song.Path, &song.TimesPlayed, song.SoundtrackUuid + } + rows = append(rows, row) + } + slices.SortFunc(rows, func(a, b repository.ListQueueSongsRow) int { return cmp.Compare(a.SongNo, b.SongNo) }) + return rows, nil +} + // Statistics queries return empty results; tests that need data use their own fakes. func (s *Store) GetMostPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) {