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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user