Prefer UUIDs in the API (Phase 3 of the UUID migration)
CI / docker (push) Successful in 46s
CI / integration (push) Successful in 23s
CI / test (push) Successful in 30s
CI / publish (push) Skipped

- New /api/v1 endpoints: GET /soundtracks, /soundtracks/{uuid},
  /songs/{uuid} and /songs/{uuid}/file (streams the song, Range support,
  locked during sync)
- UUIDs added to every response with soundtracks or songs: queue info and
  list (SoundtrackUuid, SongUuid) and statistics (soundtrack_uuid,
  song_uuid). Existing fields stay for backward compatibility
- Play counts and broken-song removal use song UUIDs instead of
  soundtrack ID + song name, so songs with the same name are counted
  separately
- Statistics return an empty song list for soundtracks without songs
  instead of a single empty entry
- The legacy .id file cleanup stays until Phase 4 (#31)

Closes #29
This commit is contained in:
Sansan committed 2026-10-06 15:54:41 +02:00
1 parent 8c974a49cc
commit 2c3e97190d
25 files changed
+1521 -117

No files matched your search

+242
View File
@@ -23,6 +23,164 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/api/v1/songs/{uuid}": {
"get": {
"description": "Returns information about a song",
"produces": [
"application/json"
],
"tags": [
"library"
],
"summary": "Get song",
"parameters": [
{
"type": "string",
"description": "Song UUID",
"name": "uuid",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/backend.Song"
}
},
"400": {
"description": "invalid uuid",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/songs/{uuid}/file": {
"get": {
"description": "Returns the audio file of a song. Supports Range requests.",
"produces": [
"audio/mpeg"
],
"tags": [
"library"
],
"summary": "Stream song",
"parameters": [
{
"type": "string",
"description": "Song UUID",
"name": "uuid",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"206": {
"description": "Partial Content",
"schema": {
"type": "file"
}
},
"400": {
"description": "invalid uuid",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
},
"423": {
"description": "Syncing is in progress",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/soundtracks": {
"get": {
"description": "Returns all soundtracks with their UUIDs, sorted by name",
"produces": [
"application/json"
],
"tags": [
"library"
],
"summary": "List soundtracks",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.Soundtrack"
}
}
}
}
}
},
"/api/v1/soundtracks/{uuid}": {
"get": {
"description": "Returns a soundtrack with its songs",
"produces": [
"application/json"
],
"tags": [
"library"
],
"summary": "Get soundtrack",
"parameters": [
{
"type": "string",
"description": "Soundtrack UUID",
"name": "uuid",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/backend.SoundtrackWithSongList"
}
},
"400": {
"description": "invalid uuid",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/statistics/songs/least-played": {
"get": {
"description": "Returns the top N least played songs with their soundtrack info",
@@ -1331,6 +1489,29 @@ const docTemplate = `{
}
},
"definitions": {
"backend.Song": {
"type": "object",
"properties": {
"file_name": {
"type": "string"
},
"name": {
"type": "string"
},
"soundtrack_name": {
"type": "string"
},
"soundtrack_uuid": {
"type": "string"
},
"times_played": {
"type": "integer"
},
"uuid": {
"type": "string"
}
}
},
"backend.SongInfo": {
"type": "object",
"properties": {
@@ -1346,11 +1527,17 @@ const docTemplate = `{
"SongPlayed": {
"type": "integer"
},
"SongUuid": {
"type": "string"
},
"Soundtrack": {
"type": "string"
},
"SoundtrackPlayed": {
"type": "integer"
},
"SoundtrackUuid": {
"type": "string"
}
}
},
@@ -1366,17 +1553,69 @@ const docTemplate = `{
"song_name": {
"type": "string"
},
"song_uuid": {
"type": "string"
},
"soundtrack_id": {
"type": "integer"
},
"soundtrack_name": {
"type": "string"
},
"soundtrack_uuid": {
"type": "string"
},
"times_played": {
"type": "integer"
}
}
},
"backend.Soundtrack": {
"type": "object",
"properties": {
"added": {
"type": "string"
},
"last_played": {
"type": "string"
},
"name": {
"type": "string"
},
"times_played": {
"type": "integer"
},
"uuid": {
"type": "string"
}
}
},
"backend.SoundtrackWithSongList": {
"type": "object",
"properties": {
"added": {
"type": "string"
},
"last_played": {
"type": "string"
},
"name": {
"type": "string"
},
"songs": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.Song"
}
},
"times_played": {
"type": "integer"
},
"uuid": {
"type": "string"
}
}
},
"backend.SoundtrackWithSongs": {
"type": "object",
"properties": {
@@ -1397,6 +1636,9 @@ const docTemplate = `{
},
"soundtrack_played": {
"type": "integer"
},
"soundtrack_uuid": {
"type": "string"
}
}
},
+242
View File
@@ -16,6 +16,164 @@
},
"basePath": "/",
"paths": {
"/api/v1/songs/{uuid}": {
"get": {
"description": "Returns information about a song",
"produces": [
"application/json"
],
"tags": [
"library"
],
"summary": "Get song",
"parameters": [
{
"type": "string",
"description": "Song UUID",
"name": "uuid",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/backend.Song"
}
},
"400": {
"description": "invalid uuid",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/songs/{uuid}/file": {
"get": {
"description": "Returns the audio file of a song. Supports Range requests.",
"produces": [
"audio/mpeg"
],
"tags": [
"library"
],
"summary": "Stream song",
"parameters": [
{
"type": "string",
"description": "Song UUID",
"name": "uuid",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"206": {
"description": "Partial Content",
"schema": {
"type": "file"
}
},
"400": {
"description": "invalid uuid",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
},
"423": {
"description": "Syncing is in progress",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/soundtracks": {
"get": {
"description": "Returns all soundtracks with their UUIDs, sorted by name",
"produces": [
"application/json"
],
"tags": [
"library"
],
"summary": "List soundtracks",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.Soundtrack"
}
}
}
}
}
},
"/api/v1/soundtracks/{uuid}": {
"get": {
"description": "Returns a soundtrack with its songs",
"produces": [
"application/json"
],
"tags": [
"library"
],
"summary": "Get soundtrack",
"parameters": [
{
"type": "string",
"description": "Soundtrack UUID",
"name": "uuid",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/backend.SoundtrackWithSongList"
}
},
"400": {
"description": "invalid uuid",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/statistics/songs/least-played": {
"get": {
"description": "Returns the top N least played songs with their soundtrack info",
@@ -1324,6 +1482,29 @@
}
},
"definitions": {
"backend.Song": {
"type": "object",
"properties": {
"file_name": {
"type": "string"
},
"name": {
"type": "string"
},
"soundtrack_name": {
"type": "string"
},
"soundtrack_uuid": {
"type": "string"
},
"times_played": {
"type": "integer"
},
"uuid": {
"type": "string"
}
}
},
"backend.SongInfo": {
"type": "object",
"properties": {
@@ -1339,11 +1520,17 @@
"SongPlayed": {
"type": "integer"
},
"SongUuid": {
"type": "string"
},
"Soundtrack": {
"type": "string"
},
"SoundtrackPlayed": {
"type": "integer"
},
"SoundtrackUuid": {
"type": "string"
}
}
},
@@ -1359,17 +1546,69 @@
"song_name": {
"type": "string"
},
"song_uuid": {
"type": "string"
},
"soundtrack_id": {
"type": "integer"
},
"soundtrack_name": {
"type": "string"
},
"soundtrack_uuid": {
"type": "string"
},
"times_played": {
"type": "integer"
}
}
},
"backend.Soundtrack": {
"type": "object",
"properties": {
"added": {
"type": "string"
},
"last_played": {
"type": "string"
},
"name": {
"type": "string"
},
"times_played": {
"type": "integer"
},
"uuid": {
"type": "string"
}
}
},
"backend.SoundtrackWithSongList": {
"type": "object",
"properties": {
"added": {
"type": "string"
},
"last_played": {
"type": "string"
},
"name": {
"type": "string"
},
"songs": {
"type": "array",
"items": {
"$ref": "#/definitions/backend.Song"
}
},
"times_played": {
"type": "integer"
},
"uuid": {
"type": "string"
}
}
},
"backend.SoundtrackWithSongs": {
"type": "object",
"properties": {
@@ -1390,6 +1629,9 @@
},
"soundtrack_played": {
"type": "integer"
},
"soundtrack_uuid": {
"type": "string"
}
}
},
+159
View File
@@ -1,5 +1,20 @@
basePath: /
definitions:
backend.Song:
properties:
file_name:
type: string
name:
type: string
soundtrack_name:
type: string
soundtrack_uuid:
type: string
times_played:
type: integer
uuid:
type: string
type: object
backend.SongInfo:
properties:
CurrentlyPlaying:
@@ -10,10 +25,14 @@ definitions:
type: integer
SongPlayed:
type: integer
SongUuid:
type: string
Soundtrack:
type: string
SoundtrackPlayed:
type: integer
SoundtrackUuid:
type: string
type: object
backend.SongInfoForStats:
properties:
@@ -23,13 +42,47 @@ definitions:
type: string
song_name:
type: string
song_uuid:
type: string
soundtrack_id:
type: integer
soundtrack_name:
type: string
soundtrack_uuid:
type: string
times_played:
type: integer
type: object
backend.Soundtrack:
properties:
added:
type: string
last_played:
type: string
name:
type: string
times_played:
type: integer
uuid:
type: string
type: object
backend.SoundtrackWithSongList:
properties:
added:
type: string
last_played:
type: string
name:
type: string
songs:
items:
$ref: '#/definitions/backend.Song'
type: array
times_played:
type: integer
uuid:
type: string
type: object
backend.SoundtrackWithSongs:
properties:
songs:
@@ -44,6 +97,8 @@ definitions:
type: string
soundtrack_played:
type: integer
soundtrack_uuid:
type: string
type: object
backend.StatisticsSummary:
properties:
@@ -134,6 +189,110 @@ info:
title: MusicServer API
version: "1.0"
paths:
/api/v1/songs/{uuid}:
get:
description: Returns information about a song
parameters:
- description: Song UUID
in: path
name: uuid
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/backend.Song'
"400":
description: invalid uuid
schema:
type: string
"404":
description: not found
schema:
type: string
summary: Get song
tags:
- library
/api/v1/songs/{uuid}/file:
get:
description: Returns the audio file of a song. Supports Range requests.
parameters:
- description: Song UUID
in: path
name: uuid
required: true
type: string
produces:
- audio/mpeg
responses:
"200":
description: OK
schema:
type: file
"206":
description: Partial Content
schema:
type: file
"400":
description: invalid uuid
schema:
type: string
"404":
description: not found
schema:
type: string
"423":
description: Syncing is in progress
schema:
type: string
summary: Stream song
tags:
- library
/api/v1/soundtracks:
get:
description: Returns all soundtracks with their UUIDs, sorted by name
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/backend.Soundtrack'
type: array
summary: List soundtracks
tags:
- library
/api/v1/soundtracks/{uuid}:
get:
description: Returns a soundtrack with its songs
parameters:
- description: Soundtrack UUID
in: path
name: uuid
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/backend.SoundtrackWithSongList'
"400":
description: invalid uuid
schema:
type: string
"404":
description: not found
schema:
type: string
summary: Get soundtrack
tags:
- library
/api/v1/statistics/songs/least-played:
get:
description: Returns the top N least played songs with their soundtrack info
+126
View File
@@ -0,0 +1,126 @@
package backend
import (
"context"
"errors"
"time"
"music-server/internal/db/repository"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Soundtrack is a soundtrack as returned by the API.
type Soundtrack struct {
Uuid uuid.UUID `json:"uuid"`
Name string `json:"name"`
TimesPlayed int32 `json:"times_played"`
LastPlayed *time.Time `json:"last_played,omitempty"`
Added time.Time `json:"added"`
}
// SoundtrackWithSongList is a soundtrack with all its songs.
type SoundtrackWithSongList struct {
Soundtrack
Songs []Song `json:"songs"`
}
// Song is a song as returned by the API.
type Song struct {
Uuid uuid.UUID `json:"uuid"`
Name string `json:"name"`
FileName *string `json:"file_name,omitempty"`
TimesPlayed int32 `json:"times_played"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"`
}
// LibraryService looks up soundtracks and songs by UUID.
type LibraryService struct {
q repository.Querier
}
// NewLibraryService creates a LibraryService.
func NewLibraryService(q repository.Querier) *LibraryService {
return &LibraryService{q: q}
}
func toSoundtrack(s repository.Soundtrack) Soundtrack {
return Soundtrack{
Uuid: s.Uuid,
Name: s.SoundtrackName,
TimesPlayed: s.TimesPlayed,
LastPlayed: s.LastPlayed,
Added: s.Added,
}
}
func notFound(err error) error {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
// Soundtracks returns all active soundtracks, sorted by name.
func (l *LibraryService) Soundtracks(ctx context.Context) ([]Soundtrack, error) {
rows, err := l.q.FindAllSoundtracks(ctx)
if err != nil {
return nil, err
}
soundtracks := make([]Soundtrack, len(rows))
for i, row := range rows {
soundtracks[i] = toSoundtrack(row)
}
return soundtracks, nil
}
// Soundtrack returns an active soundtrack with its songs.
func (l *LibraryService) Soundtrack(ctx context.Context, id uuid.UUID) (SoundtrackWithSongList, error) {
row, err := l.q.GetSoundtrackByUUID(ctx, id)
if err != nil {
return SoundtrackWithSongList{}, notFound(err)
}
songRows, err := l.q.GetSongsBySoundtrackUUID(ctx, id)
if err != nil {
return SoundtrackWithSongList{}, err
}
songs := make([]Song, len(songRows))
for i, song := range songRows {
songs[i] = toSong(song, row.SoundtrackName)
}
return SoundtrackWithSongList{Soundtrack: toSoundtrack(row), Songs: songs}, nil
}
func toSong(s repository.Song, soundtrackName string) Song {
return Song{
Uuid: s.Uuid,
Name: s.SongName,
FileName: s.FileName,
TimesPlayed: s.TimesPlayed,
SoundtrackUuid: s.SoundtrackUuid,
SoundtrackName: soundtrackName,
}
}
// Song returns a song of an active soundtrack.
func (l *LibraryService) Song(ctx context.Context, id uuid.UUID) (Song, error) {
row, err := l.q.GetActiveSongByUUID(ctx, id)
if err != nil {
return Song{}, notFound(err)
}
return toSong(row.Song, row.SoundtrackName), nil
}
// SongPath returns the file path of a song of an active soundtrack, if the file can be played.
func (l *LibraryService) SongPath(ctx context.Context, id uuid.UUID) (string, error) {
row, err := l.q.GetActiveSongByUUID(ctx, id)
if err != nil {
return "", notFound(err)
}
if !songIsPlayable(row.Song, row.SoundtrackPath) {
return "", ErrNotFound
}
return row.Song.Path, nil
}
@@ -0,0 +1,81 @@
//go:build integration
package backend
import (
"context"
"os"
"path/filepath"
"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"
)
// TestLibraryAndStatisticsWithPostgres checks the UUID and statistics queries against a real database.
func TestLibraryAndStatisticsWithPostgres(t *testing.T) {
ctx := context.Background()
q := repository.New(pgtest.NewMigratedDatabase(t).Pool)
musicDir := copyTestMusic(t)
require.NoError(t, os.Mkdir(filepath.Join(musicDir, "Empty Soundtrack"), 0o755))
writeFile(t, filepath.Join(musicDir, "Empty Soundtrack", "cover.jpg"), "image")
s := NewSyncService(q, musicDir, nil)
require.True(t, s.Start(false))
s.Wait()
require.Empty(t, s.Result().CatchedErrors)
library := NewLibraryService(q)
soundtracks, err := library.Soundtracks(ctx)
require.NoError(t, err)
require.Len(t, soundtracks, 4)
bird, err := library.Soundtrack(ctx, soundtracks[0].Uuid)
require.NoError(t, err)
assert.Equal(t, "A Bird Story", bird.Name)
require.Len(t, bird.Songs, 6)
song := bird.Songs[0]
assert.Equal(t, bird.Uuid, song.SoundtrackUuid)
info, err := library.Song(ctx, song.Uuid)
require.NoError(t, err)
assert.Equal(t, "A Bird Story", info.SoundtrackName)
path, err := library.SongPath(ctx, song.Uuid)
require.NoError(t, err)
assert.FileExists(t, path)
_, err = library.Song(ctx, uuid.New())
assert.ErrorIs(t, err, ErrNotFound)
// Play counts by UUID.
require.NoError(t, q.AddSongPlayedByUUID(ctx, song.Uuid))
require.NoError(t, q.AddSoundtrackPlayedByUUID(ctx, bird.Uuid))
info, err = library.Song(ctx, song.Uuid)
require.NoError(t, err)
assert.Equal(t, int32(1), info.TimesPlayed)
stats := NewStatisticsService(q)
most, err := stats.MostPlayedSoundtracks(ctx, 10)
require.NoError(t, err)
require.NotEmpty(t, most)
assert.Equal(t, bird.Uuid, most[0].SoundtrackUuid)
assert.Len(t, most[0].Songs, 6)
assert.NotEqual(t, uuid.Nil, most[0].Songs[0].SongUuid)
songs, err := stats.MostPlayedSongs(ctx, 1)
require.NoError(t, err)
assert.Equal(t, song.Uuid, songs[0].SongUuid)
assert.Equal(t, bird.Uuid, songs[0].SoundtrackUuid)
never, err := stats.NeverPlayedSoundtracks(ctx)
require.NoError(t, err)
for _, st := range never {
assert.NotEqual(t, uuid.Nil, st.SoundtrackUuid)
if st.SoundtrackName == "Empty Soundtrack" {
assert.Empty(t, st.Songs, "a soundtrack without songs has an empty song list")
}
}
}
+82
View File
@@ -0,0 +1,82 @@
package backend
import (
"context"
"os"
"testing"
"time"
"music-server/internal/db/repository"
"music-server/internal/testutil/fakedb"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLibrarySoundtracks(t *testing.T) {
store := fakedb.New()
b := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "B Game", TimesPlayed: 3})
a := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "A Game"})
deleted := time.Now()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Gone", Deleted: &deleted})
soundtracks, err := NewLibraryService(store).Soundtracks(context.Background())
require.NoError(t, err)
require.Len(t, soundtracks, 2)
assert.Equal(t, Soundtrack{Uuid: a.Uuid, Name: "A Game"}, soundtracks[0])
assert.Equal(t, b.Uuid, soundtracks[1].Uuid)
assert.Equal(t, int32(3), soundtracks[1].TimesPlayed)
}
func TestLibrarySoundtrackWithSongs(t *testing.T) {
store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song B", "Song A")
l := NewLibraryService(store)
result, err := l.Soundtrack(context.Background(), st.Uuid)
require.NoError(t, err)
assert.Equal(t, "Game A", result.Name)
require.Len(t, result.Songs, 2)
assert.Equal(t, "Song A", result.Songs[0].Name)
assert.Equal(t, st.Uuid, result.Songs[0].SoundtrackUuid)
assert.Equal(t, "Game A", result.Songs[0].SoundtrackName)
_, err = l.Soundtrack(context.Background(), uuid.New())
assert.ErrorIs(t, err, ErrNotFound)
}
func TestLibrarySong(t *testing.T) {
store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song")
song := store.SongsOf(st.ID)[0]
l := NewLibraryService(store)
ctx := context.Background()
result, err := l.Song(ctx, song.Uuid)
require.NoError(t, err)
assert.Equal(t, Song{Uuid: song.Uuid, Name: "Song", FileName: song.FileName, SoundtrackUuid: st.Uuid, SoundtrackName: "Game A"}, result)
path, err := l.SongPath(ctx, song.Uuid)
require.NoError(t, err)
assert.Equal(t, song.Path, path)
require.NoError(t, os.Remove(song.Path))
_, err = l.SongPath(ctx, song.Uuid)
assert.ErrorIs(t, err, ErrNotFound, "missing files are not served")
_, err = l.Song(ctx, uuid.New())
assert.ErrorIs(t, err, ErrNotFound)
}
func TestLibraryHidesSongsOfDeletedSoundtracks(t *testing.T) {
store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song")
song := store.SongsOf(st.ID)[0]
require.NoError(t, store.MarkSoundtracksDeletedExcept(context.Background(), nil))
_, err := NewLibraryService(store).Song(context.Background(), song.Uuid)
assert.ErrorIs(t, err, ErrNotFound)
_, err = NewLibraryService(store).Soundtrack(context.Background(), st.Uuid)
assert.ErrorIs(t, err, ErrNotFound)
}
+17 -10
View File
@@ -13,6 +13,7 @@ import (
"music-server/internal/db/repository"
"music-server/internal/logging"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
)
@@ -21,12 +22,14 @@ import (
const maxPickAttempts = 100
type SongInfo struct {
Soundtrack string `json:"Soundtrack"`
SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
Song string `json:"Song"`
SongPlayed int32 `json:"SongPlayed"`
CurrentlyPlaying bool `json:"CurrentlyPlaying"`
SongNo int `json:"SongNo"`
SoundtrackUuid uuid.UUID `json:"SoundtrackUuid"`
Soundtrack string `json:"Soundtrack"`
SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
SongUuid uuid.UUID `json:"SongUuid"`
Song string `json:"Song"`
SongPlayed int32 `json:"SongPlayed"`
CurrentlyPlaying bool `json:"CurrentlyPlaying"`
SongNo int `json:"SongNo"`
}
// MusicService picks songs and keeps track of the shared play queue.
@@ -111,10 +114,10 @@ func (m *MusicService) SetPlayed(ctx context.Context, songNumber int) error {
}
func (m *MusicService) markPlayed(ctx context.Context, song repository.Song) error {
if err := m.q.AddSoundtrackPlayed(ctx, song.SoundtrackID); err != nil {
if err := m.q.AddSoundtrackPlayedByUUID(ctx, song.SoundtrackUuid); err != nil {
return err
}
return m.q.AddSongPlayed(ctx, repository.AddSongPlayedParams{SoundtrackID: song.SoundtrackID, SongName: song.SongName})
return m.q.AddSongPlayedByUUID(ctx, song.Uuid)
}
func (m *MusicService) validIndex(i int) bool {
@@ -182,8 +185,10 @@ func (m *MusicService) SongInfo(ctx context.Context) SongInfo {
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,
@@ -197,8 +202,10 @@ func (m *MusicService) PlayedSongs(ctx context.Context) []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,
@@ -331,7 +338,7 @@ func (m *MusicService) removeBrokenSong(ctx context.Context, song repository.Son
logging.GetLogger().Warn("Song not found, removing from database",
zap.String("song", song.SongName),
zap.String("path", song.Path))
return m.q.RemoveBrokenSong(ctx, repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
return m.q.RemoveBrokenSongs(ctx, []uuid.UUID{song.Uuid})
}
// songIsPlayable reports whether the song file exists and its stored path is consistent.
@@ -352,7 +359,7 @@ func (m *MusicService) soundtrackFor(ctx context.Context, song repository.Song)
return repository.Soundtrack{}
}
for _, soundtrack := range soundtracks {
if soundtrack.ID == song.SoundtrackID {
if soundtrack.Uuid == song.SoundtrackUuid {
return soundtrack
}
}
+33
View File
@@ -9,6 +9,7 @@ import (
"music-server/internal/db/repository"
"music-server/internal/testutil/fakedb"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -84,6 +85,9 @@ func TestMusicServiceQueue(t *testing.T) {
info := m.SongInfo(ctx)
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, 1, info.SongNo)
assert.True(t, info.CurrentlyPlaying)
@@ -222,3 +226,32 @@ func TestSoundCheckSong(t *testing.T) {
_, err = NewMusicService(fakedb.New(), filepath.Join(dir, "missing")).SoundCheckSong()
assert.Error(t, err)
}
func songPathByUUID(store *fakedb.Store, id uuid.UUID) string {
for _, song := range store.Songs {
if song.Uuid == id {
return song.Path
}
}
return ""
}
func TestMarkPlayedUsesSongUUID(t *testing.T) {
// Two songs with the same name in one soundtrack: only the played one is counted.
store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song")
fileName := "Song (copy).mp3"
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))
songs := store.SongsOf(st.ID)
require.Len(t, songs, 2)
assert.ElementsMatch(t, []int32{0, 1}, []int32{songs[0].TimesPlayed, songs[1].TimesPlayed})
}
+15 -7
View File
@@ -6,25 +6,31 @@ import (
"time"
"music-server/internal/db/repository"
"github.com/google/uuid"
)
// SoundtrackWithSongs represents a soundtrack with its songs for statistics
type SoundtrackWithSongs struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"`
Songs []SongInfoForStats `json:"songs"`
}
// SongInfoForStats represents a song with soundtrack info for statistics
// SongInfoForStats represents a song with soundtrack info for statistics.
// The field order matches the generated query rows so they can be converted directly.
type SongInfoForStats struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
Path string `json:"path"`
TimesPlayed int32 `json:"times_played"`
FileName *string `json:"file_name,omitempty"`
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
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"`
FileName *string `json:"file_name,omitempty"`
}
// StatisticsSummary holds overall statistics
@@ -65,6 +71,7 @@ func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs,
row := repository.GetMostPlayedSoundtracksWithSongsRow(r)
result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackUuid: row.SoundtrackUuid,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
@@ -129,6 +136,7 @@ func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]Sound
for _, row := range rows {
result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackUuid: row.SoundtrackUuid,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
Songs: parseSongs(row.Songs),
+5 -1
View File
@@ -57,7 +57,11 @@ func TestSyncWithPostgres(t *testing.T) {
// Play a song, then rename one of its files and, in a later sync, the soundtrack folder.
// (Renaming both at once changes the content hash, so the folder would count as new.)
require.NoError(t, q.AddSongPlayed(ctx, repository.AddSongPlayedParams{SoundtrackID: bird.ID, SongName: "2. Yesterday"}))
for _, song := range songs {
if song.SongName == "2. Yesterday" {
require.NoError(t, q.AddSongPlayedByUUID(ctx, song.Uuid))
}
}
birdDir := filepath.Join(musicDir, "A Bird Story")
require.NoError(t, os.Rename(filepath.Join(birdDir, "2. Yesterday.mp3"), filepath.Join(birdDir, "02 Yesterday.mp3")))
+1 -1
View File
@@ -229,7 +229,7 @@ func TestSyncDetectsRenamedSoundtrack(t *testing.T) {
f.write("Game A/01.mp3", "a1")
f.run(false)
a := f.soundtrack("Game A")
require.NoError(t, f.store.AddSongPlayed(context.Background(), repository.AddSongPlayedParams{SoundtrackID: a.ID, SongName: "01"}))
require.NoError(t, f.store.AddSongPlayedByUUID(context.Background(), f.store.SongsOf(a.ID)[0].Uuid))
require.NoError(t, os.Rename(filepath.Join(f.dir, "Game A"), filepath.Join(f.dir, "Game A Remastered")))
result := f.run(false)
+3
View File
@@ -19,8 +19,11 @@ var data = []VersionData{
"#21 - Refactor handlers into domain-specific files",
"#22 - Change VersionData Changelog from string to string array",
"#23 - Update all dependencies to latest versions",
"#29 - UUIDs in API responses, new soundtrack and song endpoints by UUID",
"Added search cache backend",
"Backend sync improvements",
"Faster sync that skips unchanged soundtracks",
"Security and stability fixes",
},
},
{
+11 -1
View File
@@ -46,7 +46,8 @@ DELETE FROM song WHERE uuid = ANY(sqlc.arg(uuids)::uuid[]);
-- name: GetSongsBySoundtrackUUID :many
SELECT *
FROM song
WHERE soundtrack_uuid = $1;
WHERE soundtrack_uuid = $1
ORDER BY song_name;
-- name: GetSongUUIDsBySoundtrackId :many
SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1;
@@ -62,3 +63,12 @@ JOIN soundtrack ON soundtrack.id = song.soundtrack_id
WHERE soundtrack.deleted IS NULL
ORDER BY random()
LIMIT 1;
-- name: AddSongPlayedByUUID :exec
UPDATE song SET times_played = times_played + 1 WHERE uuid = $1;
-- name: GetActiveSongByUUID :one
SELECT sqlc.embed(song), soundtrack.soundtrack_name, soundtrack.path AS soundtrack_path
FROM song
JOIN soundtrack ON soundtrack.id = song.soundtrack_id
WHERE song.uuid = $1 AND soundtrack.deleted IS NULL;
+3
View File
@@ -62,3 +62,6 @@ AND deleted IS NULL;
-- name: GetSoundtrackUUIDById :one
SELECT uuid FROM soundtrack WHERE id = $1;
-- name: AddSoundtrackPlayedByUUID :exec
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE uuid = $1;
+71 -37
View File
@@ -2,17 +2,23 @@
-- name: GetMostPlayedSoundtracksWithSongs :many
SELECT
g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played,
json_agg(
json_build_object(
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played,
'file_name', s.file_name
)
) as songs
COALESCE(
json_agg(
json_build_object(
'song_uuid', s.uuid,
'soundtrack_uuid', g.uuid,
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played,
'file_name', s.file_name
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL
@@ -24,17 +30,23 @@ LIMIT $1;
-- name: GetLeastPlayedSoundtracksWithSongs :many
SELECT
g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played,
json_agg(
json_build_object(
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played,
'file_name', s.file_name
)
) as songs
COALESCE(
json_agg(
json_build_object(
'song_uuid', s.uuid,
'soundtrack_uuid', g.uuid,
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played,
'file_name', s.file_name
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL
@@ -46,6 +58,8 @@ LIMIT $1;
-- name: GetMostPlayedSongsWithSoundtrack :many
SELECT
s.soundtrack_id as soundtrack_id,
g.uuid as soundtrack_uuid,
s.uuid as song_uuid,
g.soundtrack_name,
s.song_name,
s.path,
@@ -61,6 +75,8 @@ LIMIT $1;
-- name: GetLeastPlayedSongsWithSoundtrack :many
SELECT
s.soundtrack_id as soundtrack_id,
g.uuid as soundtrack_uuid,
s.uuid as song_uuid,
g.soundtrack_name,
s.song_name,
s.path,
@@ -76,16 +92,22 @@ LIMIT $1;
-- name: GetNeverPlayedSoundtracks :many
SELECT
g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
g.added,
json_agg(
json_build_object(
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played
)
) as songs
COALESCE(
json_agg(
json_build_object(
'song_uuid', s.uuid,
'soundtrack_uuid', g.uuid,
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL AND g.times_played = 0
@@ -96,16 +118,22 @@ ORDER BY g.soundtrack_name;
-- name: GetLastPlayedSoundtracks :many
SELECT
g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played,
json_agg(
json_build_object(
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played
)
) as songs
COALESCE(
json_agg(
json_build_object(
'song_uuid', s.uuid,
'soundtrack_uuid', g.uuid,
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
@@ -117,16 +145,22 @@ LIMIT $1;
-- name: GetOldestPlayedSoundtracks :many
SELECT
g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played,
json_agg(
json_build_object(
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played
)
) as songs
COALESCE(
json_agg(
json_build_object(
'song_uuid', s.uuid,
'soundtrack_uuid', g.uuid,
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
+3
View File
@@ -15,7 +15,9 @@ type Querier interface {
AddHashToSong(ctx context.Context, arg AddHashToSongParams) error
AddSong(ctx context.Context, arg AddSongParams) error
AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error
AddSongPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
AddSoundtrackPlayed(ctx context.Context, id int32) error
AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
CheckSong(ctx context.Context, arg CheckSongParams) (int64, error)
CheckSongWithHash(ctx context.Context, hash string) (int64, error)
ClearSongs(ctx context.Context) error
@@ -27,6 +29,7 @@ type Querier interface {
FetchAllSongs(ctx context.Context) ([]Song, error)
FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error)
GetActiveSongByUUID(ctx context.Context, argUuid uuid.UUID) (GetActiveSongByUUIDRow, error)
GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soundtrack, error)
GetIdBySoundtrackName(ctx context.Context, soundtrackName string) (int32, error)
// Last played soundtracks (most recently played)
+41
View File
@@ -68,6 +68,15 @@ func (q *Queries) AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) er
return err
}
const addSongPlayedByUUID = `-- name: AddSongPlayedByUUID :exec
UPDATE song SET times_played = times_played + 1 WHERE uuid = $1
`
func (q *Queries) AddSongPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error {
_, err := q.db.Exec(ctx, addSongPlayedByUUID, argUuid)
return err
}
const checkSong = `-- name: CheckSong :one
SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2
`
@@ -181,6 +190,37 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
return items, nil
}
const getActiveSongByUUID = `-- name: GetActiveSongByUUID :one
SELECT song.soundtrack_id, song.song_name, song.path, song.times_played, song.hash, song.file_name, song.uuid, song.soundtrack_uuid, soundtrack.soundtrack_name, soundtrack.path AS soundtrack_path
FROM song
JOIN soundtrack ON soundtrack.id = song.soundtrack_id
WHERE song.uuid = $1 AND soundtrack.deleted IS NULL
`
type GetActiveSongByUUIDRow struct {
Song Song `json:"song"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPath string `json:"soundtrack_path"`
}
func (q *Queries) GetActiveSongByUUID(ctx context.Context, argUuid uuid.UUID) (GetActiveSongByUUIDRow, error) {
row := q.db.QueryRow(ctx, getActiveSongByUUID, argUuid)
var i GetActiveSongByUUIDRow
err := row.Scan(
&i.Song.SoundtrackID,
&i.Song.SongName,
&i.Song.Path,
&i.Song.TimesPlayed,
&i.Song.Hash,
&i.Song.FileName,
&i.Song.Uuid,
&i.Song.SoundtrackUuid,
&i.SoundtrackName,
&i.SoundtrackPath,
)
return i, err
}
const getRandomActiveSong = `-- name: GetRandomActiveSong :one
SELECT song.soundtrack_id, song.song_name, song.path, song.times_played, song.hash, song.file_name, song.uuid, song.soundtrack_uuid, soundtrack.path AS soundtrack_path
FROM song
@@ -285,6 +325,7 @@ const getSongsBySoundtrackUUID = `-- name: GetSongsBySoundtrackUUID :many
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid
FROM song
WHERE soundtrack_uuid = $1
ORDER BY song_name
`
func (q *Queries) GetSongsBySoundtrackUUID(ctx context.Context, soundtrackUuid uuid.UUID) ([]Song, error) {
+9
View File
@@ -20,6 +20,15 @@ func (q *Queries) AddSoundtrackPlayed(ctx context.Context, id int32) error {
return err
}
const addSoundtrackPlayedByUUID = `-- name: AddSoundtrackPlayedByUUID :exec
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE uuid = $1
`
func (q *Queries) AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error {
_, err := q.db.Exec(ctx, addSoundtrackPlayedByUUID, argUuid)
return err
}
const clearSoundtracks = `-- name: ClearSoundtracks :exec
DELETE FROM soundtrack
`
+103 -49
View File
@@ -8,21 +8,29 @@ package repository
import (
"context"
"time"
"github.com/google/uuid"
)
const getLastPlayedSoundtracks = `-- name: GetLastPlayedSoundtracks :many
SELECT
g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played,
json_agg(
json_build_object(
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played
)
) as songs
COALESCE(
json_agg(
json_build_object(
'song_uuid', s.uuid,
'soundtrack_uuid', g.uuid,
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
@@ -33,6 +41,7 @@ LIMIT $1
type GetLastPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
@@ -51,6 +60,7 @@ func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]
var i GetLastPlayedSoundtracksRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackUuid,
&i.SoundtrackName,
&i.SoundtrackPlayed,
&i.SoundtrackLastPlayed,
@@ -69,6 +79,8 @@ func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]
const getLeastPlayedSongsWithSoundtrack = `-- name: GetLeastPlayedSongsWithSoundtrack :many
SELECT
s.soundtrack_id as soundtrack_id,
g.uuid as soundtrack_uuid,
s.uuid as song_uuid,
g.soundtrack_name,
s.song_name,
s.path,
@@ -82,12 +94,14 @@ LIMIT $1
`
type GetLeastPlayedSongsWithSoundtrackRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
Path string `json:"path"`
TimesPlayed int32 `json:"times_played"`
FileName *string `json:"file_name"`
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
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"`
FileName *string `json:"file_name"`
}
// Least played songs with their soundtrack info
@@ -102,6 +116,8 @@ func (q *Queries) GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit i
var i GetLeastPlayedSongsWithSoundtrackRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackUuid,
&i.SongUuid,
&i.SoundtrackName,
&i.SongName,
&i.Path,
@@ -121,17 +137,23 @@ func (q *Queries) GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit i
const getLeastPlayedSoundtracksWithSongs = `-- name: GetLeastPlayedSoundtracksWithSongs :many
SELECT
g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played,
json_agg(
json_build_object(
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played,
'file_name', s.file_name
)
) as songs
COALESCE(
json_agg(
json_build_object(
'song_uuid', s.uuid,
'soundtrack_uuid', g.uuid,
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played,
'file_name', s.file_name
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL
@@ -142,6 +164,7 @@ LIMIT $1
type GetLeastPlayedSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
@@ -160,6 +183,7 @@ func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit
var i GetLeastPlayedSoundtracksWithSongsRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackUuid,
&i.SoundtrackName,
&i.SoundtrackPlayed,
&i.SoundtrackLastPlayed,
@@ -178,6 +202,8 @@ func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit
const getMostPlayedSongsWithSoundtrack = `-- name: GetMostPlayedSongsWithSoundtrack :many
SELECT
s.soundtrack_id as soundtrack_id,
g.uuid as soundtrack_uuid,
s.uuid as song_uuid,
g.soundtrack_name,
s.song_name,
s.path,
@@ -191,12 +217,14 @@ LIMIT $1
`
type GetMostPlayedSongsWithSoundtrackRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
Path string `json:"path"`
TimesPlayed int32 `json:"times_played"`
FileName *string `json:"file_name"`
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
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"`
FileName *string `json:"file_name"`
}
// Most played songs with their soundtrack info
@@ -211,6 +239,8 @@ func (q *Queries) GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit in
var i GetMostPlayedSongsWithSoundtrackRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackUuid,
&i.SongUuid,
&i.SoundtrackName,
&i.SongName,
&i.Path,
@@ -230,17 +260,23 @@ func (q *Queries) GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit in
const getMostPlayedSoundtracksWithSongs = `-- name: GetMostPlayedSoundtracksWithSongs :many
SELECT
g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played,
json_agg(
json_build_object(
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played,
'file_name', s.file_name
)
) as songs
COALESCE(
json_agg(
json_build_object(
'song_uuid', s.uuid,
'soundtrack_uuid', g.uuid,
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played,
'file_name', s.file_name
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL
@@ -251,6 +287,7 @@ LIMIT $1
type GetMostPlayedSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
@@ -269,6 +306,7 @@ func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit i
var i GetMostPlayedSoundtracksWithSongsRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackUuid,
&i.SoundtrackName,
&i.SoundtrackPlayed,
&i.SoundtrackLastPlayed,
@@ -287,16 +325,22 @@ func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit i
const getNeverPlayedSoundtracks = `-- name: GetNeverPlayedSoundtracks :many
SELECT
g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
g.added,
json_agg(
json_build_object(
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played
)
) as songs
COALESCE(
json_agg(
json_build_object(
'song_uuid', s.uuid,
'soundtrack_uuid', g.uuid,
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL AND g.times_played = 0
@@ -306,6 +350,7 @@ ORDER BY g.soundtrack_name
type GetNeverPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
Added time.Time `json:"added"`
@@ -324,6 +369,7 @@ func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlay
var i GetNeverPlayedSoundtracksRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackUuid,
&i.SoundtrackName,
&i.SoundtrackPlayed,
&i.Added,
@@ -342,16 +388,22 @@ func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlay
const getOldestPlayedSoundtracks = `-- name: GetOldestPlayedSoundtracks :many
SELECT
g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played,
json_agg(
json_build_object(
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played
)
) as songs
COALESCE(
json_agg(
json_build_object(
'song_uuid', s.uuid,
'soundtrack_uuid', g.uuid,
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
@@ -362,6 +414,7 @@ LIMIT $1
type GetOldestPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
@@ -380,6 +433,7 @@ func (q *Queries) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) (
var i GetOldestPlayedSoundtracksRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackUuid,
&i.SoundtrackName,
&i.SoundtrackPlayed,
&i.SoundtrackLastPlayed,
+10 -1
View File
@@ -13,6 +13,7 @@ import (
"music-server/internal/config"
"music-server/internal/db/repository"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -237,6 +238,11 @@ func TestTokenRateLimit(t *testing.T) {
assert.Contains(t, rec.Body.String(), "Rate limit exceeded")
}
var (
statsSoundtrackUUID = uuid.New()
statsSongUUID = uuid.New()
)
// statsStore returns canned statistics.
type statsStore struct {
repository.Querier
@@ -246,8 +252,9 @@ type statsStore struct {
func (s statsStore) GetMostPlayedSoundtracksWithSongs(_ context.Context, limit int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) {
return []repository.GetMostPlayedSoundtracksWithSongsRow{{
SoundtrackID: int32(limit),
SoundtrackUuid: statsSoundtrackUUID,
SoundtrackName: "Game A",
Songs: []byte(`[{"song_name":"Song","times_played":3}]`),
Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`),
}}, s.err
}
@@ -265,6 +272,8 @@ func TestStatisticsEndpoints(t *testing.T) {
require.Len(t, soundtracks, 1)
assert.Equal(t, int32(10), soundtracks[0].SoundtrackID, "default limit")
assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName)
assert.Equal(t, statsSoundtrackUUID, soundtracks[0].SoundtrackUuid)
assert.Equal(t, statsSongUUID, soundtracks[0].Songs[0].SongUuid)
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit=500").Body.Bytes(), &soundtracks))
assert.Equal(t, int32(100), soundtracks[0].SoundtrackID, "limit is capped")
+111
View File
@@ -0,0 +1,111 @@
package server
import (
"net/http"
"music-server/internal/backend"
"github.com/google/uuid"
"github.com/labstack/echo/v5"
)
type LibraryHandler struct {
library *backend.LibraryService
}
func NewLibraryHandler(library *backend.LibraryService) *LibraryHandler {
return &LibraryHandler{library: library}
}
// uuidParam parses the :uuid path parameter.
func uuidParam(ctx *echo.Context) (uuid.UUID, error) {
id, err := uuid.Parse(ctx.Param("uuid"))
if err != nil {
return uuid.Nil, echo.NewHTTPError(http.StatusBadRequest, "invalid uuid")
}
return id, nil
}
// GetSoundtracks godoc
//
// @Summary List soundtracks
// @Description Returns all soundtracks with their UUIDs, sorted by name
// @Tags library
// @Produce json
// @Success 200 {array} backend.Soundtrack
// @Router /api/v1/soundtracks [get]
func (l *LibraryHandler) GetSoundtracks(ctx *echo.Context) error {
soundtracks, err := l.library.Soundtracks(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, soundtracks)
}
// GetSoundtrack godoc
//
// @Summary Get soundtrack
// @Description Returns a soundtrack with its songs
// @Tags library
// @Produce json
// @Param uuid path string true "Soundtrack UUID"
// @Success 200 {object} backend.SoundtrackWithSongList
// @Failure 400 {string} string "invalid uuid"
// @Failure 404 {string} string "not found"
// @Router /api/v1/soundtracks/{uuid} [get]
func (l *LibraryHandler) GetSoundtrack(ctx *echo.Context) error {
id, err := uuidParam(ctx)
if err != nil {
return err
}
soundtrack, err := l.library.Soundtrack(ctx.Request().Context(), id)
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, soundtrack)
}
// GetSong godoc
//
// @Summary Get song
// @Description Returns information about a song
// @Tags library
// @Produce json
// @Param uuid path string true "Song UUID"
// @Success 200 {object} backend.Song
// @Failure 400 {string} string "invalid uuid"
// @Failure 404 {string} string "not found"
// @Router /api/v1/songs/{uuid} [get]
func (l *LibraryHandler) GetSong(ctx *echo.Context) error {
id, err := uuidParam(ctx)
if err != nil {
return err
}
song, err := l.library.Song(ctx.Request().Context(), id)
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, song)
}
// GetSongFile godoc
//
// @Summary Stream song
// @Description Returns the audio file of a song. Supports Range requests.
// @Tags library
// @Produce audio/mpeg
// @Param uuid path string true "Song UUID"
// @Success 200 {file} file
// @Success 206 {file} file
// @Failure 400 {string} string "invalid uuid"
// @Failure 404 {string} string "not found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /api/v1/songs/{uuid}/file [get]
func (l *LibraryHandler) GetSongFile(ctx *echo.Context) error {
id, err := uuidParam(ctx)
if err != nil {
return err
}
path, err := l.library.SongPath(ctx.Request().Context(), id)
return serveSong(ctx, path, err)
}
+87
View File
@@ -0,0 +1,87 @@
package server
import (
"encoding/json"
"net/http"
"testing"
"music-server/internal/backend"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLibraryEndpoints(t *testing.T) {
e := newTestEnv(t)
song := e.addSong("Game A", "Song", "0123456789")
e.addSong("Game B", "Other", "x")
var soundtracks []backend.Soundtrack
rec := e.do(http.MethodGet, "/api/v1/soundtracks")
require.Equal(t, http.StatusOK, rec.Code)
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &soundtracks))
require.Len(t, soundtracks, 2)
assert.Equal(t, "Game A", soundtracks[0].Name)
assert.Equal(t, song.SoundtrackUuid, soundtracks[0].Uuid)
var soundtrack backend.SoundtrackWithSongList
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()).Body.Bytes(), &soundtrack))
require.Len(t, soundtrack.Songs, 1)
assert.Equal(t, song.Uuid, soundtrack.Songs[0].Uuid)
var info backend.Song
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/songs/"+song.Uuid.String()).Body.Bytes(), &info))
assert.Equal(t, "Song", info.Name)
assert.Equal(t, "Game A", info.SoundtrackName)
rec = e.do(http.MethodGet, "/api/v1/songs/"+song.Uuid.String()+"/file", "Range", "bytes=0-3")
assert.Equal(t, http.StatusPartialContent, rec.Code)
assert.Equal(t, "0123", rec.Body.String())
assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type"))
}
func TestLibraryEndpointErrors(t *testing.T) {
e := newTestEnv(t)
missing := uuid.New().String()
for _, path := range []string{"/api/v1/soundtracks/" + missing, "/api/v1/songs/" + missing, "/api/v1/songs/" + missing + "/file"} {
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
}
for _, path := range []string{"/api/v1/soundtracks/not-a-uuid", "/api/v1/songs/123", "/api/v1/songs/123/file"} {
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, path).Code, path)
}
}
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) {
e := newTestEnv(t)
song := e.addSong("Game A", "Song", "x")
release := e.blockSync()
defer release()
require.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code)
assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, "/api/v1/songs/"+song.Uuid.String()+"/file").Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/songs/"+song.Uuid.String()).Code, "song info stays available")
}
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)
var info map[string]any
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/info").Body.Bytes(), &info))
assert.Equal(t, song.Uuid.String(), info["SongUuid"])
assert.Equal(t, song.SoundtrackUuid.String(), info["SoundtrackUuid"])
var list []map[string]any
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/list").Body.Bytes(), &list))
require.Len(t, list, 1)
assert.Equal(t, song.Uuid.String(), list[0]["SongUuid"])
}
+7
View File
@@ -38,6 +38,7 @@ type handlers struct {
download *DownloadHandler
sync *SyncHandler
music *MusicHandler
library *LibraryHandler
statistics *StatisticsHandler
token *TokenHandler
}
@@ -50,6 +51,7 @@ func (s *Server) handlers() handlers {
download: NewDownloadHandler(s.releases),
sync: NewSyncHandler(s.sync),
music: NewMusicHandler(s.music),
library: NewLibraryHandler(s.library),
statistics: NewStatisticsHandler(s.stats),
token: NewTokenHandler(s.q, s.cfg.APIPassword, s.cfg.DisableSessionAuth),
}
@@ -103,6 +105,11 @@ func (s *Server) RegisterRoutes() http.Handler {
registerCommon(protected, h, syncLock)
protected.GET("/character/list", h.character.GetCharacterList)
protected.GET("/soundtracks", h.library.GetSoundtracks)
protected.GET("/soundtracks/:uuid", h.library.GetSoundtrack)
protected.GET("/songs/:uuid", h.library.GetSong)
protected.GET("/songs/:uuid/file", h.library.GetSongFile, syncLock)
stats := protected.Group("/statistics")
stats.GET("/soundtracks/most-played", h.statistics.GetMostPlayedSoundtracks)
stats.GET("/soundtracks/least-played", h.statistics.GetLeastPlayedSoundtracks)
+2
View File
@@ -27,6 +27,7 @@ type Server struct {
db Database
q repository.Querier
music *backend.MusicService
library *backend.LibraryService
sync *backend.SyncService
characters *backend.CharacterService
releases *backend.ReleaseService
@@ -47,6 +48,7 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e
db: database,
q: q,
music: backend.NewMusicService(q, cfg.SoundCheckPath),
library: backend.NewLibraryService(q),
characters: characters,
releases: backend.NewReleaseService(cfg.ReleasesURL),
stats: backend.NewStatisticsService(q),
+57 -10
View File
@@ -52,12 +52,15 @@ func (s *Store) AddSoundtrack(st repository.Soundtrack) repository.Soundtrack {
return st
}
// AddTestSong inserts a song and returns it with its UUID set.
// AddTestSong inserts a song and returns it with its UUID and soundtrack UUID set.
func (s *Store) AddTestSong(song repository.Song) repository.Song {
defer s.lock()()
if song.Uuid == uuid.Nil {
song.Uuid = uuid.New()
}
if i := s.soundtrackIndex(song.SoundtrackID); song.SoundtrackUuid == uuid.Nil && i >= 0 {
song.SoundtrackUuid = s.Soundtracks[i].Uuid
}
s.Songs = append(s.Songs, song)
return song
}
@@ -162,9 +165,29 @@ func (s *Store) RemoveSoundtrackDeletionDate(_ context.Context, id int32) error
return s.updateSoundtrack(id, func(st *repository.Soundtrack) { st.Deleted = nil })
}
func (s *Store) AddSoundtrackPlayed(_ context.Context, id int32) error {
func (s *Store) AddSoundtrackPlayedByUUID(_ context.Context, id uuid.UUID) error {
defer s.lock()()
now := time.Now()
return s.updateSoundtrack(id, func(st *repository.Soundtrack) { st.TimesPlayed++; st.LastPlayed = &now })
for i := range s.Soundtracks {
if s.Soundtracks[i].Uuid == id {
s.Soundtracks[i].TimesPlayed++
s.Soundtracks[i].LastPlayed = &now
}
}
return s.Err
}
func (s *Store) GetSoundtrackByUUID(_ context.Context, id uuid.UUID) (repository.Soundtrack, error) {
defer s.lock()()
if s.Err != nil {
return repository.Soundtrack{}, s.Err
}
for _, st := range s.Soundtracks {
if st.Uuid == id && st.Deleted == nil {
return st, nil
}
}
return repository.Soundtrack{}, pgx.ErrNoRows
}
func (s *Store) MarkSoundtracksDeletedExcept(_ context.Context, ids []int32) error {
@@ -231,22 +254,46 @@ func (s *Store) UpdateSongByUUID(_ context.Context, arg repository.UpdateSongByU
return s.Err
}
func (s *Store) AddSongPlayed(_ context.Context, arg repository.AddSongPlayedParams) error {
func (s *Store) AddSongPlayedByUUID(_ context.Context, id uuid.UUID) error {
defer s.lock()()
for i := range s.Songs {
if s.Songs[i].SoundtrackID == arg.SoundtrackID && s.Songs[i].SongName == arg.SongName {
if s.Songs[i].Uuid == id {
s.Songs[i].TimesPlayed++
}
}
return s.Err
}
func (s *Store) RemoveBrokenSong(_ context.Context, arg repository.RemoveBrokenSongParams) error {
func (s *Store) GetSongsBySoundtrackUUID(_ context.Context, id uuid.UUID) ([]repository.Song, error) {
defer s.lock()()
s.Songs = slices.DeleteFunc(s.Songs, func(song repository.Song) bool {
return song.SoundtrackID == arg.SoundtrackID && song.Path == arg.Path
})
return s.Err
if s.Err != nil {
return nil, s.Err
}
var songs []repository.Song
for _, song := range s.Songs {
if song.SoundtrackUuid == id {
songs = append(songs, song)
}
}
slices.SortFunc(songs, func(a, b repository.Song) int { return cmp.Compare(a.SongName, b.SongName) })
return songs, nil
}
func (s *Store) GetActiveSongByUUID(_ context.Context, id uuid.UUID) (repository.GetActiveSongByUUIDRow, error) {
defer s.lock()()
if s.Err != nil {
return repository.GetActiveSongByUUIDRow{}, s.Err
}
for _, song := range s.Songs {
if song.Uuid != id {
continue
}
if i := s.soundtrackIndex(song.SoundtrackID); i >= 0 && s.Soundtracks[i].Deleted == nil {
st := s.Soundtracks[i]
return repository.GetActiveSongByUUIDRow{Song: song, SoundtrackName: st.SoundtrackName, SoundtrackPath: st.Path}, nil
}
}
return repository.GetActiveSongByUUIDRow{}, pgx.ErrNoRows
}
func (s *Store) RemoveBrokenSongs(_ context.Context, uuids []uuid.UUID) error {