Store each client's play queue in the database

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

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

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

No files matched your search

+3 -1
View File
@@ -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())
}
@@ -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 '';
+51
View File
@@ -0,0 +1,51 @@
-- name: CreateQueue :one
INSERT INTO queue DEFAULT VALUES
RETURNING *;
-- name: GetQueue :one
SELECT *
FROM queue
WHERE uuid = $1;
-- name: EndQueue :exec
UPDATE queue SET ended_at = NOW()
WHERE uuid = $1 AND ended_at IS NULL;
-- name: EndActiveQueues :exec
UPDATE queue SET ended_at = NOW()
WHERE ended_at IS NULL;
-- name: SetQueueLastFetched :exec
UPDATE queue SET last_fetched_song_uuid = $2
WHERE uuid = $1;
-- name: SetQueueCurrentSong :exec
UPDATE queue SET current_song = $2
WHERE uuid = $1;
-- name: AddLastFetchedToQueue :exec
-- Appends the queue's last fetched song and makes it the current song.
-- Does nothing when no song has been fetched since the last call.
WITH added AS (
INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name)
SELECT q.uuid,
(SELECT COALESCE(MAX(qs.song_no), -1) + 1 FROM queue_song qs WHERE qs.queue_uuid = q.uuid),
s.uuid, st.soundtrack_name, s.song_name
FROM queue q
JOIN song s ON s.uuid = q.last_fetched_song_uuid
JOIN soundtrack st ON st.id = s.soundtrack_id
WHERE q.uuid = $1
RETURNING queue_uuid, song_no
)
UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL
FROM added
WHERE queue.uuid = added.queue_uuid;
-- name: ListQueueSongs :many
-- The song columns are NULL when the song has been removed from the library since.
SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name,
s.path, s.times_played, s.soundtrack_uuid
FROM queue_song qs
LEFT JOIN song s ON s.uuid = qs.song_uuid
WHERE qs.queue_uuid = $1
ORDER BY qs.song_no;
+17
View File
@@ -11,6 +11,23 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
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"`
+11
View File
@@ -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
+171
View File
@@ -0,0 +1,171 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: queue.sql
package repository
import (
"context"
"github.com/google/uuid"
)
const addLastFetchedToQueue = `-- name: AddLastFetchedToQueue :exec
WITH added AS (
INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name)
SELECT q.uuid,
(SELECT COALESCE(MAX(qs.song_no), -1) + 1 FROM queue_song qs WHERE qs.queue_uuid = q.uuid),
s.uuid, st.soundtrack_name, s.song_name
FROM queue q
JOIN song s ON s.uuid = q.last_fetched_song_uuid
JOIN soundtrack st ON st.id = s.soundtrack_id
WHERE q.uuid = $1
RETURNING queue_uuid, song_no
)
UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL
FROM added
WHERE queue.uuid = added.queue_uuid
`
// Appends the queue's last fetched song and makes it the current song.
// Does nothing when no song has been fetched since the last call.
func (q *Queries) AddLastFetchedToQueue(ctx context.Context, argUuid uuid.UUID) error {
_, err := q.db.Exec(ctx, addLastFetchedToQueue, argUuid)
return err
}
const createQueue = `-- name: CreateQueue :one
INSERT INTO queue DEFAULT VALUES
RETURNING uuid, current_song, last_fetched_song_uuid, started_at, ended_at
`
func (q *Queries) CreateQueue(ctx context.Context) (Queue, error) {
row := q.db.QueryRow(ctx, createQueue)
var i Queue
err := row.Scan(
&i.Uuid,
&i.CurrentSong,
&i.LastFetchedSongUuid,
&i.StartedAt,
&i.EndedAt,
)
return i, err
}
const endActiveQueues = `-- name: EndActiveQueues :exec
UPDATE queue SET ended_at = NOW()
WHERE ended_at IS NULL
`
func (q *Queries) EndActiveQueues(ctx context.Context) error {
_, err := q.db.Exec(ctx, endActiveQueues)
return err
}
const endQueue = `-- name: EndQueue :exec
UPDATE queue SET ended_at = NOW()
WHERE uuid = $1 AND ended_at IS NULL
`
func (q *Queries) EndQueue(ctx context.Context, argUuid uuid.UUID) error {
_, err := q.db.Exec(ctx, endQueue, argUuid)
return err
}
const getQueue = `-- name: GetQueue :one
SELECT uuid, current_song, last_fetched_song_uuid, started_at, ended_at
FROM queue
WHERE uuid = $1
`
func (q *Queries) GetQueue(ctx context.Context, argUuid uuid.UUID) (Queue, error) {
row := q.db.QueryRow(ctx, getQueue, argUuid)
var i Queue
err := row.Scan(
&i.Uuid,
&i.CurrentSong,
&i.LastFetchedSongUuid,
&i.StartedAt,
&i.EndedAt,
)
return i, err
}
const listQueueSongs = `-- name: ListQueueSongs :many
SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name,
s.path, s.times_played, s.soundtrack_uuid
FROM queue_song qs
LEFT JOIN song s ON s.uuid = qs.song_uuid
WHERE qs.queue_uuid = $1
ORDER BY qs.song_no
`
type ListQueueSongsRow struct {
SongNo int32 `json:"song_no"`
SongUuid uuid.UUID `json:"song_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
Path *string `json:"path"`
TimesPlayed *int32 `json:"times_played"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
}
// The song columns are NULL when the song has been removed from the library since.
func (q *Queries) ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]ListQueueSongsRow, error) {
rows, err := q.db.Query(ctx, listQueueSongs, queueUuid)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListQueueSongsRow
for rows.Next() {
var i ListQueueSongsRow
if err := rows.Scan(
&i.SongNo,
&i.SongUuid,
&i.SoundtrackName,
&i.SongName,
&i.Path,
&i.TimesPlayed,
&i.SoundtrackUuid,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setQueueCurrentSong = `-- name: SetQueueCurrentSong :exec
UPDATE queue SET current_song = $2
WHERE uuid = $1
`
type SetQueueCurrentSongParams struct {
Uuid uuid.UUID `json:"uuid"`
CurrentSong int32 `json:"current_song"`
}
func (q *Queries) SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSongParams) error {
_, err := q.db.Exec(ctx, setQueueCurrentSong, arg.Uuid, arg.CurrentSong)
return err
}
const setQueueLastFetched = `-- name: SetQueueLastFetched :exec
UPDATE queue SET last_fetched_song_uuid = $2
WHERE uuid = $1
`
type SetQueueLastFetchedParams struct {
Uuid uuid.UUID `json:"uuid"`
LastFetchedSongUuid uuid.UUID `json:"last_fetched_song_uuid"`
}
func (q *Queries) SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error {
_, err := q.db.Exec(ctx, setQueueLastFetched, arg.Uuid, arg.LastFetchedSongUuid)
return err
}