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}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "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": { "/api/v1/statistics/songs/least-played": {
"get": { "get": {
"description": "Returns the top N least played songs with their soundtrack info", "description": "Returns the top N least played songs with their soundtrack info",
@@ -1331,6 +1489,29 @@ const docTemplate = `{
} }
}, },
"definitions": { "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": { "backend.SongInfo": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1346,11 +1527,17 @@ const docTemplate = `{
"SongPlayed": { "SongPlayed": {
"type": "integer" "type": "integer"
}, },
"SongUuid": {
"type": "string"
},
"Soundtrack": { "Soundtrack": {
"type": "string" "type": "string"
}, },
"SoundtrackPlayed": { "SoundtrackPlayed": {
"type": "integer" "type": "integer"
},
"SoundtrackUuid": {
"type": "string"
} }
} }
}, },
@@ -1366,17 +1553,69 @@ const docTemplate = `{
"song_name": { "song_name": {
"type": "string" "type": "string"
}, },
"song_uuid": {
"type": "string"
},
"soundtrack_id": { "soundtrack_id": {
"type": "integer" "type": "integer"
}, },
"soundtrack_name": { "soundtrack_name": {
"type": "string" "type": "string"
}, },
"soundtrack_uuid": {
"type": "string"
},
"times_played": { "times_played": {
"type": "integer" "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": { "backend.SoundtrackWithSongs": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1397,6 +1636,9 @@ const docTemplate = `{
}, },
"soundtrack_played": { "soundtrack_played": {
"type": "integer" "type": "integer"
},
"soundtrack_uuid": {
"type": "string"
} }
} }
}, },
+242
View File
@@ -16,6 +16,164 @@
}, },
"basePath": "/", "basePath": "/",
"paths": { "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": { "/api/v1/statistics/songs/least-played": {
"get": { "get": {
"description": "Returns the top N least played songs with their soundtrack info", "description": "Returns the top N least played songs with their soundtrack info",
@@ -1324,6 +1482,29 @@
} }
}, },
"definitions": { "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": { "backend.SongInfo": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1339,11 +1520,17 @@
"SongPlayed": { "SongPlayed": {
"type": "integer" "type": "integer"
}, },
"SongUuid": {
"type": "string"
},
"Soundtrack": { "Soundtrack": {
"type": "string" "type": "string"
}, },
"SoundtrackPlayed": { "SoundtrackPlayed": {
"type": "integer" "type": "integer"
},
"SoundtrackUuid": {
"type": "string"
} }
} }
}, },
@@ -1359,17 +1546,69 @@
"song_name": { "song_name": {
"type": "string" "type": "string"
}, },
"song_uuid": {
"type": "string"
},
"soundtrack_id": { "soundtrack_id": {
"type": "integer" "type": "integer"
}, },
"soundtrack_name": { "soundtrack_name": {
"type": "string" "type": "string"
}, },
"soundtrack_uuid": {
"type": "string"
},
"times_played": { "times_played": {
"type": "integer" "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": { "backend.SoundtrackWithSongs": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1390,6 +1629,9 @@
}, },
"soundtrack_played": { "soundtrack_played": {
"type": "integer" "type": "integer"
},
"soundtrack_uuid": {
"type": "string"
} }
} }
}, },
+159
View File
@@ -1,5 +1,20 @@
basePath: / basePath: /
definitions: 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: backend.SongInfo:
properties: properties:
CurrentlyPlaying: CurrentlyPlaying:
@@ -10,10 +25,14 @@ definitions:
type: integer type: integer
SongPlayed: SongPlayed:
type: integer type: integer
SongUuid:
type: string
Soundtrack: Soundtrack:
type: string type: string
SoundtrackPlayed: SoundtrackPlayed:
type: integer type: integer
SoundtrackUuid:
type: string
type: object type: object
backend.SongInfoForStats: backend.SongInfoForStats:
properties: properties:
@@ -23,13 +42,47 @@ definitions:
type: string type: string
song_name: song_name:
type: string type: string
song_uuid:
type: string
soundtrack_id: soundtrack_id:
type: integer type: integer
soundtrack_name: soundtrack_name:
type: string type: string
soundtrack_uuid:
type: string
times_played: times_played:
type: integer type: integer
type: object 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: backend.SoundtrackWithSongs:
properties: properties:
songs: songs:
@@ -44,6 +97,8 @@ definitions:
type: string type: string
soundtrack_played: soundtrack_played:
type: integer type: integer
soundtrack_uuid:
type: string
type: object type: object
backend.StatisticsSummary: backend.StatisticsSummary:
properties: properties:
@@ -134,6 +189,110 @@ info:
title: MusicServer API title: MusicServer API
version: "1.0" version: "1.0"
paths: 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: /api/v1/statistics/songs/least-played:
get: get:
description: Returns the top N least played songs with their soundtrack info 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/db/repository"
"music-server/internal/logging" "music-server/internal/logging"
"github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -21,12 +22,14 @@ import (
const maxPickAttempts = 100 const maxPickAttempts = 100
type SongInfo struct { type SongInfo struct {
Soundtrack string `json:"Soundtrack"` SoundtrackUuid uuid.UUID `json:"SoundtrackUuid"`
SoundtrackPlayed int32 `json:"SoundtrackPlayed"` Soundtrack string `json:"Soundtrack"`
Song string `json:"Song"` SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
SongPlayed int32 `json:"SongPlayed"` SongUuid uuid.UUID `json:"SongUuid"`
CurrentlyPlaying bool `json:"CurrentlyPlaying"` Song string `json:"Song"`
SongNo int `json:"SongNo"` SongPlayed int32 `json:"SongPlayed"`
CurrentlyPlaying bool `json:"CurrentlyPlaying"`
SongNo int `json:"SongNo"`
} }
// MusicService picks songs and keeps track of the shared play queue. // 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 { 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 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 { func (m *MusicService) validIndex(i int) bool {
@@ -182,8 +185,10 @@ func (m *MusicService) SongInfo(ctx context.Context) SongInfo {
song := m.queue[m.currentSong] song := m.queue[m.currentSong]
soundtrack := m.soundtrackFor(ctx, song) soundtrack := m.soundtrackFor(ctx, song)
return SongInfo{ return SongInfo{
SoundtrackUuid: soundtrack.Uuid,
Soundtrack: soundtrack.SoundtrackName, Soundtrack: soundtrack.SoundtrackName,
SoundtrackPlayed: soundtrack.TimesPlayed, SoundtrackPlayed: soundtrack.TimesPlayed,
SongUuid: song.Uuid,
Song: song.SongName, Song: song.SongName,
SongPlayed: song.TimesPlayed, SongPlayed: song.TimesPlayed,
CurrentlyPlaying: true, CurrentlyPlaying: true,
@@ -197,8 +202,10 @@ func (m *MusicService) PlayedSongs(ctx context.Context) []SongInfo {
for i, song := range m.queue { for i, song := range m.queue {
soundtrack := m.soundtrackFor(ctx, song) soundtrack := m.soundtrackFor(ctx, song)
songList = append(songList, SongInfo{ songList = append(songList, SongInfo{
SoundtrackUuid: soundtrack.Uuid,
Soundtrack: soundtrack.SoundtrackName, Soundtrack: soundtrack.SoundtrackName,
SoundtrackPlayed: soundtrack.TimesPlayed, SoundtrackPlayed: soundtrack.TimesPlayed,
SongUuid: song.Uuid,
Song: song.SongName, Song: song.SongName,
SongPlayed: song.TimesPlayed, SongPlayed: song.TimesPlayed,
CurrentlyPlaying: i == m.currentSong, 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", logging.GetLogger().Warn("Song not found, removing from database",
zap.String("song", song.SongName), zap.String("song", song.SongName),
zap.String("path", song.Path)) 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. // 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{} return repository.Soundtrack{}
} }
for _, soundtrack := range soundtracks { for _, soundtrack := range soundtracks {
if soundtrack.ID == song.SoundtrackID { if soundtrack.Uuid == song.SoundtrackUuid {
return soundtrack return soundtrack
} }
} }
+33
View File
@@ -9,6 +9,7 @@ import (
"music-server/internal/db/repository" "music-server/internal/db/repository"
"music-server/internal/testutil/fakedb" "music-server/internal/testutil/fakedb"
"github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -84,6 +85,9 @@ func TestMusicServiceQueue(t *testing.T) {
info := m.SongInfo(ctx) info := m.SongInfo(ctx)
assert.Equal(t, "Game A", info.Soundtrack) 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.Equal(t, 1, info.SongNo)
assert.True(t, info.CurrentlyPlaying) assert.True(t, info.CurrentlyPlaying)
@@ -222,3 +226,32 @@ func TestSoundCheckSong(t *testing.T) {
_, err = NewMusicService(fakedb.New(), filepath.Join(dir, "missing")).SoundCheckSong() _, err = NewMusicService(fakedb.New(), filepath.Join(dir, "missing")).SoundCheckSong()
assert.Error(t, err) 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" "time"
"music-server/internal/db/repository" "music-server/internal/db/repository"
"github.com/google/uuid"
) )
// SoundtrackWithSongs represents a soundtrack with its songs for statistics // SoundtrackWithSongs represents a soundtrack with its songs for statistics
type SoundtrackWithSongs struct { type SoundtrackWithSongs struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"` SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"`
Songs []SongInfoForStats `json:"songs"` 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 { type SongInfoForStats struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SongName string `json:"song_name"` SongUuid uuid.UUID `json:"song_uuid"`
Path string `json:"path"` SoundtrackName string `json:"soundtrack_name"`
TimesPlayed int32 `json:"times_played"` SongName string `json:"song_name"`
FileName *string `json:"file_name,omitempty"` Path string `json:"path"`
TimesPlayed int32 `json:"times_played"`
FileName *string `json:"file_name,omitempty"`
} }
// StatisticsSummary holds overall statistics // StatisticsSummary holds overall statistics
@@ -65,6 +71,7 @@ func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs,
row := repository.GetMostPlayedSoundtracksWithSongsRow(r) row := repository.GetMostPlayedSoundtracksWithSongsRow(r)
result = append(result, SoundtrackWithSongs{ result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackUuid: row.SoundtrackUuid,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed, SoundtrackLastPlayed: row.SoundtrackLastPlayed,
@@ -129,6 +136,7 @@ func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]Sound
for _, row := range rows { for _, row := range rows {
result = append(result, SoundtrackWithSongs{ result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackUuid: row.SoundtrackUuid,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
Songs: parseSongs(row.Songs), 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. // 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.) // (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") birdDir := filepath.Join(musicDir, "A Bird Story")
require.NoError(t, os.Rename(filepath.Join(birdDir, "2. Yesterday.mp3"), filepath.Join(birdDir, "02 Yesterday.mp3"))) 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.write("Game A/01.mp3", "a1")
f.run(false) f.run(false)
a := f.soundtrack("Game A") 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"))) require.NoError(t, os.Rename(filepath.Join(f.dir, "Game A"), filepath.Join(f.dir, "Game A Remastered")))
result := f.run(false) result := f.run(false)
+3
View File
@@ -19,8 +19,11 @@ var data = []VersionData{
"#21 - Refactor handlers into domain-specific files", "#21 - Refactor handlers into domain-specific files",
"#22 - Change VersionData Changelog from string to string array", "#22 - Change VersionData Changelog from string to string array",
"#23 - Update all dependencies to latest versions", "#23 - Update all dependencies to latest versions",
"#29 - UUIDs in API responses, new soundtrack and song endpoints by UUID",
"Added search cache backend", "Added search cache backend",
"Backend sync improvements", "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 -- name: GetSongsBySoundtrackUUID :many
SELECT * SELECT *
FROM song FROM song
WHERE soundtrack_uuid = $1; WHERE soundtrack_uuid = $1
ORDER BY song_name;
-- name: GetSongUUIDsBySoundtrackId :many -- name: GetSongUUIDsBySoundtrackId :many
SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1; 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 WHERE soundtrack.deleted IS NULL
ORDER BY random() ORDER BY random()
LIMIT 1; 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 -- name: GetSoundtrackUUIDById :one
SELECT uuid FROM soundtrack WHERE id = $1; 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 -- name: GetMostPlayedSoundtracksWithSongs :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
json_agg( COALESCE(
json_build_object( json_agg(
'song_name', s.song_name, json_build_object(
'path', s.path, 'song_uuid', s.uuid,
'times_played', s.times_played, 'soundtrack_uuid', g.uuid,
'file_name', s.file_name 'song_name', s.song_name,
) 'path', s.path,
) as songs 'times_played', s.times_played,
'file_name', s.file_name
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL WHERE g.deleted IS NULL
@@ -24,17 +30,23 @@ LIMIT $1;
-- name: GetLeastPlayedSoundtracksWithSongs :many -- name: GetLeastPlayedSoundtracksWithSongs :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
json_agg( COALESCE(
json_build_object( json_agg(
'song_name', s.song_name, json_build_object(
'path', s.path, 'song_uuid', s.uuid,
'times_played', s.times_played, 'soundtrack_uuid', g.uuid,
'file_name', s.file_name 'song_name', s.song_name,
) 'path', s.path,
) as songs 'times_played', s.times_played,
'file_name', s.file_name
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL WHERE g.deleted IS NULL
@@ -46,6 +58,8 @@ LIMIT $1;
-- name: GetMostPlayedSongsWithSoundtrack :many -- name: GetMostPlayedSongsWithSoundtrack :many
SELECT SELECT
s.soundtrack_id as soundtrack_id, s.soundtrack_id as soundtrack_id,
g.uuid as soundtrack_uuid,
s.uuid as song_uuid,
g.soundtrack_name, g.soundtrack_name,
s.song_name, s.song_name,
s.path, s.path,
@@ -61,6 +75,8 @@ LIMIT $1;
-- name: GetLeastPlayedSongsWithSoundtrack :many -- name: GetLeastPlayedSongsWithSoundtrack :many
SELECT SELECT
s.soundtrack_id as soundtrack_id, s.soundtrack_id as soundtrack_id,
g.uuid as soundtrack_uuid,
s.uuid as song_uuid,
g.soundtrack_name, g.soundtrack_name,
s.song_name, s.song_name,
s.path, s.path,
@@ -76,16 +92,22 @@ LIMIT $1;
-- name: GetNeverPlayedSoundtracks :many -- name: GetNeverPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.added, g.added,
json_agg( COALESCE(
json_build_object( json_agg(
'song_name', s.song_name, json_build_object(
'path', s.path, 'song_uuid', s.uuid,
'times_played', s.times_played 'soundtrack_uuid', g.uuid,
) 'song_name', s.song_name,
) as songs 'path', s.path,
'times_played', s.times_played
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL AND g.times_played = 0 WHERE g.deleted IS NULL AND g.times_played = 0
@@ -96,16 +118,22 @@ ORDER BY g.soundtrack_name;
-- name: GetLastPlayedSoundtracks :many -- name: GetLastPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
json_agg( COALESCE(
json_build_object( json_agg(
'song_name', s.song_name, json_build_object(
'path', s.path, 'song_uuid', s.uuid,
'times_played', s.times_played 'soundtrack_uuid', g.uuid,
) 'song_name', s.song_name,
) as songs 'path', s.path,
'times_played', s.times_played
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
@@ -117,16 +145,22 @@ LIMIT $1;
-- name: GetOldestPlayedSoundtracks :many -- name: GetOldestPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
json_agg( COALESCE(
json_build_object( json_agg(
'song_name', s.song_name, json_build_object(
'path', s.path, 'song_uuid', s.uuid,
'times_played', s.times_played 'soundtrack_uuid', g.uuid,
) 'song_name', s.song_name,
) as songs 'path', s.path,
'times_played', s.times_played
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL 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 AddHashToSong(ctx context.Context, arg AddHashToSongParams) error
AddSong(ctx context.Context, arg AddSongParams) error AddSong(ctx context.Context, arg AddSongParams) error
AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error
AddSongPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
AddSoundtrackPlayed(ctx context.Context, id int32) error AddSoundtrackPlayed(ctx context.Context, id int32) error
AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
CheckSong(ctx context.Context, arg CheckSongParams) (int64, error) CheckSong(ctx context.Context, arg CheckSongParams) (int64, error)
CheckSongWithHash(ctx context.Context, hash string) (int64, error) CheckSongWithHash(ctx context.Context, hash string) (int64, error)
ClearSongs(ctx context.Context) error ClearSongs(ctx context.Context) error
@@ -27,6 +29,7 @@ type Querier interface {
FetchAllSongs(ctx context.Context) ([]Song, error) FetchAllSongs(ctx context.Context) ([]Song, error)
FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error)
GetActiveSongByUUID(ctx context.Context, argUuid uuid.UUID) (GetActiveSongByUUIDRow, error)
GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soundtrack, error) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soundtrack, error)
GetIdBySoundtrackName(ctx context.Context, soundtrackName string) (int32, error) GetIdBySoundtrackName(ctx context.Context, soundtrackName string) (int32, error)
// Last played soundtracks (most recently played) // 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 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 const checkSong = `-- name: CheckSong :one
SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2 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 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 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 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 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 SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid
FROM song FROM song
WHERE soundtrack_uuid = $1 WHERE soundtrack_uuid = $1
ORDER BY song_name
` `
func (q *Queries) GetSongsBySoundtrackUUID(ctx context.Context, soundtrackUuid uuid.UUID) ([]Song, error) { 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 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 const clearSoundtracks = `-- name: ClearSoundtracks :exec
DELETE FROM soundtrack DELETE FROM soundtrack
` `
+103 -49
View File
@@ -8,21 +8,29 @@ package repository
import ( import (
"context" "context"
"time" "time"
"github.com/google/uuid"
) )
const getLastPlayedSoundtracks = `-- name: GetLastPlayedSoundtracks :many const getLastPlayedSoundtracks = `-- name: GetLastPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
json_agg( COALESCE(
json_build_object( json_agg(
'song_name', s.song_name, json_build_object(
'path', s.path, 'song_uuid', s.uuid,
'times_played', s.times_played 'soundtrack_uuid', g.uuid,
) 'song_name', s.song_name,
) as songs 'path', s.path,
'times_played', s.times_played
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
@@ -33,6 +41,7 @@ LIMIT $1
type GetLastPlayedSoundtracksRow struct { type GetLastPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
@@ -51,6 +60,7 @@ func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]
var i GetLastPlayedSoundtracksRow var i GetLastPlayedSoundtracksRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.SoundtrackLastPlayed, &i.SoundtrackLastPlayed,
@@ -69,6 +79,8 @@ func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]
const getLeastPlayedSongsWithSoundtrack = `-- name: GetLeastPlayedSongsWithSoundtrack :many const getLeastPlayedSongsWithSoundtrack = `-- name: GetLeastPlayedSongsWithSoundtrack :many
SELECT SELECT
s.soundtrack_id as soundtrack_id, s.soundtrack_id as soundtrack_id,
g.uuid as soundtrack_uuid,
s.uuid as song_uuid,
g.soundtrack_name, g.soundtrack_name,
s.song_name, s.song_name,
s.path, s.path,
@@ -82,12 +94,14 @@ LIMIT $1
` `
type GetLeastPlayedSongsWithSoundtrackRow struct { type GetLeastPlayedSongsWithSoundtrackRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SongName string `json:"song_name"` SongUuid uuid.UUID `json:"song_uuid"`
Path string `json:"path"` SoundtrackName string `json:"soundtrack_name"`
TimesPlayed int32 `json:"times_played"` SongName string `json:"song_name"`
FileName *string `json:"file_name"` Path string `json:"path"`
TimesPlayed int32 `json:"times_played"`
FileName *string `json:"file_name"`
} }
// Least played songs with their soundtrack info // Least played songs with their soundtrack info
@@ -102,6 +116,8 @@ func (q *Queries) GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit i
var i GetLeastPlayedSongsWithSoundtrackRow var i GetLeastPlayedSongsWithSoundtrackRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackUuid,
&i.SongUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SongName, &i.SongName,
&i.Path, &i.Path,
@@ -121,17 +137,23 @@ func (q *Queries) GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit i
const getLeastPlayedSoundtracksWithSongs = `-- name: GetLeastPlayedSoundtracksWithSongs :many const getLeastPlayedSoundtracksWithSongs = `-- name: GetLeastPlayedSoundtracksWithSongs :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
json_agg( COALESCE(
json_build_object( json_agg(
'song_name', s.song_name, json_build_object(
'path', s.path, 'song_uuid', s.uuid,
'times_played', s.times_played, 'soundtrack_uuid', g.uuid,
'file_name', s.file_name 'song_name', s.song_name,
) 'path', s.path,
) as songs 'times_played', s.times_played,
'file_name', s.file_name
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL WHERE g.deleted IS NULL
@@ -142,6 +164,7 @@ LIMIT $1
type GetLeastPlayedSoundtracksWithSongsRow struct { type GetLeastPlayedSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
@@ -160,6 +183,7 @@ func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit
var i GetLeastPlayedSoundtracksWithSongsRow var i GetLeastPlayedSoundtracksWithSongsRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.SoundtrackLastPlayed, &i.SoundtrackLastPlayed,
@@ -178,6 +202,8 @@ func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit
const getMostPlayedSongsWithSoundtrack = `-- name: GetMostPlayedSongsWithSoundtrack :many const getMostPlayedSongsWithSoundtrack = `-- name: GetMostPlayedSongsWithSoundtrack :many
SELECT SELECT
s.soundtrack_id as soundtrack_id, s.soundtrack_id as soundtrack_id,
g.uuid as soundtrack_uuid,
s.uuid as song_uuid,
g.soundtrack_name, g.soundtrack_name,
s.song_name, s.song_name,
s.path, s.path,
@@ -191,12 +217,14 @@ LIMIT $1
` `
type GetMostPlayedSongsWithSoundtrackRow struct { type GetMostPlayedSongsWithSoundtrackRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SongName string `json:"song_name"` SongUuid uuid.UUID `json:"song_uuid"`
Path string `json:"path"` SoundtrackName string `json:"soundtrack_name"`
TimesPlayed int32 `json:"times_played"` SongName string `json:"song_name"`
FileName *string `json:"file_name"` Path string `json:"path"`
TimesPlayed int32 `json:"times_played"`
FileName *string `json:"file_name"`
} }
// Most played songs with their soundtrack info // Most played songs with their soundtrack info
@@ -211,6 +239,8 @@ func (q *Queries) GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit in
var i GetMostPlayedSongsWithSoundtrackRow var i GetMostPlayedSongsWithSoundtrackRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackUuid,
&i.SongUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SongName, &i.SongName,
&i.Path, &i.Path,
@@ -230,17 +260,23 @@ func (q *Queries) GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit in
const getMostPlayedSoundtracksWithSongs = `-- name: GetMostPlayedSoundtracksWithSongs :many const getMostPlayedSoundtracksWithSongs = `-- name: GetMostPlayedSoundtracksWithSongs :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
json_agg( COALESCE(
json_build_object( json_agg(
'song_name', s.song_name, json_build_object(
'path', s.path, 'song_uuid', s.uuid,
'times_played', s.times_played, 'soundtrack_uuid', g.uuid,
'file_name', s.file_name 'song_name', s.song_name,
) 'path', s.path,
) as songs 'times_played', s.times_played,
'file_name', s.file_name
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL WHERE g.deleted IS NULL
@@ -251,6 +287,7 @@ LIMIT $1
type GetMostPlayedSoundtracksWithSongsRow struct { type GetMostPlayedSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
@@ -269,6 +306,7 @@ func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit i
var i GetMostPlayedSoundtracksWithSongsRow var i GetMostPlayedSoundtracksWithSongsRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.SoundtrackLastPlayed, &i.SoundtrackLastPlayed,
@@ -287,16 +325,22 @@ func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit i
const getNeverPlayedSoundtracks = `-- name: GetNeverPlayedSoundtracks :many const getNeverPlayedSoundtracks = `-- name: GetNeverPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.added, g.added,
json_agg( COALESCE(
json_build_object( json_agg(
'song_name', s.song_name, json_build_object(
'path', s.path, 'song_uuid', s.uuid,
'times_played', s.times_played 'soundtrack_uuid', g.uuid,
) 'song_name', s.song_name,
) as songs 'path', s.path,
'times_played', s.times_played
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL AND g.times_played = 0 WHERE g.deleted IS NULL AND g.times_played = 0
@@ -306,6 +350,7 @@ ORDER BY g.soundtrack_name
type GetNeverPlayedSoundtracksRow struct { type GetNeverPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
Added time.Time `json:"added"` Added time.Time `json:"added"`
@@ -324,6 +369,7 @@ func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlay
var i GetNeverPlayedSoundtracksRow var i GetNeverPlayedSoundtracksRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.Added, &i.Added,
@@ -342,16 +388,22 @@ func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlay
const getOldestPlayedSoundtracks = `-- name: GetOldestPlayedSoundtracks :many const getOldestPlayedSoundtracks = `-- name: GetOldestPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
json_agg( COALESCE(
json_build_object( json_agg(
'song_name', s.song_name, json_build_object(
'path', s.path, 'song_uuid', s.uuid,
'times_played', s.times_played 'soundtrack_uuid', g.uuid,
) 'song_name', s.song_name,
) as songs 'path', s.path,
'times_played', s.times_played
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g FROM soundtrack g
LEFT JOIN song s ON g.id = s.soundtrack_id LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
@@ -362,6 +414,7 @@ LIMIT $1
type GetOldestPlayedSoundtracksRow struct { type GetOldestPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
@@ -380,6 +433,7 @@ func (q *Queries) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) (
var i GetOldestPlayedSoundtracksRow var i GetOldestPlayedSoundtracksRow
if err := rows.Scan( if err := rows.Scan(
&i.SoundtrackID, &i.SoundtrackID,
&i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.SoundtrackLastPlayed, &i.SoundtrackLastPlayed,
+10 -1
View File
@@ -13,6 +13,7 @@ import (
"music-server/internal/config" "music-server/internal/config"
"music-server/internal/db/repository" "music-server/internal/db/repository"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -237,6 +238,11 @@ func TestTokenRateLimit(t *testing.T) {
assert.Contains(t, rec.Body.String(), "Rate limit exceeded") assert.Contains(t, rec.Body.String(), "Rate limit exceeded")
} }
var (
statsSoundtrackUUID = uuid.New()
statsSongUUID = uuid.New()
)
// statsStore returns canned statistics. // statsStore returns canned statistics.
type statsStore struct { type statsStore struct {
repository.Querier repository.Querier
@@ -246,8 +252,9 @@ type statsStore struct {
func (s statsStore) GetMostPlayedSoundtracksWithSongs(_ context.Context, limit int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) { func (s statsStore) GetMostPlayedSoundtracksWithSongs(_ context.Context, limit int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) {
return []repository.GetMostPlayedSoundtracksWithSongsRow{{ return []repository.GetMostPlayedSoundtracksWithSongsRow{{
SoundtrackID: int32(limit), SoundtrackID: int32(limit),
SoundtrackUuid: statsSoundtrackUUID,
SoundtrackName: "Game A", 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 }}, s.err
} }
@@ -265,6 +272,8 @@ func TestStatisticsEndpoints(t *testing.T) {
require.Len(t, soundtracks, 1) require.Len(t, soundtracks, 1)
assert.Equal(t, int32(10), soundtracks[0].SoundtrackID, "default limit") assert.Equal(t, int32(10), soundtracks[0].SoundtrackID, "default limit")
assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName) 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)) 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") 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 download *DownloadHandler
sync *SyncHandler sync *SyncHandler
music *MusicHandler music *MusicHandler
library *LibraryHandler
statistics *StatisticsHandler statistics *StatisticsHandler
token *TokenHandler token *TokenHandler
} }
@@ -50,6 +51,7 @@ func (s *Server) handlers() handlers {
download: NewDownloadHandler(s.releases), download: NewDownloadHandler(s.releases),
sync: NewSyncHandler(s.sync), sync: NewSyncHandler(s.sync),
music: NewMusicHandler(s.music), music: NewMusicHandler(s.music),
library: NewLibraryHandler(s.library),
statistics: NewStatisticsHandler(s.stats), statistics: NewStatisticsHandler(s.stats),
token: NewTokenHandler(s.q, s.cfg.APIPassword, s.cfg.DisableSessionAuth), token: NewTokenHandler(s.q, s.cfg.APIPassword, s.cfg.DisableSessionAuth),
} }
@@ -103,6 +105,11 @@ func (s *Server) RegisterRoutes() http.Handler {
registerCommon(protected, h, syncLock) registerCommon(protected, h, syncLock)
protected.GET("/character/list", h.character.GetCharacterList) 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 := protected.Group("/statistics")
stats.GET("/soundtracks/most-played", h.statistics.GetMostPlayedSoundtracks) stats.GET("/soundtracks/most-played", h.statistics.GetMostPlayedSoundtracks)
stats.GET("/soundtracks/least-played", h.statistics.GetLeastPlayedSoundtracks) stats.GET("/soundtracks/least-played", h.statistics.GetLeastPlayedSoundtracks)
+2
View File
@@ -27,6 +27,7 @@ type Server struct {
db Database db Database
q repository.Querier q repository.Querier
music *backend.MusicService music *backend.MusicService
library *backend.LibraryService
sync *backend.SyncService sync *backend.SyncService
characters *backend.CharacterService characters *backend.CharacterService
releases *backend.ReleaseService releases *backend.ReleaseService
@@ -47,6 +48,7 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e
db: database, db: database,
q: q, q: q,
music: backend.NewMusicService(q, cfg.SoundCheckPath), music: backend.NewMusicService(q, cfg.SoundCheckPath),
library: backend.NewLibraryService(q),
characters: characters, characters: characters,
releases: backend.NewReleaseService(cfg.ReleasesURL), releases: backend.NewReleaseService(cfg.ReleasesURL),
stats: backend.NewStatisticsService(q), stats: backend.NewStatisticsService(q),
+57 -10
View File
@@ -52,12 +52,15 @@ func (s *Store) AddSoundtrack(st repository.Soundtrack) repository.Soundtrack {
return st 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 { func (s *Store) AddTestSong(song repository.Song) repository.Song {
defer s.lock()() defer s.lock()()
if song.Uuid == uuid.Nil { if song.Uuid == uuid.Nil {
song.Uuid = uuid.New() 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) s.Songs = append(s.Songs, song)
return 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 }) 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() 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 { 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 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()() defer s.lock()()
for i := range s.Songs { 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++ s.Songs[i].TimesPlayed++
} }
} }
return s.Err 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()() defer s.lock()()
s.Songs = slices.DeleteFunc(s.Songs, func(song repository.Song) bool { if s.Err != nil {
return song.SoundtrackID == arg.SoundtrackID && song.Path == arg.Path return nil, s.Err
}) }
return 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 { func (s *Store) RemoveBrokenSongs(_ context.Context, uuids []uuid.UUID) error {