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