Prefer UUIDs in the API (Phase 3 of the UUID migration)
- 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:
1 parent
8c974a49cc
commit
2c3e97190d
25 files changed
+1521
-117
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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")))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user