Store each client's play queue in the database

The queue lived in memory and was shared by all clients, so a restart or a
finished sync cleared it. Every client now has its own queue in the new
queue and queue_song tables (added to the undeployed migration 000006).

The music endpoints return the queue ID in the X-Queue-Id header and expect
it back. A request without an ID, or with an ended one, starts a new queue.
/music/reset ends the queue and returns a new ID, and a sync ends all
running queues. Ended queues are kept as history.

The deprecated legacy routes without the /api/v1 prefix are removed.
This commit is contained in:
Sansan committed 2026-10-07 20:03:03 +02:00
1 parent b5dc42b654
commit a705d7b476
28 files changed
+1103 -320

No files matched your search

+150 -85
View File
@@ -32,23 +32,19 @@ type SongInfo struct {
SongNo int `json:"SongNo"` 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 { type MusicService struct {
q repository.Querier q repository.Querier
soundCheckPath string soundCheckPath string
cacheMu sync.Mutex cacheMu sync.Mutex
soundtracks []repository.Soundtrack 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. // NewMusicService creates a MusicService. soundCheckPath is the directory holding sound check songs.
func NewMusicService(q repository.Querier, soundCheckPath string) *MusicService { 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) { 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 return m.soundtracks, nil
} }
// Reset clears the play queue and reloads the soundtrack list. // ReloadSoundtracks reloads the soundtrack list.
func (m *MusicService) Reset(ctx context.Context) error { func (m *MusicService) ReloadSoundtracks(ctx context.Context) error {
m.queue = nil
m.currentSong = -1
m.cacheMu.Lock() m.cacheMu.Lock()
m.soundtracks = nil m.soundtracks = nil
m.cacheMu.Unlock() m.cacheMu.Unlock()
@@ -75,6 +69,36 @@ func (m *MusicService) Reset(ctx context.Context) error {
return err 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. // SoundCheckSong returns the path of a random sound check song.
func (m *MusicService) SoundCheckSong() (string, error) { func (m *MusicService) SoundCheckSong() (string, error) {
files, err := os.ReadDir(m.soundCheckPath) 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 return filepath.Join(m.soundCheckPath, files[rand.IntN(len(files))].Name()), nil
} }
// AddLatestToQueue appends the last fetched random song to the queue. // AddLatestToQueue appends the last fetched random song to the queue and makes it the current song.
func (m *MusicService) AddLatestToQueue() { func (m *MusicService) AddLatestToQueue(ctx context.Context, queueID uuid.UUID) error {
if m.lastFetched.Path != "" { return m.q.AddLastFetchedToQueue(ctx, queueID)
m.currentSong = len(m.queue)
m.queue = append(m.queue, m.lastFetched)
m.lastFetched = repository.Song{}
}
} }
// AddLatestPlayed increments the play counters of the current song. // AddLatestPlayed increments the play counters of the current song.
func (m *MusicService) AddLatestPlayed(ctx context.Context) error { func (m *MusicService) AddLatestPlayed(ctx context.Context, queueID uuid.UUID) error {
if !m.validIndex(m.currentSong) { queue, songs, err := m.loadQueue(ctx, queueID)
return nil 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. // SetPlayed increments the play counters of the song at the given queue position.
func (m *MusicService) SetPlayed(ctx context.Context, songNumber int) error { func (m *MusicService) SetPlayed(ctx context.Context, queueID uuid.UUID, songNumber int) error {
if !m.validIndex(songNumber) { _, songs, err := m.loadQueue(ctx, queueID)
return nil 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 { if err := m.q.AddSoundtrackPlayedByUUID(ctx, song.SoundtrackUuid); err != nil {
return err return err
} }
return m.q.AddSongPlayedByUUID(ctx, song.Uuid) return m.q.AddSongPlayedByUUID(ctx, song.SongUuid)
} }
func (m *MusicService) validIndex(i int) bool { func (m *MusicService) loadQueue(ctx context.Context, queueID uuid.UUID) (repository.Queue, []repository.ListQueueSongsRow, error) {
return i >= 0 && i < len(m.queue) 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. // 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) soundtracks, err := m.allSoundtracks(ctx)
if err != nil { if err != nil {
return "", err return "", err
@@ -134,29 +167,37 @@ func (m *MusicService) RandomSong(ctx context.Context) (string, error) {
for i, s := range soundtracks { for i, s := range soundtracks {
candidates[i] = weightedSoundtrack{soundtrack: s, weight: 1} 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. // 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) soundtracks, err := m.allSoundtracks(ctx)
if err != nil { if err != nil {
return "", err 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) song, err := m.pickSong(ctx, candidates)
if err != nil { if err != nil {
return "", err 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 return song.Path, nil
} }
// RandomSongClassic picks a random song where every song has the same chance. // 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 { for range maxPickAttempts {
row, err := m.q.GetRandomActiveSong(ctx) row, err := m.q.GetRandomActiveSong(ctx)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@@ -171,73 +212,97 @@ func (m *MusicService) RandomSongClassic(ctx context.Context) (string, error) {
} }
continue continue
} }
m.lastFetched = row.Song return m.remember(ctx, queueID, row.Song)
return row.Song.Path, nil
} }
return "", ErrNoSongs return "", ErrNoSongs
} }
// SongInfo returns information about the current song in the queue. // SongInfo returns information about the current song in the queue.
func (m *MusicService) SongInfo(ctx context.Context) SongInfo { func (m *MusicService) SongInfo(ctx context.Context, queueID uuid.UUID) (SongInfo, error) {
if !m.validIndex(m.currentSong) { queue, songs, err := m.loadQueue(ctx, queueID)
return SongInfo{} if err != nil {
return SongInfo{}, err
} }
song := m.queue[m.currentSong] current := int(queue.CurrentSong)
soundtrack := m.soundtrackFor(ctx, song) if current < 0 || current >= len(songs) {
return SongInfo{ return SongInfo{}, nil
SoundtrackUuid: soundtrack.Uuid,
Soundtrack: soundtrack.SoundtrackName,
SoundtrackPlayed: soundtrack.TimesPlayed,
SongUuid: song.Uuid,
Song: song.SongName,
SongPlayed: song.TimesPlayed,
CurrentlyPlaying: true,
SongNo: m.currentSong,
} }
return m.songInfo(ctx, songs[current], true), nil
} }
// PlayedSongs returns all songs in the queue. // PlayedSongs returns all songs in the queue.
func (m *MusicService) PlayedSongs(ctx context.Context) []SongInfo { func (m *MusicService) PlayedSongs(ctx context.Context, queueID uuid.UUID) ([]SongInfo, error) {
songList := []SongInfo{} queue, songs, err := m.loadQueue(ctx, queueID)
for i, song := range m.queue { if err != nil {
soundtrack := m.soundtrackFor(ctx, song) return nil, err
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,
})
} }
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. // Song jumps to the given queue position (clamped to the queue) and returns its path.
func (m *MusicService) Song(songNumber int) (string, error) { func (m *MusicService) Song(ctx context.Context, queueID uuid.UUID, songNumber int) (string, error) {
if len(m.queue) == 0 { _, songs, err := m.loadQueue(ctx, queueID)
return "", ErrQueueEmpty if err != nil {
return "", err
} }
m.currentSong = max(0, min(songNumber, len(m.queue)-1)) return m.jump(ctx, queueID, songs, songNumber)
return m.queue[m.currentSong].Path, nil
} }
// NextSong moves to the next song in the queue, staying on the last one at the end. // NextSong moves to the next song in the queue, staying on the last one at the end.
func (m *MusicService) NextSong() (string, error) { func (m *MusicService) NextSong(ctx context.Context, queueID uuid.UUID) (string, error) {
if len(m.queue) == 0 { queue, songs, err := m.loadQueue(ctx, queueID)
return "", ErrQueueEmpty 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. // PreviousSong moves to the previous song in the queue, staying on the first one at the start.
func (m *MusicService) PreviousSong() (string, error) { func (m *MusicService) PreviousSong(ctx context.Context, queueID uuid.UUID) (string, error) {
if len(m.queue) == 0 { 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 "", 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. // 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() 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) soundtracks, err := m.allSoundtracks(ctx)
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to load soundtracks", zap.Error(err)) logging.GetLogger().Error("Failed to load soundtracks", zap.Error(err))
return repository.Soundtrack{} return repository.Soundtrack{}, false
} }
for _, soundtrack := range soundtracks { for _, soundtrack := range soundtracks {
if soundtrack.Uuid == song.SoundtrackUuid { if soundtrack.Uuid == soundtrackUUID {
return soundtrack return soundtrack, true
} }
} }
return repository.Soundtrack{} return repository.Soundtrack{}, false
} }
+180 -44
View File
@@ -33,30 +33,55 @@ func addSoundtrackWithSongs(t *testing.T, store *fakedb.Store, musicDir, name st
return 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) { func TestMusicServiceEmptyQueue(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir()) 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) assert.ErrorIs(t, err, ErrQueueEmpty)
_, err = m.PreviousSong() _, err = m.PreviousSong(ctx, q)
assert.ErrorIs(t, err, ErrQueueEmpty) assert.ErrorIs(t, err, ErrQueueEmpty)
_, err = m.Song(0) _, err = m.Song(ctx, q, 0)
assert.ErrorIs(t, err, ErrQueueEmpty) assert.ErrorIs(t, err, ErrQueueEmpty)
assert.Equal(t, SongInfo{}, m.SongInfo(context.Background())) info, err := m.SongInfo(ctx, q)
assert.Empty(t, m.PlayedSongs(context.Background())) require.NoError(t, err)
assert.NoError(t, m.AddLatestPlayed(context.Background())) assert.Equal(t, SongInfo{}, info)
assert.NoError(t, m.SetPlayed(context.Background(), -1)) 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) { func TestMusicServiceNoSoundtracks(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir()) m := NewMusicService(fakedb.New(), t.TempDir())
ctx := context.Background() ctx := context.Background()
q := newQueue(t, m)
_, err := m.RandomSong(ctx) _, err := m.RandomSong(ctx, q)
assert.ErrorIs(t, err, ErrNoSongs) assert.ErrorIs(t, err, ErrNoSongs)
_, err = m.RandomSongLowChance(ctx) _, err = m.RandomSongLowChance(ctx, q)
assert.ErrorIs(t, err, ErrNoSongs) assert.ErrorIs(t, err, ErrNoSongs)
_, err = m.RandomSongClassic(ctx) _, err = m.RandomSongClassic(ctx, q)
assert.ErrorIs(t, err, ErrNoSongs) assert.ErrorIs(t, err, ErrNoSongs)
} }
@@ -65,7 +90,7 @@ func TestMusicServiceSoundtracksWithoutSongs(t *testing.T) {
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Empty"}) store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Empty"})
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
_, err := m.RandomSong(context.Background()) _, err := m.RandomSong(context.Background(), newQueue(t, m))
assert.ErrorIs(t, err, ErrNoSongs) 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") addSoundtrackWithSongs(t, store, musicDir, "Game A", 0, "Song 1", "Song 2")
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
ctx := context.Background() ctx := context.Background()
q := newQueue(t, m)
first, err := m.RandomSong(ctx) first := queueSong(t, m, q)
require.NoError(t, err) second := queueSong(t, m, q)
m.AddLatestToQueue()
second, err := m.RandomSong(ctx)
require.NoError(t, err)
m.AddLatestToQueue()
info := m.SongInfo(ctx) info, err := m.SongInfo(ctx, q)
require.NoError(t, err)
assert.Equal(t, "Game A", info.Soundtrack) assert.Equal(t, "Game A", info.Soundtrack)
soundtrack, _ := store.Soundtrack("Game A") soundtrack, _ := store.Soundtrack("Game A")
assert.Equal(t, soundtrack.Uuid, info.SoundtrackUuid) 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.Equal(t, 1, info.SongNo)
assert.True(t, info.CurrentlyPlaying) assert.True(t, info.CurrentlyPlaying)
path, err := m.PreviousSong() path, err := m.PreviousSong(ctx, q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, first, path) assert.Equal(t, first, path)
path, err = m.PreviousSong() path, err = m.PreviousSong(ctx, q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, first, path, "previous stays on the first song") 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) require.NoError(t, err)
assert.Equal(t, second, path) assert.Equal(t, second, path)
path, err = m.NextSong() path, err = m.NextSong(ctx, q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, second, path, "next stays on the last song") 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) require.NoError(t, err)
assert.Equal(t, second, path, "song number is clamped") 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) require.Len(t, played, 2)
assert.False(t, played[0].CurrentlyPlaying) assert.False(t, played[0].CurrentlyPlaying)
assert.True(t, played[1].CurrentlyPlaying) assert.True(t, played[1].CurrentlyPlaying)
}
require.NoError(t, m.Reset(ctx)) func TestMusicServiceQueuesAreSeparate(t *testing.T) {
_, err = m.NextSong() 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) 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) { func TestMusicServiceAddLatestToQueueWithoutFetch(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir()) m := NewMusicService(fakedb.New(), t.TempDir())
m.AddLatestToQueue() q := newQueue(t, m)
_, err := m.NextSong() require.NoError(t, m.AddLatestToQueue(context.Background(), q))
_, err := m.NextSong(context.Background(), q)
assert.ErrorIs(t, err, ErrQueueEmpty) 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) { func TestMusicServiceMarkPlayed(t *testing.T) {
store := fakedb.New() store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1") st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
ctx := context.Background() ctx := context.Background()
q := newQueue(t, m)
queueSong(t, m, q)
_, err := m.RandomSong(ctx) require.NoError(t, m.AddLatestPlayed(ctx, q))
require.NoError(t, err) require.NoError(t, m.SetPlayed(ctx, q, 0))
m.AddLatestToQueue() require.NoError(t, m.SetPlayed(ctx, q, 5), "out of range is ignored")
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")
updated, _ := store.Soundtrack("Game A") updated, _ := store.Soundtrack("Game A")
assert.Equal(t, int32(2), updated.TimesPlayed) assert.Equal(t, int32(2), updated.TimesPlayed)
@@ -155,9 +292,10 @@ func TestMusicServiceRemovesBrokenSongs(t *testing.T) {
writeFile(t, st.Path+wav, "wav") writeFile(t, st.Path+wav, "wav")
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Wave", Path: st.Path + wav, FileName: &wav}) store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Wave", Path: st.Path + wav, FileName: &wav})
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
q := newQueue(t, m)
for range 20 { for range 20 {
path, err := m.RandomSong(context.Background()) path, err := m.RandomSong(context.Background(), q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, st.Path+"Good.mp3", path) assert.Equal(t, st.Path+"Good.mp3", path)
} }
@@ -172,9 +310,10 @@ func TestMusicServiceRandomSongClassic(t *testing.T) {
missing := "Missing.mp3" missing := "Missing.mp3"
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Missing", Path: st.Path + missing, FileName: &missing}) store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Missing", Path: st.Path + missing, FileName: &missing})
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
q := newQueue(t, m)
for range 20 { for range 20 {
path, err := m.RandomSongClassic(context.Background()) path, err := m.RandomSongClassic(context.Background(), q)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, st.Path+"Good.mp3", path) assert.Equal(t, st.Path+"Good.mp3", path)
} }
@@ -244,12 +383,9 @@ func TestMarkPlayedUsesSongUUID(t *testing.T) {
writeFile(t, st.Path+fileName, "copy") writeFile(t, st.Path+fileName, "copy")
store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Song", Path: st.Path + fileName, FileName: &fileName}) store.AddTestSong(repository.Song{SoundtrackID: st.ID, SongName: "Song", Path: st.Path + fileName, FileName: &fileName})
m := NewMusicService(store, t.TempDir()) m := NewMusicService(store, t.TempDir())
ctx := context.Background() q := newQueue(t, m)
queueSong(t, m, q)
_, err := m.RandomSong(ctx) require.NoError(t, m.AddLatestPlayed(context.Background(), q))
require.NoError(t, err)
m.AddLatestToQueue()
require.NoError(t, m.AddLatestPlayed(ctx))
songs := store.SongsOf(st.ID) songs := store.SongsOf(st.ID)
require.Len(t, songs, 2) require.Len(t, songs, 2)
@@ -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)
}
+3 -1
View File
@@ -65,7 +65,7 @@ func TestMigrationsKeepData(t *testing.T) {
assert.Error(t, err, "game table is renamed") assert.Error(t, err, "game table is renamed")
} }
func TestFingerprintMigrationDown(t *testing.T) { func TestFingerprintAndQueueMigrationDown(t *testing.T) {
database := openDatabase(t, pgtest.NewDatabase(t)) database := openDatabase(t, pgtest.NewDatabase(t))
m, closeFn, err := database.NewMigrator() m, closeFn, err := database.NewMigrator()
require.NoError(t, err) require.NoError(t, err)
@@ -75,6 +75,8 @@ func TestFingerprintMigrationDown(t *testing.T) {
require.NoError(t, m.Steps(-1)) require.NoError(t, m.Steps(-1))
assert.Equal(t, 0, count(t, database, assert.Equal(t, 0, count(t, database,
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'soundtrack' AND column_name = 'fingerprint'")) "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()) require.NoError(t, m.Up())
} }
@@ -1 +1,3 @@
DROP TABLE queue_song;
DROP TABLE queue;
ALTER TABLE soundtrack DROP COLUMN fingerprint; ALTER TABLE soundtrack DROP COLUMN fingerprint;
@@ -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)
);
@@ -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 '';
+51
View File
@@ -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;
+17
View File
@@ -11,6 +11,23 @@ import (
"github.com/jackc/pgx/v5/pgtype" "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 { type Session struct {
Token string `json:"token"` Token string `json:"token"`
IpAddress string `json:"ip_address"` IpAddress string `json:"ip_address"`
+11
View File
@@ -13,6 +13,9 @@ import (
type Querier interface { type Querier interface {
AddHashToSong(ctx context.Context, arg AddHashToSongParams) error 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 AddSong(ctx context.Context, arg AddSongParams) error
AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error
AddSongPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error AddSongPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
@@ -23,9 +26,12 @@ type Querier interface {
ClearSongs(ctx context.Context) error ClearSongs(ctx context.Context) error
ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error
ClearSoundtracks(ctx context.Context) error ClearSoundtracks(ctx context.Context) error
CreateQueue(ctx context.Context) (Queue, error)
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
DeleteExpiredSessions(ctx context.Context) error DeleteExpiredSessions(ctx context.Context) error
DeleteSession(ctx context.Context, token string) 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) FetchAllSongs(ctx context.Context) ([]Song, error)
FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error)
@@ -48,6 +54,7 @@ type Querier interface {
GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error)
// Oldest played soundtracks (least recently played, but has been played at least once) // Oldest played soundtracks (least recently played, but has been played at least once)
GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error)
GetQueue(ctx context.Context, argUuid uuid.UUID) (Queue, error)
GetRandomActiveSong(ctx context.Context) (GetRandomActiveSongRow, error) GetRandomActiveSong(ctx context.Context) (GetRandomActiveSongRow, error)
GetSession(ctx context.Context, token string) (Session, error) GetSession(ctx context.Context, token string) (Session, error)
GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error) GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error)
@@ -64,12 +71,16 @@ type Querier interface {
InsertSongInList(ctx context.Context, arg InsertSongInListParams) error InsertSongInList(ctx context.Context, arg InsertSongInListParams) error
InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error)
InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) 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) ListSessions(ctx context.Context) ([]Session, error)
MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error
RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error
RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error
RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error
ResetSoundtrackIdSeq(ctx context.Context) (int64, 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 SetSoundtrackDeletionDate(ctx context.Context) error
SetSoundtrackFingerprint(ctx context.Context, arg SetSoundtrackFingerprintParams) error SetSoundtrackFingerprint(ctx context.Context, arg SetSoundtrackFingerprintParams) error
UpdateSong(ctx context.Context, arg UpdateSongParams) error UpdateSong(ctx context.Context, arg UpdateSongParams) error
+171
View File
@@ -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
}
+47 -35
View File
@@ -8,6 +8,7 @@ import (
"music-server/internal/backend" "music-server/internal/backend"
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/server/middleware"
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humaecho" "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. func newAPI(e *echo.Echo) huma.API {
type apis struct {
v1 huma.API
legacy huma.API
}
func newAPIs(e *echo.Echo) apis {
config := huma.DefaultConfig("MusicServer API", backend.GetLatestVersion().Version) config := huma.DefaultConfig("MusicServer API", backend.GetLatestVersion().Version)
// No $schema fields or Link headers in responses. // No $schema fields or Link headers in responses.
config.CreateHooks = nil config.CreateHooks = nil
config.DocsRenderer = huma.DocsRendererScalar config.DocsRenderer = huma.DocsRendererScalar
config.Info.Description = "API for the MusicServer application.\n\n" + config.Info.Description = "API for the MusicServer application.\n\n" +
"Get a token with `POST /api/v1/token` and send it as `Authorization: Bearer <token>`.\n\n" + "Get a token with `POST /api/v1/token` and send it as `Authorization: Bearer <token>`.\n\n" +
"The same endpoints without the `/api/v1` prefix (legacy routes) are deprecated, need no token " + "Every client has its own play queue. The music endpoints return its ID in the `X-Queue-Id` header; " +
"and are not documented here. They will be removed." "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{ config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
bearerAuthName: {Type: "http", Scheme: "bearer", Description: "Token from POST /api/v1/token"}, bearerAuthName: {Type: "http", Scheme: "bearer", Description: "Token from POST /api/v1/token"},
} }
return humaecho.New(e, config)
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)}
} }
// 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 { type route struct {
op huma.Operation op huma.Operation
// public endpoints need no token. // public endpoints need no token.
public bool 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 endpoints answer 423 while a sync is running.
locked bool 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. var queueHeaderParam = &huma.Param{
func register[I, O any](s *Server, a apis, r route, handler func(context.Context, *I) (*O, error)) { 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 := r.op
op.Path = apiPrefix + r.op.Path op.Path = apiPrefix + r.op.Path
if !r.public { 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) op.Errors = append(op.Errors, http.StatusUnauthorized)
} }
if r.locked { 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) 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 == "" { // documentQueueHeader adds the X-Queue-Id header to the operation's success responses.
return 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. // syncLock answers 423 Locked while a sync is running.
+75 -28
View File
@@ -23,32 +23,80 @@ func TestMusicRandomAndQueue(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.addSong("Game A", "Song", "0123456789") 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) require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type")) assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type"))
assert.Equal(t, "0123456789", rec.Body.String()) 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, "/api/v1/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/addPlayed").Code)
var info backend.SongInfo 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, "Game A", info.Soundtrack)
assert.Equal(t, "Song", info.Song) 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.MethodGet, path).Code, path)
} }
assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/music/played?song=0").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, "/music/played?song=x").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, "/music/reset").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) { func TestMusicRangeRequest(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.addSong("Game A", "Song", "0123456789") 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, http.StatusPartialContent, rec.Code)
assert.Equal(t, "bytes 2-5/10", rec.Header().Get("Content-Range")) assert.Equal(t, "bytes 2-5/10", rec.Header().Get("Content-Range"))
assert.Equal(t, "2345", rec.Body.String()) assert.Equal(t, "2345", rec.Body.String())
@@ -57,21 +105,21 @@ func TestMusicRangeRequest(t *testing.T) {
func TestMusicEmptyStates(t *testing.T) { func TestMusicEmptyStates(t *testing.T) {
e := newTestEnv(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) assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
} }
// Invalid or missing parameters fail validation. // 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, "/api/v1/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").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.Equal(t, http.StatusOK, rec.Code)
assert.JSONEq(t, "[]", rec.Body.String()) assert.JSONEq(t, "[]", rec.Body.String())
} }
func TestMusicSoundCheck(t *testing.T) { func TestMusicSoundCheck(t *testing.T) {
e := newTestEnv(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, http.StatusOK, rec.Code)
assert.Equal(t, "sound check", rec.Body.String()) assert.Equal(t, "sound check", rec.Body.String())
} }
@@ -82,31 +130,31 @@ func TestMusicSoundtrackLists(t *testing.T) {
e.addSong("A Game", "Song", "a") e.addSong("A Game", "Song", "a")
var ordered []string 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) assert.Equal(t, []string{"A Game", "B Game"}, ordered)
var random []string 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) assert.ElementsMatch(t, ordered, random)
} }
func TestMusicDatabaseError(t *testing.T) { func TestMusicDatabaseError(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.store.Err = errors.New("db down") 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, "/api/v1/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/all/order").Code)
} }
func TestCharacterEndpoints(t *testing.T) { func TestCharacterEndpoints(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
var list []string 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) 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)) 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) 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, http.StatusOK, rec.Code)
assert.Equal(t, "image/png", rec.Header().Get("Content-Type")) 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, http.StatusOK, rec.Code)
assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type")) 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) { func TestCharacterPathTraversal(t *testing.T) {
e := newTestEnv(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"} { 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.Equal(t, http.StatusBadRequest, rec.Code, name)
assert.NotContains(t, rec.Body.String(), "secret", name) assert.NotContains(t, rec.Body.String(), "secret", name)
} }
@@ -140,23 +188,23 @@ func TestDownloadEndpoints(t *testing.T) {
url := newGitea(t, http.StatusOK) url := newGitea(t, http.StatusOK)
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = url }) 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.Equal(t, http.StatusOK, rec.Code)
assert.JSONEq(t, `"v2.0.0"`, rec.Body.String()) assert.JSONEq(t, `"v2.0.0"`, rec.Body.String())
rec = e.do(http.MethodGet, "/api/v1/download/list") rec = e.do(http.MethodGet, "/api/v1/download/list")
assert.JSONEq(t, `["Player.exe"]`, rec.Body.String()) 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, http.StatusFound, rec.Code)
assert.Equal(t, "https://example.com/Player.exe", rec.Header().Get("Location")) 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) { func TestDownloadUpstreamDown(t *testing.T) {
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = newGitea(t, http.StatusServiceUnavailable) }) 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) 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") token := e.createToken("secret")
auth := []string{"Authorization", "Bearer " + token} 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, "/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.StatusOK, e.do(http.MethodDelete, "/api/v1/token", auth...).Code)
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", auth...).Code) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", auth...).Code)
+9
View File
@@ -14,6 +14,7 @@ import (
"music-server/internal/config" "music-server/internal/config"
"music-server/internal/db/repository" "music-server/internal/db/repository"
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/server/middleware"
"music-server/internal/testutil/fakedb" "music-server/internal/testutil/fakedb"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -48,6 +49,8 @@ type testEnv struct {
db *fakeDatabase db *fakeDatabase
server *Server server *Server
handler http.Handler 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) { 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 != "" { if body != "" {
req.Header.Set("Content-Type", "application/json") 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 { for i := 0; i+1 < len(headers); i += 2 {
req.Header.Set(headers[i], headers[i+1]) req.Header.Set(headers[i], headers[i+1])
} }
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
e.handler.ServeHTTP(rec, req) e.handler.ServeHTTP(rec, req)
if id := rec.Header().Get(middleware.QueueHeader); id != "" {
e.queueID = id
}
return rec return rec
} }
+1 -1
View File
@@ -19,7 +19,7 @@ func (in *uuidInput) id() uuid.UUID {
return uuid.MustParse(in.UUID) return uuid.MustParse(in.UUID)
} }
func (s *Server) registerLibrary(a apis) { func (s *Server) registerLibrary(a huma.API) {
l := s.library l := s.library
op := func(id, path, summary, description string, errs ...int) huma.Operation { op := func(id, path, summary, description string, errs ...int) huma.Operation {
return huma.Operation{ return huma.Operation{
+3 -4
View File
@@ -55,7 +55,6 @@ func TestLibraryEndpointErrors(t *testing.T) {
func TestLibraryEndpointsRequireToken(t *testing.T) { func TestLibraryEndpointsRequireToken(t *testing.T) {
e := newTestEnv(t, withAuth("secret")) e := newTestEnv(t, withAuth("secret"))
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/soundtracks").Code) 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) { func TestSongFileLockedDuringSync(t *testing.T) {
@@ -72,11 +71,11 @@ func TestSongFileLockedDuringSync(t *testing.T) {
func TestQueueResponsesIncludeUUIDs(t *testing.T) { func TestQueueResponsesIncludeUUIDs(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
song := e.addSong("Game A", "Song", "x") 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, "/api/v1/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/addQue").Code)
var info map[string]any 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.Uuid.String(), info["SongUuid"])
assert.Equal(t, song.SoundtrackUuid.String(), info["SoundtrackUuid"]) assert.Equal(t, song.SoundtrackUuid.String(), info["SoundtrackUuid"])
-10
View File
@@ -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)
}
+38
View File
@@ -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))
}
}
+9 -9
View File
@@ -22,7 +22,7 @@ type healthOutput struct {
Body map[string]string 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 { get := func(id, path, tag, summary, description string, errs ...int) huma.Operation {
return huma.Operation{ return huma.Operation{
OperationID: id, OperationID: id,
@@ -37,30 +37,30 @@ func (s *Server) registerMisc(a apis) {
register(s, a, route{op: get("get-health", "/health", "server", "Health check", 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."), "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) { func(ctx context.Context, _ *struct{}) (*healthOutput, error) {
return &healthOutput{Body: s.db.Health(ctx)}, nil return &healthOutput{Body: s.db.Health(ctx)}, nil
}) })
register(s, a, route{op: get("get-version", "/version", "server", "Latest server version", 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) { func(ctx context.Context, _ *struct{}) (*jsonBody[backend.VersionData], error) {
return ok(backend.GetLatestVersion(), nil) return ok(backend.GetLatestVersion(), nil)
}) })
register(s, a, route{op: get("get-version-history", "/version/history", "server", "Version history", 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) { func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.VersionData], error) {
return ok(backend.GetVersionHistory(), nil) return ok(backend.GetVersionHistory(), nil)
}) })
register(s, a, route{op: get("list-characters", "/character/list", "characters", "List characters", 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) { func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
return ok(s.characters.List()) return ok(s.characters.List())
}) })
image := get("get-character", "/character", "characters", "Character image", image := get("get-character", "/character", "characters", "Character image",
"Returns a character image.", http.StatusBadRequest, http.StatusNotFound) "Returns a character image.", http.StatusBadRequest, http.StatusNotFound)
image.Responses = fileResponses("The image", "image/png", "image/jpeg") 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) { func(ctx context.Context, in *characterInput) (*huma.StreamResponse, error) {
f, err := s.characters.Open(in.Name) f, err := s.characters.Open(in.Name)
if err != nil { if err != nil {
@@ -71,12 +71,12 @@ func (s *Server) registerMisc(a apis) {
r := s.releases r := s.releases
register(s, a, route{op: get("get-latest-client-version", "/download", "download", "Latest client version", 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) { func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
return ok(r.LatestVersion(ctx)) return ok(r.LatestVersion(ctx))
}) })
register(s, a, route{op: get("list-client-assets", "/download/list", "download", "Latest client assets", 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) { func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
return ok(r.AssetNames(ctx)) 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+".", "Redirects to the download URL of the latest MusicPlayer release for "+platform+".",
http.StatusNotFound, http.StatusBadGateway) http.StatusNotFound, http.StatusBadGateway)
op.DefaultStatus = http.StatusFound 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) { func(ctx context.Context, _ *struct{}) (*redirectOutput, error) {
url, err := r.AssetURL(ctx, suffix) url, err := r.AssetURL(ctx, suffix)
if err != nil { if err != nil {
+53 -36
View File
@@ -6,6 +6,7 @@ import (
"music-server/internal/backend" "music-server/internal/backend"
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/server/middleware"
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
"go.uber.org/zap" "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)"` 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" const audioMPEG = "audio/mpeg"
func (s *Server) registerMusic(a apis) { func (s *Server) registerMusic(a huma.API) {
m := s.music m := s.music
audio := func(id, path, summary, description string, errs ...int) route { audio := func(id, path, summary, description string, errs ...int) route {
return route{ return route{
@@ -31,8 +37,7 @@ func (s *Server) registerMusic(a apis) {
Responses: fileResponses("The song (MP3)", audioMPEG), Responses: fileResponses("The song (MP3)", audioMPEG),
Errors: errs, Errors: errs,
}, },
legacyPath: path, locked: true,
locked: true,
} }
} }
status := func(id, method, path, summary, description string) route { status := func(id, method, path, summary, description string) route {
@@ -46,8 +51,8 @@ func (s *Server) registerMusic(a apis) {
Tags: []string{"music"}, Tags: []string{"music"},
DefaultStatus: http.StatusOK, DefaultStatus: http.StatusOK,
}, },
legacyPath: path, locked: true,
locked: true, queue: true,
} }
} }
list := func(id, path, summary, description string, locked bool) route { list := func(id, path, summary, description string, locked bool) route {
@@ -60,54 +65,59 @@ func (s *Server) registerMusic(a apis) {
Description: description, Description: description,
Tags: []string{"music"}, 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", 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), "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) { 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", register(s, a, audio("get-sound-test-song", "/music/soundTest", "Sound check song",
"Returns a random song for checking the volume.", http.StatusNotFound), "Returns a random song for checking the volume.", http.StatusNotFound),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.SoundCheckSong()) return streamSong(m.SoundCheckSong())
}) })
register(s, a, audio("get-random-song", "/music/rand", "Random song", 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), "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) { 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", 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), "Picks a random song, favouring soundtracks that have been played less than average.", http.StatusNotFound)),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { 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", 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), "Picks a random song where every song has the same chance, regardless of soundtrack.", http.StatusNotFound)),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { 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", 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), "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) { 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", 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), "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) { 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) { 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) { 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) { randomNames := func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
return ok(m.SoundtrackNamesRandom(ctx)) 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", register(s, a, status("reset-queue", http.MethodGet, "/music/reset", "Reset the queue",
"Clears the queue and reloads the soundtrack list."), "Ends the queue, starts a new one and reloads the soundtrack list. "+
func(ctx context.Context, _ *struct{}) (*noBody, error) { "The new queue's ID is returned in the X-Queue-Id header; send it with the following requests."),
if err := m.Reset(ctx); err != nil { func(ctx context.Context, _ *struct{}) (*resetOutput, error) {
id, err := m.NewQueue(ctx, queue(ctx))
if err != nil {
return nil, apiError(err) 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", 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."), "Adds the song returned by the last random song request to the end of the queue."),
func(ctx context.Context, _ *struct{}) (*noBody, error) { 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 return &noBody{}, nil
}) })
register(s, a, status("mark-current-played", http.MethodGet, "/music/addPlayed", "Mark the current song as played", 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."), "Increments the play counters of the current song and its soundtrack."),
func(ctx context.Context, _ *struct{}) (*noBody, error) { 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 nil, apiError(err)
} }
return &noBody{}, nil 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."), "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) { func(ctx context.Context, in *songNumberInput) (*noBody, error) {
logging.GetLogger().Info("Marking song as played", zap.Int("song_no", in.Song)) 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 nil, apiError(err)
} }
return &noBody{}, nil return &noBody{}, nil
+1 -1
View File
@@ -118,7 +118,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
"POST /api/v1/token", "POST /api/v1/token",
"POST /api/v1/token/cleanup", "POST /api/v1/token/cleanup",
"PUT /api/v1/music/played", "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, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type)
assert.Equal(t, "bearer", spec.Components.SecuritySchemes[bearerAuthName].Value.Scheme) assert.Equal(t, "bearer", spec.Components.SecuritySchemes[bearerAuthName].Value.Scheme)
+7 -7
View File
@@ -24,10 +24,10 @@ func (s *Server) RegisterRoutes() http.Handler {
// the handlers are registered but never called. // the handlers are registered but never called.
func OpenAPISpec() *huma.OpenAPI { func OpenAPISpec() *huma.OpenAPI {
_, a := (&Server{}).routes() _, 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 := echo.New()
e.HTTPErrorHandler = httpErrorHandler e.HTTPErrorHandler = httpErrorHandler
if len(s.cfg.TrustedProxies) > 0 { if len(s.cfg.TrustedProxies) > 0 {
@@ -43,15 +43,15 @@ func (s *Server) routes() (*echo.Echo, apis) {
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
AllowOrigins: []string{"https://*", "http://*"}, AllowOrigins: []string{"https://*", "http://*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, 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, AllowCredentials: true,
MaxAge: 300, MaxAge: 300,
})) }))
// The API: /api/v1 is documented at /docs and /openapi.json; legacy routes are hidden. // The API: /api/v1 is documented at /docs and /openapi.json.
a := newAPIs(e) a := newAPI(e)
a.v1.UseMiddleware(middleware.ClientInfo, middleware.TokenAuth(a.v1, s.q, s.cfg.DisableSessionAuth)) a.UseMiddleware(middleware.ClientInfo, middleware.TokenAuth(a, s.q, s.cfg.DisableSessionAuth))
a.legacy.UseMiddleware(middleware.ClientInfo, middleware.Deprecation)
s.registerToken(a) s.registerToken(a)
s.registerMisc(a) s.registerMisc(a)
s.registerSync(a) s.registerSync(a)
+29 -46
View File
@@ -28,64 +28,56 @@ func (e *testEnv) blockSync() (release func()) {
func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) { func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.addSong("Game A", "Song", "a") e.addSong("Game A", "Song", "a")
for _, prefix := range []string{"", "/api/v1"} { for _, path := range []string{"/api/v1/sync", "/api/v1/sync/full", "/api/v1/sync/reset"} {
for _, path := range []string{"/sync", "/sync/full", "/sync/reset"} { // GET falls through to the frontend's static files, which answer 404.
// 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.Equal(t, http.StatusNotFound, e.do(http.MethodGet, prefix+path).Code, prefix+path)
}
} }
assert.False(t, e.server.sync.IsSyncing(), "no sync was started") assert.False(t, e.server.sync.IsSyncing(), "no sync was started")
assert.Len(t, e.store.Songs, 1, "nothing was deleted") assert.Len(t, e.store.Songs, 1, "nothing was deleted")
} }
func TestSyncStartAndLock(t *testing.T) { func TestSyncStartAndLock(t *testing.T) {
for _, prefix := range []string{"", "/api/v1"} { e := newTestEnv(t)
t.Run("prefix="+prefix, func(t *testing.T) { e.addSong("Game A", "Song", "a")
e := newTestEnv(t) release := e.blockSync()
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.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code)
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/full").Code) assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, "/api/v1/sync/full").Code)
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/reset").Code) assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, "/api/v1/sync/reset").Code)
assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, prefix+"/music/rand").Code) assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, "/api/v1/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.MethodGet, "/api/v1/music/info").Code, "info is available during sync")
progress := e.do(http.MethodGet, prefix+"/sync/progress") progress := e.do(http.MethodGet, "/api/v1/sync/progress")
assert.Equal(t, http.StatusOK, progress.Code) assert.Equal(t, http.StatusOK, progress.Code)
assert.Contains(t, progress.Body.String(), `"progress"`) assert.Contains(t, progress.Body.String(), `"progress"`)
release() release()
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/all").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/all").Code)
result := e.do(http.MethodGet, prefix+"/sync/progress") result := e.do(http.MethodGet, "/api/v1/sync/progress")
assert.Contains(t, result.Body.String(), `"total_time"`) assert.Contains(t, result.Body.String(), `"total_time"`)
})
}
} }
func TestSyncResetEndpoint(t *testing.T) { func TestSyncResetEndpoint(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
e.addSong("Game A", "Song", "a") 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.Soundtracks)
assert.Empty(t, e.store.Songs) assert.Empty(t, e.store.Songs)
} }
func TestLegacyRoutesAreDeprecated(t *testing.T) { func TestLegacyRoutesAreRemoved(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
rec := e.do(http.MethodGet, "/version") for _, path := range []string{"/version", "/health", "/music/list", "/music/rand", "/characters", "/sync/progress"} {
assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
assert.Equal(t, "true", rec.Header().Get("Deprecation")) }
assert.Equal(t, http.StatusMethodNotAllowed, e.do(http.MethodPost, "/sync").Code)
rec = e.do(http.MethodGet, "/api/v1/version") assert.False(t, e.server.sync.IsSyncing())
assert.Equal(t, http.StatusOK, rec.Code)
assert.Empty(t, rec.Header().Get("Deprecation"))
} }
func TestVersionEndpoints(t *testing.T) { func TestVersionEndpoints(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
var latest backend.VersionData 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) assert.Equal(t, backend.GetLatestVersion(), latest)
var history []backend.VersionData var history []backend.VersionData
@@ -95,28 +87,19 @@ func TestVersionEndpoints(t *testing.T) {
func TestHealthEndpoint(t *testing.T) { func TestHealthEndpoint(t *testing.T) {
e := newTestEnv(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.Equal(t, http.StatusOK, rec.Code)
assert.JSONEq(t, `{"status":"up"}`, rec.Body.String()) assert.JSONEq(t, `{"status":"up"}`, rec.Body.String())
} }
func TestSyncAliasesAreRemoved(t *testing.T) { func TestSyncAliasesAreRemoved(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
for _, prefix := range []string{"", "/api/v1"} { for _, path := range []string{"/api/v1/sync/new", "/api/v1/sync/quick", "/api/v1/sync/new/full"} {
for _, path := range []string{"/sync/new", "/sync/quick", "/sync/new/full"} { assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, path).Code, path)
assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, prefix+path).Code, prefix+path)
}
} }
assert.False(t, e.server.sync.IsSyncing()) 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) { func TestDocsAndSpecLinks(t *testing.T) {
e := newTestEnv(t) e := newTestEnv(t)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/docs").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/docs").Code)
+5 -2
View File
@@ -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. // afterSync refreshes cached data once a sync has finished.
func (s *Server) afterSync(ctx context.Context) { func (s *Server) afterSync(ctx context.Context) {
if err := s.music.Reset(ctx); err != nil { if err := s.music.EndAllQueues(ctx); err != nil {
logging.GetLogger().Error("Failed to reset music after sync", zap.Error(err)) 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 { if err := s.search.Reload(ctx); err != nil {
logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err)) logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err))
+1 -1
View File
@@ -19,7 +19,7 @@ func (in *limitInput) capped() int32 {
return int32(min(in.Limit, maxStatsLimit)) return int32(min(in.Limit, maxStatsLimit))
} }
func (s *Server) registerStatistics(a apis) { func (s *Server) registerStatistics(a huma.API) {
st := s.stats st := s.stats
op := func(id, path, summary, description string) route { op := func(id, path, summary, description string) route {
return route{op: huma.Operation{ return route{op: huma.Operation{
+4 -5
View File
@@ -11,8 +11,8 @@ import (
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
) )
func (s *Server) registerSync(a apis) { func (s *Server) registerSync(a huma.API) {
registry := a.v1.OpenAPI().Components.Schemas registry := a.OpenAPI().Components.Schemas
progress := huma.Operation{ progress := huma.Operation{
OperationID: "get-sync-progress", OperationID: "get-sync-progress",
Method: http.MethodGet, 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) { func(ctx context.Context, _ *struct{}) (*jsonBody[any], error) {
if s.sync.IsSyncing() { if s.sync.IsSyncing() {
return ok[any](s.sync.Progress(), nil) return ok[any](s.sync.Progress(), nil)
@@ -49,7 +49,6 @@ func (s *Server) registerSync(a apis) {
Tags: []string{"sync"}, Tags: []string{"sync"},
Errors: []int{http.StatusLocked}, Errors: []int{http.StatusLocked},
}, },
legacyPath: path,
}, func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) { }, func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
message := "Start syncing soundtracks" message := "Start syncing soundtracks"
if full { if full {
@@ -78,7 +77,7 @@ func (s *Server) registerSync(a apis) {
Tags: []string{"sync"}, Tags: []string{"sync"},
Hidden: true, 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) { func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
logging.GetLogger().Info("Resetting soundtracks database") logging.GetLogger().Info("Resetting soundtracks database")
return ok("Soundtracks and songs are deleted from the database", s.sync.ResetDB(ctx)) return ok("Soundtracks and songs are deleted from the database", s.sync.ResetDB(ctx))
+2 -2
View File
@@ -55,7 +55,7 @@ func (s *Server) passwordValid(password string) bool {
return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.APIPassword)) == 1 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{ create := huma.Operation{
OperationID: "create-token", OperationID: "create-token",
Method: http.MethodPost, 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.", "Requires the API password when the server has one. Limited to 10 requests per minute per client.",
Tags: []string{"auth"}, Tags: []string{"auth"},
Errors: []int{http.StatusUnauthorized, http.StatusTooManyRequests}, 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}, register(s, a, route{op: create, public: true},
func(ctx context.Context, in *tokenInput) (*jsonBody[TokenResponse], error) { func(ctx context.Context, in *tokenInput) (*jsonBody[TokenResponse], error) {
+119
View File
@@ -13,6 +13,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
) )
// Store is an in-memory implementation of the queries used by the application. // Store is an in-memory implementation of the queries used by the application.
@@ -25,6 +26,8 @@ type Store struct {
Soundtracks []repository.Soundtrack Soundtracks []repository.Soundtrack
Songs []repository.Song Songs []repository.Song
Sessions map[string]repository.Session Sessions map[string]repository.Session
Queues []repository.Queue
QueueSongs []repository.QueueSong
// Err, when set, is returned by every implemented query. // Err, when set, is returned by every implemented query.
Err error Err error
@@ -373,6 +376,122 @@ func (s *Store) HasSession(token string) bool {
return ok 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. // Statistics queries return empty results; tests that need data use their own fakes.
func (s *Store) GetMostPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) { func (s *Store) GetMostPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) {