diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index 9fea4cd..55a4a6b 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -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" } } }, diff --git a/cmd/docs/swagger.json b/cmd/docs/swagger.json index 298343c..7929965 100644 --- a/cmd/docs/swagger.json +++ b/cmd/docs/swagger.json @@ -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" } } }, diff --git a/cmd/docs/swagger.yaml b/cmd/docs/swagger.yaml index 74aefdc..4429f87 100644 --- a/cmd/docs/swagger.yaml +++ b/cmd/docs/swagger.yaml @@ -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 diff --git a/internal/backend/library.go b/internal/backend/library.go new file mode 100644 index 0000000..8b2a38d --- /dev/null +++ b/internal/backend/library.go @@ -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 +} diff --git a/internal/backend/library_integration_test.go b/internal/backend/library_integration_test.go new file mode 100644 index 0000000..2fa7604 --- /dev/null +++ b/internal/backend/library_integration_test.go @@ -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") + } + } +} diff --git a/internal/backend/library_test.go b/internal/backend/library_test.go new file mode 100644 index 0000000..a707ebf --- /dev/null +++ b/internal/backend/library_test.go @@ -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) +} diff --git a/internal/backend/music.go b/internal/backend/music.go index 933516f..6b5485b 100644 --- a/internal/backend/music.go +++ b/internal/backend/music.go @@ -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 } } diff --git a/internal/backend/music_test.go b/internal/backend/music_test.go index 1747db5..e67e0e7 100644 --- a/internal/backend/music_test.go +++ b/internal/backend/music_test.go @@ -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}) +} diff --git a/internal/backend/statistics.go b/internal/backend/statistics.go index 0206323..3eb0ec6 100644 --- a/internal/backend/statistics.go +++ b/internal/backend/statistics.go @@ -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), diff --git a/internal/backend/sync_integration_test.go b/internal/backend/sync_integration_test.go index 2c00d19..0d6c60f 100644 --- a/internal/backend/sync_integration_test.go +++ b/internal/backend/sync_integration_test.go @@ -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"))) diff --git a/internal/backend/sync_test.go b/internal/backend/sync_test.go index d73608f..8f6e727 100644 --- a/internal/backend/sync_test.go +++ b/internal/backend/sync_test.go @@ -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) diff --git a/internal/backend/version.go b/internal/backend/version.go index 650a1e4..38bc462 100644 --- a/internal/backend/version.go +++ b/internal/backend/version.go @@ -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", }, }, { diff --git a/internal/db/queries/song.sql b/internal/db/queries/song.sql index 7ccae1b..e57689d 100644 --- a/internal/db/queries/song.sql +++ b/internal/db/queries/song.sql @@ -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; diff --git a/internal/db/queries/soundtrack.sql b/internal/db/queries/soundtrack.sql index a2fdf6b..1d6a103 100644 --- a/internal/db/queries/soundtrack.sql +++ b/internal/db/queries/soundtrack.sql @@ -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; diff --git a/internal/db/queries/statistics.sql b/internal/db/queries/statistics.sql index a5bfc7a..97c1f23 100644 --- a/internal/db/queries/statistics.sql +++ b/internal/db/queries/statistics.sql @@ -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 diff --git a/internal/db/repository/querier.go b/internal/db/repository/querier.go index 63261d4..f734c63 100644 --- a/internal/db/repository/querier.go +++ b/internal/db/repository/querier.go @@ -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) diff --git a/internal/db/repository/song.sql.go b/internal/db/repository/song.sql.go index f9502cb..652870b 100644 --- a/internal/db/repository/song.sql.go +++ b/internal/db/repository/song.sql.go @@ -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) { diff --git a/internal/db/repository/soundtrack.sql.go b/internal/db/repository/soundtrack.sql.go index 9484e27..c6062fe 100644 --- a/internal/db/repository/soundtrack.sql.go +++ b/internal/db/repository/soundtrack.sql.go @@ -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 ` diff --git a/internal/db/repository/statistics.sql.go b/internal/db/repository/statistics.sql.go index 851062e..c257802 100644 --- a/internal/db/repository/statistics.sql.go +++ b/internal/db/repository/statistics.sql.go @@ -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, diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 7ddb187..e6f0b0d 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -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") diff --git a/internal/server/library_handler.go b/internal/server/library_handler.go new file mode 100644 index 0000000..43572f8 --- /dev/null +++ b/internal/server/library_handler.go @@ -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) +} diff --git a/internal/server/library_handler_test.go b/internal/server/library_handler_test.go new file mode 100644 index 0000000..b22e57a --- /dev/null +++ b/internal/server/library_handler_test.go @@ -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"]) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index be76e75..7df93cc 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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) diff --git a/internal/server/server.go b/internal/server/server.go index 3bfc173..3e04a76 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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), diff --git a/internal/testutil/fakedb/fakedb.go b/internal/testutil/fakedb/fakedb.go index d5751b0..cc76b4f 100644 --- a/internal/testutil/fakedb/fakedb.go +++ b/internal/testutil/fakedb/fakedb.go @@ -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 {