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